diff --git a/.codex/scripts/task_guard.sh b/.codex/scripts/task_guard.sh old mode 100755 new mode 100644 index b0ff99cd00..72e122699b --- a/.codex/scripts/task_guard.sh +++ b/.codex/scripts/task_guard.sh @@ -7,15 +7,17 @@ usage() { Usage: task_guard.sh start --id ID --mode MODE --scope PATH [--scope PATH ...] [--adopt-dirty PATH ...] - task_guard.sh check + task_guard.sh check [--cycle] + task_guard.sh replan --reason TEXT --next TEXT + task_guard.sh checkpoint [--document PATH] --message TEXT --next TEXT task_guard.sh finish --verified EVIDENCE --commit-message MESSAGE - task_guard.sh abort task_guard.sh status Modes: quick-fix, standard, assessment, upstream -Exit codes: 0 pass, 2 usage/setup error, +Exit codes: 0 pass, 2 usage/setup error, 3 checkpoint required, 4 safety gate (scope, dirty-file, branch, or HEAD violation), +5 replan required; continue the same task after checkpoint/replan, 6 commit created but recovery-tag creation needs a direct fix and one retry. EOF } @@ -31,6 +33,12 @@ safety_gate() { exit 4 } +replan_required() { + printf 'REPLAN_REQUIRED_CONTINUE: %s\n' "$1" >&2 + printf 'ACTION_REQUIRED: checkpoint if needed, run replan with a materially different next action, then continue the same task.\n' >&2 + exit 5 +} + repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || fail_usage "not inside a Git worktree" cd "$repo_root" @@ -64,34 +72,16 @@ path_matches_file() { } list_dirty() { + local output="$1" { git diff --name-only git diff --cached --name-only git ls-files --others --exclude-standard - } | LC_ALL=C sort -u + } | LC_ALL=C sort -u > "$output" } list_staged() { - git diff --cached --name-only | LC_ALL=C sort -u -} - -check_staged_whitespace() { - local -a paths=() - local path - while IFS= read -r path; do - [[ -n "$path" ]] || continue - [[ "$path" == *.patch ]] && continue - paths+=("$path") - done < <(list_staged) - ((${#paths[@]} == 0)) && return 0 - # Windows CreateProcess 32KB argv 上限:大合并时一次性传全部 staged 路径会 E2BIG。 - # 分批校验保持语义不变(.patch 排除已在上方完成)。 - local -a chunk=() - local i - for ((i = 0; i < ${#paths[@]}; i += 100)); do - chunk=("${paths[@]:i:100}") - git diff --cached --check -- "${chunk[@]}" - done + git diff --cached --name-only | LC_ALL=C sort -u > "$1" } fingerprint_path() { @@ -108,17 +98,24 @@ fingerprint_path() { } read_state() { - if [[ -f "$state_dir/$1" ]]; then - sed -n '1p' "$state_dir/$1" - else - return 1 - fi + [[ -f "$state_dir/$1" ]] || fail_usage "no active task guard state ($1 missing)" + sed -n '1p' "$state_dir/$1" } write_state() { printf '%s\n' "$2" > "$state_dir/$1" } +mode_defaults() { + case "$1" in + quick-fix) printf '%s %s %s\n' 15 4 2 ;; + standard) printf '%s %s %s\n' 30 8 3 ;; + assessment) printf '%s %s %s\n' 45 6 3 ;; + upstream) printf '%s %s %s\n' 45 16 3 ;; + *) return 1 ;; + esac +} + show_status() { [[ -d "$state_dir" ]] || fail_usage "no task guard state" printf 'Task: %s\n' "$(read_state id)" @@ -126,6 +123,8 @@ show_status() { printf 'Status: %s\n' "$(read_state status)" printf 'Branch: %s\n' "$(read_state branch)" printf 'Base HEAD: %s\n' "$(read_state base_head)" + printf 'Cycles: %s/%s\n' "$(read_state cycles)" "$(read_state max_cycles)" + printf 'Start/interval-end: %s / %s\n' "$(read_state start_epoch)" "$(read_state interval_end_epoch)" printf '%s\n' 'Scope:' sed 's/^/ - /' "$state_dir/scope" if [[ -s "$state_dir/adopted" ]]; then @@ -135,10 +134,15 @@ show_status() { } scope_check() { + local enforce_interval="$1" + local count_cycle="$2" local expected_branch local expected_head local current_branch local current_head + local current_dirty="$state_dir/current-dirty.tmp" + local current_staged="$state_dir/current-staged.tmp" + local task_dirty="$state_dir/task-dirty.tmp" local path local saved_hash local current_hash @@ -162,6 +166,8 @@ scope_check() { fi done < "$state_dir/protected-fingerprints" + list_dirty "$current_dirty" + : > "$task_dirty" while IFS= read -r path; do [[ -n "$path" ]] || continue if path_matches_file "$path" "$state_dir/protected"; then @@ -172,22 +178,66 @@ scope_check() { violations=$((violations + 1)) continue fi - done < <(list_dirty) + printf '%s\n' "$path" >> "$task_dirty" + done < "$current_dirty" + LC_ALL=C sort -u "$task_dirty" -o "$task_dirty" + list_staged "$current_staged" while IFS= read -r path; do [[ -n "$path" ]] || continue if path_matches_file "$path" "$state_dir/protected" || ! path_matches_file "$path" "$state_dir/scope"; then printf 'SCOPE_VIOLATION: staged path is not task-owned: %s\n' "$path" >&2 violations=$((violations + 1)) fi - done < <(list_staged) + done < "$current_staged" if ((violations > 0)); then - safety_gate "$violations scope/worktree violation(s); reconcile the declared scope or worktree before continuing" + safety_gate "$violations scope/worktree violation(s); checkpoint and reconcile before continuing" fi - printf 'PASS: task=%s branch/HEAD, protected dirty paths, scope, and staged paths are safe\n' \ - "$(read_state id)" + local changed_count + local max_files + changed_count="$(wc -l < "$task_dirty" | tr -d ' ')" + max_files="$(read_state max_files)" + if ((changed_count > max_files)); then + safety_gate "task-owned changed files $changed_count exceed mode limit $max_files" + fi + + if [[ "$count_cycle" == "1" ]]; then + local cycles + cycles=$(( $(read_state cycles) + 1 )) + write_state cycles "$cycles" + fi + + local now + local start + local interval_end + local threshold + local cycles + local max_cycles + now="$(date +%s)" + start="$(read_state start_epoch)" + interval_end="$(read_state interval_end_epoch)" + threshold="$(read_state checkpoint_threshold_epoch)" + cycles="$(read_state cycles)" + max_cycles="$(read_state max_cycles)" + + if [[ "$enforce_interval" == "1" ]]; then + if ((cycles > max_cycles)); then + replan_required "diagnosis/edit cycles $cycles exceed the current route limit $max_cycles; checkpoint and choose a materially different path" + fi + if ((now >= threshold)); then + local checkpoint_epoch + checkpoint_epoch="$(read_state checkpoint_epoch)" + if ((checkpoint_epoch < threshold)); then + printf 'CHECKPOINT_REQUIRED: 70%% of the review interval has elapsed; persist state before more exploration\n' >&2 + exit 3 + fi + fi + fi + + printf 'PASS: task=%s elapsed=%sm changed=%s/%s cycles=%s/%s\n' \ + "$(read_state id)" "$(( (now - start) / 60 ))" "$changed_count" "$max_files" "$cycles" "$max_cycles" } start_task() { @@ -209,32 +259,28 @@ start_task() { [[ "$id" =~ ^[A-Za-z0-9._-]+$ ]] || fail_usage "task id must use letters, digits, dot, underscore, or hyphen" [[ ${#scopes[@]} -gt 0 ]] || fail_usage "at least one --scope is required" - case "$mode" in - quick-fix|standard|assessment|upstream) ;; - *) fail_usage "unknown mode: $mode" ;; - esac + local defaults + defaults="$(mode_defaults "$mode")" || fail_usage "unknown mode: $mode" - mkdir -p "$state_dir" if [[ -d "$state_dir" && -f "$state_dir/status" ]]; then local previous_status - local previous_id previous_status="$(sed -n '1p' "$state_dir/status")" - previous_id="$(sed -n '1p' "$state_dir/id" 2>/dev/null || true)" if [[ "$previous_status" == "active" || "$previous_status" == "commit_needs_tag" ]]; then - # 只把「有 id 的未完成会话」当作真实占用。写序修复前留下的半成品状态 - # (有 status 无 id)必须被识别为损坏,否则它会永久堵死后续所有 start。 - if [[ -z "$previous_id" ]]; then - fail_usage "task guard state is corrupt (status=$previous_status but no id); run 'task_guard.sh abort' to archive it" - fi - fail_usage "another task guard is unfinished: $previous_id ($previous_status)" + fail_usage "another task guard is unfinished: $(sed -n '1p' "$state_dir/id") ($previous_status)" fi fi - # 会话初始化。status 一律在 start_task 末尾写入:任何中途失败(scope 校验、 - # --adopt-dirty 校验等)都不得留下「active 但无 id」的会话。 - : > "$state_dir/initial-dirty" - : > "$state_dir/initial-staged" - : > "$state_dir/protected" - : > "$state_dir/protected-fingerprints" + mkdir -p "$state_root" + if [[ -d "$state_dir" ]]; then + local archive_dir + local previous_id="partial" + if [[ -f "$state_dir/id" ]]; then + previous_id="$(sed -n '1p' "$state_dir/id")" + fi + archive_dir="$state_root/archive/$previous_id-$(TZ=Asia/Shanghai date +%Y%m%d%H%M%S)-$$" + mkdir -p "$state_root/archive" + mv -- "$state_dir" "$archive_dir" + fi + mkdir -p "$state_dir" : > "$state_dir/scope" : > "$state_dir/adopted" @@ -254,8 +300,8 @@ start_task() { done LC_ALL=C sort -u "$state_dir/adopted" -o "$state_dir/adopted" - list_dirty > "$state_dir/initial-dirty" - list_staged > "$state_dir/initial-staged" + list_dirty "$state_dir/initial-dirty" + list_staged "$state_dir/initial-staged" local adopted_match while IFS= read -r value; do [[ -n "$value" ]] || continue @@ -294,45 +340,94 @@ start_task() { fi done < "$state_dir/initial-dirty" + local minutes max_files max_cycles now + read -r minutes max_files max_cycles <<< "$defaults" + now="$(date +%s)" write_state id "$id" write_state mode "$mode" + write_state status active write_state branch "$(git branch --show-current)" write_state base_head "$(git rev-parse HEAD)" - # 最后一步:只有走到这里会话才真正生效。 - write_state status active + write_state start_epoch "$now" + write_state interval_end_epoch "$((now + minutes * 60))" + write_state checkpoint_threshold_epoch "$((now + minutes * 60 * 70 / 100))" + write_state checkpoint_epoch 0 + write_state max_files "$max_files" + write_state max_cycles "$max_cycles" + write_state cycles 0 show_status printf 'PASS: task guard started; protected %s pre-existing dirty path(s)\n' "$(wc -l < "$state_dir/protected" | tr -d ' ')" } -# 归档一个未完成的会话,使后续 start 不再被堵死。 -# -# 存在的理由:start 在 scope / --adopt-dirty 校验失败时会在写完 id 之前退出。 -# 写序已修为「status 最后写」,但历史遗留或异常中断仍可能留下半成品状态, -# 而脚本原本没有任何退路(只能手工 rm -rf),所以 abort 是必要的逃生口。 -# -# 安全约束:绝不静默丢弃工作。已提交(commit_needs_tag)的会话必须走 finish, -# 否则会丢掉 recovery tag;归档保留全部状态文件供事后查证。 -abort_task() { - (($# == 0)) || fail_usage "abort accepts no options" - [[ -d "$state_dir" ]] || fail_usage "no task guard state to abort" - - local status="" - local id="" - status="$(sed -n '1p' "$state_dir/status" 2>/dev/null || true)" - id="$(sed -n '1p' "$state_dir/id" 2>/dev/null || true)" - - if [[ "$status" == "commit_needs_tag" ]]; then - fail_usage "session $id already committed; run 'finish' to create its recovery tag instead of aborting" +checkpoint_task() { + local document="" + local message="" + local next_action="" + while (($# > 0)); do + case "$1" in + --document) (($# >= 2)) || fail_usage "--document needs a value"; document="$2"; shift 2 ;; + --message) (($# >= 2)) || fail_usage "--message needs a value"; message="$2"; shift 2 ;; + --next) (($# >= 2)) || fail_usage "--next needs a value"; next_action="$2"; shift 2 ;; + *) fail_usage "unknown checkpoint option: $1" ;; + esac + done + [[ -n "$message" && -n "$next_action" ]] || fail_usage "checkpoint message and next action are required" + if [[ -n "$document" ]]; then + document="$(normalize_path "$document")" || fail_usage "unsafe checkpoint document path" + path_matches_file "$document" "$state_dir/scope" || safety_gate "checkpoint document is outside declared scope: $document" + [[ -f "$document" ]] || safety_gate "checkpoint document does not exist: $document" + rg -Fq -- "$message" "$document" || safety_gate "checkpoint document does not contain the supplied completion message" + rg -Fq -- "$next_action" "$document" || safety_gate "checkpoint document does not contain the supplied next action" + else + document="-" fi + scope_check 0 0 + local now + local minutes + now="$(date +%s)" + case "$(read_state mode)" in + quick-fix) minutes=15 ;; + standard) minutes=30 ;; + assessment|upstream) minutes=45 ;; + *) fail_usage "invalid stored mode" ;; + esac + write_state checkpoint_epoch "$now" + write_state start_epoch "$now" + write_state interval_end_epoch "$((now + minutes * 60))" + write_state checkpoint_threshold_epoch "$((now + minutes * 60 * 70 / 100))" + printf '%s\t%s\t%s\t%s\n' "$now" "$document" "$message" "$next_action" >> "$state_dir/checkpoints.log" + printf 'PASS: checkpoint recorded from %s; review cadence reset and task remains active\n' "$document" +} - local label="${id:-corrupt}" - local archive - archive="$state_root/aborted-$(TZ=Asia/Shanghai date +%Y%m%d%H%M%S)-$label" - mv "$state_dir" "$archive" - printf 'PASS: aborted session %s (status=%s); state archived at %s\n' \ - "$label" "${status:-none}" "$(basename "$archive")" - printf 'NOTE: worktree changes were left untouched.\n' +replan_task() { + local reason="" + local next_action="" + while (($# > 0)); do + case "$1" in + --reason) (($# >= 2)) || fail_usage "--reason needs a value"; reason="$2"; shift 2 ;; + --next) (($# >= 2)) || fail_usage "--next needs a value"; next_action="$2"; shift 2 ;; + *) fail_usage "unknown replan option: $1" ;; + esac + done + [[ -n "$reason" && -n "$next_action" ]] || fail_usage "replan reason and next action are required" + scope_check 0 0 + local now + local minutes + now="$(date +%s)" + case "$(read_state mode)" in + quick-fix) minutes=15 ;; + standard) minutes=30 ;; + assessment|upstream) minutes=45 ;; + *) fail_usage "invalid stored mode" ;; + esac + printf '%s\t%s\t%s\n' "$now" "$reason" "$next_action" >> "$state_dir/replans.log" + write_state start_epoch "$now" + write_state interval_end_epoch "$((now + minutes * 60))" + write_state checkpoint_threshold_epoch "$((now + minutes * 60 * 70 / 100))" + write_state checkpoint_epoch "$now" + write_state cycles 0 + printf 'PASS: review interval reset after replan; next=%s\n' "$next_action" } create_recovery_tag() { @@ -358,8 +453,6 @@ create_recovery_tag() { write_state tag_elapsed_seconds "$tag_elapsed" write_state recovery_tag "$tag" write_state status finished - mkdir -p "$state_root" - mv "$state_dir" "$state_root/finished-$(TZ=Asia/Shanghai date +%Y%m%d%H%M%S)-$(read_state id)" if ((tag_elapsed > 5)); then printf 'WARN: recovery tag phase took %ss (target <=5s); tag already created, continue without repeated validation\n' "$tag_elapsed" >&2 fi @@ -378,42 +471,38 @@ finish_task() { done [[ -n "$verified" && -n "$commit_message" ]] || fail_usage "verification evidence and commit message are required" - local committed_head - committed_head="$(read_state committed_head 2>/dev/null || true)" - if [[ "$(read_state status)" == "commit_needs_tag" ]] || - { [[ -n "$committed_head" ]] && [[ "$(git rev-parse HEAD)" == "$committed_head" ]]; }; then + if [[ "$(read_state status)" == "commit_needs_tag" ]]; then create_recovery_tag "$(read_state committed_head)" "$(read_state verification)" return fi - scope_check + scope_check 0 0 + local task_dirty="$state_dir/task-dirty.tmp" + [[ -s "$task_dirty" ]] || safety_gate "no task-owned changes to commit" local path - local task_change_count=0 while IFS= read -r path; do [[ -n "$path" ]] || continue - path_matches_file "$path" "$state_dir/protected" && continue - path_matches_file "$path" "$state_dir/scope" || continue - if [[ -e "$path" || -L "$path" ]]; then - git add -A -- "$path" - else - git update-index --remove -- "$path" - fi - task_change_count=$((task_change_count + 1)) - done < <(list_dirty) - ((task_change_count > 0)) || safety_gate "no task-owned changes to commit" + git add -A -- "$path" + done < "$task_dirty" - check_staged_whitespace || safety_gate "staged non-patch diff failed whitespace validation" - local staged_count=0 + git diff --cached --check || safety_gate "staged task diff failed git diff --cached --check" + local staged="$state_dir/finish-staged.tmp" + list_staged "$staged" + [[ -s "$staged" ]] || safety_gate "task changes did not produce a staged diff" while IFS= read -r path; do [[ -n "$path" ]] || continue - staged_count=$((staged_count + 1)) if ! path_matches_file "$path" "$state_dir/scope" || path_matches_file "$path" "$state_dir/protected"; then safety_gate "automatic commit would include non-task path: $path" fi - done < <(list_staged) - ((staged_count > 0)) || safety_gate "task changes did not produce a staged diff" + done < "$staged" - git commit -m "$commit_message" -m "Verification: $verified" -m "Task-Guard: $(read_state id)" + local message_file="$state_dir/commit-message.txt" + { + printf '%s\n\n' "$commit_message" + printf 'Verification: %s\n' "$verified" + printf 'Task-Guard: %s\n' "$(read_state id)" + } > "$message_file" + git commit -F "$message_file" local commit commit="$(git rev-parse HEAD)" write_state committed_head "$commit" @@ -429,9 +518,17 @@ shift case "$command_name" in start) start_task "$@" ;; - check) (($# == 0)) || fail_usage "check accepts no options"; scope_check ;; + check) + count_cycle=0 + if (($# > 0)); then + [[ "$1" == "--cycle" && $# == 1 ]] || fail_usage "check accepts only --cycle" + count_cycle=1 + fi + scope_check 1 "$count_cycle" + ;; + checkpoint) checkpoint_task "$@" ;; + replan) replan_task "$@" ;; finish) finish_task "$@" ;; - abort) abort_task "$@" ;; status) (($# == 0)) || fail_usage "status accepts no options"; show_status ;; -h|--help|help) usage ;; *) fail_usage "unknown command: $command_name" ;; diff --git a/.codex/skills/upstream-integration-governor/SKILL.md b/.codex/skills/upstream-integration-governor/SKILL.md index 66d524b443..302f2c31c2 100644 --- a/.codex/skills/upstream-integration-governor/SKILL.md +++ b/.codex/skills/upstream-integration-governor/SKILL.md @@ -1,6 +1,6 @@ --- name: upstream-integration-governor -description: Inventory related upstream repositories, generate or refresh exhaustive commit-ledger assessment documents, and evaluate or implement dependency integrations safely, efficiently, and reversibly. Use when Codex is asked to inspect, compare, assess, port, merge, cherry-pick, rebase, rebuild, or update upstream commits, forks, locks, patches, AARs, native libraries, or binary dependency packages—especially WebHTV FFmpeg, AndroidX media/Media3/nextlib, mpv, mpv-android, libplacebo, IJK, JNI, Exo, MPV, or cross-repository player work; also use for regression, performance, provenance, rollback, commit/tag, staged rollout, and durable checkpoint planning around those changes. +description: Evaluate and implement upstream dependency integrations safely, efficiently, and reversibly. Use when Codex is asked to inspect, compare, port, merge, cherry-pick, rebase, rebuild, or update upstream commits, forks, locks, patches, AARs, native libraries, or binary dependency packages—especially WebHTV FFmpeg, AndroidX media/Media3/nextlib, mpv, mpv-android, libplacebo, IJK, JNI, Exo, MPV, or cross-repository player work; also use for regression, performance, provenance, rollback, commit/tag, staged rollout, and durable checkpoint planning around those changes. --- # Upstream Integration Governor @@ -24,36 +24,6 @@ Start `bash .codex/scripts/task_guard.sh` using the matching lane. Check it afte - A started task owns exactly one `docs/-.md`. Put research, alternatives, recommendation, approval, implementation, repeated corrections, validation, full commit/tag records, rollback, current status, and the one next action in that file. - Never create additional `plans/`, assessment, implementation, fix, or dated continuation documents for the same task. Continue appending to its unique file. The master assessment is the only exception and only supplies the cross-task index and complete commit ledger. -## Build or refresh an upstream assessment ledger - -Use this mode when the user asks to梳理 new upstream updates, rebuild the player dependency merge plan, or produce a document comparable to the existing dated assessment. - -1. Discover the repository graph from the prior assessment, `.gitmodules`, lock files, source/version manifests, Gradle/native build scripts, patch directories, artifact provenance, and repository documentation. Include direct upstreams, WebHTV forks, dependency/source repositories, and packaging repositories that can change the shipped player behavior; record why each repository is in scope and how revisions flow between them. -2. Freeze exact local and upstream baselines before judging commits: repository URL/path, branch, previous assessed head, current head, ancestry, relevant lock or artifact hash, access date, and any proxy or source-access limitation. Never infer a missing hash from conversation memory. -3. Enumerate the complete commit range in each repository using full 40-character IDs. Inspect actual diffs, parents, tests, follow-ups, reverts, rebases/equivalents, downstream consumers, and final-tree behavior. Give every commit one explicit disposition such as already implemented, partially implemented, superseded, irrelevant, candidate, blocked, or implemented; do not omit merge, cleanup, or low-value commits merely because they are inconvenient. -4. Map cross-repository chains rather than presenting isolated lists. Show which source commit feeds which fork/lock/patch/artifact/App change, which commits are companions, and which ordering or binary rebuild dependency makes them one functional stage. -5. Compare the ledger with current WebHTV code and Git history before creating tasks. Existing local behavior is the baseline contract; detect equivalent implementations, later fixes, partial coverage, and code paths that make an upstream change unreachable. Mark covered or meaningless work as ignored instead of manufacturing a task. -6. Preserve a closed historical assessment. When a materially new upstream wave begins, create `docs/upstream-player-dependency-merge-assessment-YYYY-MM-DD.md`, link its predecessor, and record the new baselines; when continuing the same active wave, update its existing dated document. The document must remain the exhaustive cross-task index and link each actionable item to its single `docs/-.md` file. -7. Organize actionable work into reversible `Exo`, `MPV`, and `common` stages with the selection order `Exo -> MPV -> common`. For every stage record the user-visible playback capability, repository/commit chain, current coverage, benefits, risks and disadvantages, compatibility/performance/package-size impact, best-practice evidence, WebHTV adaptation, recommendation, acceptance criteria, verification, rollout, rollback, and next action. - -For a request that only asks for the next task, do not mutate the ledger or code. Recover the current state, identify exactly one unambiguous next candidate, and return the following decision packet in ordinary user-facing language: - -```text -任务编号和名称: -所属分类:Exo / MPV / 通用 -要实现的实际能力: -当前项目已有实现: -涉及仓库及完整 commit ID: -收益: -缺点与风险: -与现有功能的关系: -建议:实施 / 暂缓 / 忽略 -最小实施步骤: -预计需要的验证: -``` - -Stop after that packet. Do not implement until the user explicitly approves the candidate. When estimating approved work, report current-agent wall-clock execution time and separate code work, native/Gradle build time, device or user-dependent validation, and commit/tag/document closure when those phases apply. - ## Mandatory best-practice review before implementation This gate applies to every proposed upstream merge stage and every material new player/dependency requirement. It is not optional merely because a commit appears small or its upstream title sounds authoritative. diff --git a/.github/scripts/publish-oci-apks.sh b/.github/scripts/publish-oci-apks.sh deleted file mode 100644 index cedb6ee11f..0000000000 --- a/.github/scripts/publish-oci-apks.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -dist_dir="${DIST_DIR:-dist}" -metadata_file="${OCI_METADATA_FILE:-build/oci-metadata.json}" -release_tag="${RELEASE_TAG:-}" -repository_input="${OCI_REPOSITORY:-}" -username="${OCI_USERNAME:-}" -token="${OCI_TOKEN:-}" - -mkdir -p "$(dirname "$metadata_file")" -printf '{}\n' > "$metadata_file" - -if [[ -z "$repository_input" || -z "$username" || -z "$token" || -z "$release_tag" ]]; then - echo "OCI publishing requested but vars.OCI_REPOSITORY, OCI credentials, or RELEASE_TAG are missing." >&2 - exit 1 -fi - -if ! command -v oras >/dev/null 2>&1; then - echo "oras CLI is required" >&2 - exit 1 -fi - -repository_input="${repository_input#https://}" -repository_input="${repository_input#http://}" -repository_input="${repository_input%/}" -first_segment="${repository_input%%/*}" -if [[ "$repository_input" != */* ]]; then - login_registry="docker.io" - api_registry="registry-1.docker.io" - repository="$repository_input" -elif [[ "$first_segment" == *.* || "$first_segment" == *:* || "$first_segment" == "localhost" ]]; then - login_registry="$first_segment" - api_registry="$first_segment" - repository="${repository_input#*/}" - if [[ "$login_registry" == "docker.io" || "$login_registry" == "registry-1.docker.io" ]]; then - login_registry="docker.io" - api_registry="registry-1.docker.io" - fi -else - login_registry="docker.io" - api_registry="registry-1.docker.io" - repository="$repository_input" -fi - -if [[ ! "$repository" =~ ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$ ]]; then - echo "Invalid OCI repository: $repository" >&2 - exit 1 -fi - -printf '%s' "$token" | oras login "$login_registry" --username "$username" --password-stdin - -records="$(mktemp)" -trap 'rm -f "$records"' EXIT - -shopt -s nullglob -apks=("$dist_dir"/*.apk) -if [[ ${#apks[@]} -eq 0 ]]; then - echo "No APK files found in $dist_dir" >&2 - exit 1 -fi - -for apk in "${apks[@]}"; do - filename="$(basename "$apk")" - base="${filename%.apk}" - tag="${release_tag#v}-${base}" - reference="$login_registry/$repository:$tag" - layer_digest="sha256:$(sha256sum "$apk" | awk '{print $1}')" - size="$(stat -c '%s' "$apk")" - manifest_digest="$(oras push \ - --artifact-type application/vnd.webhtv.apk.v1 \ - --annotation "org.opencontainers.image.title=$filename" \ - --annotation "org.opencontainers.image.version=$release_tag" \ - --format go-template='{{.digest}}' \ - "$reference" \ - "$apk:application/vnd.android.package-archive")" - verified_digest="$(oras manifest fetch --descriptor "$reference" | python3 -c 'import json, sys; print(json.load(sys.stdin)["digest"])')" - if [[ "$manifest_digest" != "$verified_digest" || ! "$manifest_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "OCI manifest verification failed for $filename" >&2 - exit 1 - fi - printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$base" "$api_registry" "$repository" "$tag" "$manifest_digest" "$layer_digest:$size" >> "$records" -done - -python3 - "$records" "$metadata_file" <<'PY' -import json -import sys - -records, output = sys.argv[1:] -data = {} -with open(records, encoding="utf-8") as fh: - for line in fh: - base, registry, repository, reference, manifest_digest, layer = line.rstrip("\n").split("\t") - layer_digest, size = layer.rsplit(":", 1) - data[base] = { - "registry": registry, - "repository": repository, - "reference": reference, - "manifestDigest": manifest_digest, - "layerDigest": layer_digest, - "size": int(size), - } -with open(output, "w", encoding="utf-8") as fh: - json.dump(data, fh, ensure_ascii=False, indent=2) - fh.write("\n") -PY - -echo "Published ${#apks[@]} APK artifacts to $login_registry/$repository" diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index ba6af52128..78882a80fa 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -30,11 +30,6 @@ on: required: false default: false type: boolean - publish_oci: - description: "Publish APKs as OCI artifacts when Registry variables and secrets are configured" - required: false - default: true - type: boolean permissions: contents: write @@ -80,8 +75,6 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@v3 - with: - packages: platform-tools - name: Install Android packages run: | @@ -214,26 +207,6 @@ jobs: cp Release/apk/*.apk dist/ ls -lh dist/ - - name: Set up ORAS - id: oras - if: ${{ inputs.publish_oci }} - uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1 - with: - url: https://github.com/oras-project/oras/releases/download/v1.3.4/oras_1.3.4_linux_amd64.tar.gz - checksum: f27adb935022d94df8dc77719c322dda592c78a0d57a6f7dcdd8d900b248c454 - - - name: Publish APKs to OCI Registry - id: oci - if: ${{ inputs.publish_oci && steps.oras.outcome == 'success' }} - env: - DIST_DIR: dist - OCI_METADATA_FILE: build/oci-metadata.json - RELEASE_TAG: ${{ steps.meta.outputs.tag }} - OCI_REPOSITORY: ${{ vars.OCI_REPOSITORY }} - OCI_USERNAME: ${{ secrets.OCI_USERNAME }} - OCI_TOKEN: ${{ secrets.OCI_TOKEN }} - run: bash .github/scripts/publish-oci-apks.sh - - name: Prepare release notes input id: release_notes env: @@ -267,7 +240,6 @@ jobs: env: RELEASE_NOTES: ${{ steps.release_notes.outputs.notes }} GITHUB_RELEASE_DOWNLOAD_BASE: https://github.com/${{ github.repository }}/releases/download/${{ steps.meta.outputs.tag }} - OCI_PUBLISH_OUTCOME: ${{ steps.oci.outcome }} run: | python3 - <<'PY' import glob @@ -278,33 +250,19 @@ jobs: tag = "${{ steps.meta.outputs.tag }}" notes = os.environ.get("RELEASE_NOTES", "").strip() release_download_base = os.environ["GITHUB_RELEASE_DOWNLOAD_BASE"] - oci = {} - oci_metadata = "build/oci-metadata.json" - if os.environ.get("OCI_PUBLISH_OUTCOME") == "success" and os.path.isfile(oci_metadata): - with open(oci_metadata, encoding="utf-8") as fh: - oci = json.load(fh) for apk in glob.glob("dist/*.apk"): base = os.path.splitext(os.path.basename(apk))[0] with open(apk, "rb") as fh: content = fh.read() data = { "name": tag, - "versionName": "${{ steps.meta.outputs.version_name }}", "desc": f"{tag} - ${{ steps.meta.outputs.short_sha }}", "channel": "${{ steps.meta.outputs.channel }}", "code": int("${{ steps.meta.outputs.version_code }}"), "apk": f"{release_download_base}/{base}.apk", "size": len(content), "sha256": hashlib.sha256(content).hexdigest(), - "downloads": { - "github": {"url": f"{release_download_base}/{base}.apk"}, - }, } - if base in oci: - descriptor = oci[base] - if descriptor.get("size") != len(content) or descriptor.get("layerDigest") != "sha256:" + data["sha256"]: - raise SystemExit(f"OCI descriptor does not match {base}.apk") - data["downloads"]["oci"] = descriptor if notes: data["notes"] = notes with open(f"dist/{base}.json", "w", encoding="utf-8") as fh: diff --git a/.gitignore b/.gitignore index 79b5200be5..514c6b00fa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,13 @@ out/ !third_party/mpv-native-overrides/aimagereader-stable/video/out/ !third_party/mpv-native-overrides/aimagereader-stable/video/out/hwdec/ !third_party/mpv-native-overrides/aimagereader-stable/video/out/hwdec/hwdec_aimagereader_vk_stable.c +!third_party/mpv-native-overrides/aimagereader-v556/video/out/ +!third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/ +third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/* +!third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_vk.c +!third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_vk.h +!third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader.comp +!third_party/mpv-native-overrides/aimagereader-v556/video/out/hwdec/hwdec_aimagereader_comp.h Release/ *.apk *.aab diff --git a/AGENTS.md b/AGENTS.md index 432ecfd9b6..ec89f14f50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,10 @@ # WebHTV Agent Contract -This file applies to the whole repository. Keep work correct, narrow, reversible, and fast. A nested `AGENTS.md` may add path-specific rules but may not weaken the safety, scope, or rollback rules here. +This file applies to the whole repository. Keep work correct, narrow, reversible, and fast. A nested `AGENTS.md` may add path-specific rules but may not weaken the safety, scope, time, or rollback rules here. ## 1. Start with a bounded lane -Before the first edit, state one completion sentence, the allowed paths, protected pre-existing dirty paths, the current local time and timezone, a realistic total-duration estimate with expected finish time, and the cheapest decisive verification. For multi-phase work, give a short estimate for each phase. Treat these estimates as execution targets rather than guard gates; when the estimate is reached or slips materially, stop optional work, state the cause, and continue with the narrowest completion path or a materially different shortest route. Do not let a stale estimate justify repeated checks, open-ended research, or an unfinished handoff. Use the smallest applicable lane: - -Estimate elapsed wall-clock time for the current Codex agent in this workspace, not the time a human engineer or team would need. Base the estimate on the actual repository state, available tools, warm caches, ABI/build scope, device availability, and unavoidable network/build/user wait; split those phases when they materially differ and give the expected local finish time. Do not quote person-days or staffing estimates unless the user explicitly asks for them. +Before the first edit, state one completion sentence, the allowed paths, protected pre-existing dirty paths, and the cheapest decisive verification. Use the smallest applicable lane: ### Governance-maintenance fast path @@ -18,14 +16,20 @@ When the task only edits `AGENTS.md`, `.codex/skills/**`, `.codex/scripts/**`, o - The repository task guard is not required for maintenance of the guard itself or its instruction files. Preserve unrelated dirty files and do not commit/tag unless the user explicitly requests it or this maintenance is already an isolated task-owned change. - Maximum normal tool sequence: one inspection call, one patch call, one combined validation call. A fourth call requires a failed validation or a concrete blocker, and the reason must be stated before running it. +| Lane | Efficiency-review cadence | File limit per logical unit | Same-route cycle threshold | +| --- | ---: | ---: | --- | +| `quick-fix` | 15 minutes | 4 task-owned files | 2 diagnosis cycles | +| `standard` | 30 minutes | 8 task-owned files | 3 diagnosis cycles | +| `assessment` | 45 minutes per batch | 6 task-owned files | one bounded evidence question or commit cluster | +| `upstream` | 45 minutes per approved unit | 16 task-owned files | one approved functional unit | + - A small bug is `quick-fix` by default. Do not promote it to architecture, broad research, or native work merely because more investigation is possible. -- Lane names describe risk and workflow only. They do not impose elapsed-time, changed-file, cycle-count, checkpoint, or replan gates. +- A simple rule/document/Skill edit should finish in a few minutes; 5 minutes triggers immediate removal of every optional step, and 10 minutes is already abnormal. A localized bug should also take the shortest evidence-backed path. These are efficiency escalation points, not permission to abandon incomplete work: after escalation perform only the necessary edit, one cheapest decisive verification, commit/tag when authorized, and hand off. Continue longer only when a named required operation inherently takes longer or a concrete blocker prevents completion; state that reason immediately. +- These cadences are progress-review reminders, never task budgets, deadlines, or caps on total duration/review count. At a boundary, checkpoint and judge convergence: continue a productive route with one bounded next action, or replace an unproductive/repeated route. The task remains active until complete or genuinely blocked. - Optimize for shortest elapsed time by removing redundant exploration, repeated commands, speculative scope, and low-value validation. Never gain speed by dropping required behavior, risk-driven verification, rollback, or the requested completion target. -- Under a user-stated time constraint, never repeat a successful or otherwise conclusive check, and never expand research after the available evidence can decide the approved implementation. A retry requires a relevant edit or an inconclusive result; broader research requires one named unresolved fact that can materially change the decision. -- When the user states a time budget or deadline, treat it as the execution target for the current turn and reserve roughly the final quarter for verification, documentation, commit, tag, and handoff. Choose one shortest evidence-backed route, run each expensive build/test at most once unless a relevant edit or an inconclusive result requires a retry, and stop optional research or cleanup before the budget is exhausted. Keep all code, lock, artifact, and task-document changes for one approved unit in one guard session and one commit when possible; only make a second documentation-only closure commit when an unavoidable post-commit ID/tag must be recorded, with no extra build or research pass. This routing rule never lowers correctness, rollback, or completion requirements. -- Classify command failures before asking for approval: a repository file-mode or invocation error is fixed by using the correct in-scope invocation once (for example `bash ./gradlew`), while sandbox/network/permission errors are the cases that warrant an escalation request. Do not spend a second attempt or approval round on the wrong failure class. - Do not widen declared behavior or paths without explicit user approval. Split genuinely large work into independently useful units while preserving the original completion target. -- Except for the governance-maintenance fast path above, run `bash .codex/scripts/task_guard.sh start --id --mode --scope ...` before code edits. `check` is an optional read-only safety audit; it is not required after each cycle, and `finish` performs the final safety check itself. If a session is left unfinished without a commit (or its state is corrupt), `abort` archives that state so later `start` calls are not blocked; it never discards worktree changes, and it refuses a session that already committed, because that one must go through `finish` to receive its recovery tag. +- Except for the governance-maintenance fast path above, run `bash .codex/scripts/task_guard.sh start --id --mode --scope ...` before code edits. Run `check` after each diagnosis/edit cycle and before any expensive command. +- A nonzero guard result is a gate, not task termination. On `CHECKPOINT_REQUIRED`, persist state, then continue. On `REPLAN_REQUIRED_CONTINUE`, record why the current route is not converging, choose a materially different shortest path, run `replan`, and immediately continue the same task. There is no maximum number of replans. - A timeout may bound one potentially hung command; it must not bound the task. Diagnose the timeout and continue with a safer, narrower, or resumable command. ## 2. Scope is closed by default @@ -33,7 +37,7 @@ When the task only edits `AGENTS.md`, `.codex/skills/**`, `.codex/scripts/**`, o - Modify only the declared paths and behavior needed for the completion sentence. No unrelated refactor, cleanup, formatting sweep, dependency upgrade, generated-file churn, or speculative abstraction. - A failing unrelated test, warning, or nearby defect is not part of the task. Report it; do not fix it unless it blocks the requested result and the user approves the expansion. - Preserve every file dirty before task start. Never include an initially dirty path in task scope or a task commit unless the user explicitly assigns that work and it has been isolated safely. -- If the proposed fix crosses a new module, changes public behavior/API, or changes dependency or binary ownership, narrow it to the smallest useful unit. Ask the user only when that unit expands the authorized behavior or paths. +- If the proposed fix crosses a new module, changes public behavior/API, changes dependency or binary ownership, or exceeds the current logical-unit limits, checkpoint and replan the smallest next unit. Ask the user only when that next unit expands the authorized behavior or paths. ## 3. Search and diagnosis must earn their cost @@ -42,7 +46,7 @@ When the task only edits `AGENTS.md`, `.codex/skills/**`, `.codex/scripts/**`, o - Expand search only when the current evidence cannot decide the next action. Use at most two query reformulations for a quick fix and three for one material upstream question; prefer primary code/docs/issues over repetitive posts. - Capture a command's full output once and filter/read that saved result. Do not rerun the same test or build with different output filters. - Run each final syntax/lint/test check once. Repeat it only after a relevant edit or when the first result is inconclusive; never re-check an unchanged successful result for reassurance. -- After two failed attempts with the same hypothesis, discard it and choose a materially different path. Repetition without new evidence is prohibited; task completion remains required. +- After two failed attempts with the same hypothesis, discard it and replan a materially different path. Repetition without new evidence is prohibited; task completion remains required. ## 4. Verification is risk-based and minimal @@ -52,28 +56,22 @@ When the task only edits `AGENTS.md`, `.codex/skills/**`, `.codex/scripts/**`, o - Do not weaken a failing gate. Classify the failure as regression, environment, or stale expectation. Expand work only if fixing it is within the declared scope. - Quality floor: do not trade away existing behavior, correctness, security, compatibility, material performance, or task completion merely to meet the clock. Time pressure removes redundant work; it never authorizes an unverified shortcut. -## 5. Context recovery +## 5. Context and elapsed-time recovery -- Create a durable checkpoint only when a task is genuinely likely to cross a session/compaction boundary, before a risky long-running operation, or when changing functional stage/repository. Never require it because of elapsed minutes, cycle count, or changed-file count. +- Codex cannot reliably inspect its exact remaining context tokens. Use hard proxies: elapsed time, completed cycles, changed-file count, and anticipated output size. +- Checkpoint after every verified logical unit, at 70% of a review cadence, before a command likely to run over 10 minutes or emit over roughly 200 lines, and before changing functional stage/repository. - A checkpoint records objective, lane/scope, branch/HEAD, protected dirty paths, completed evidence, files changed, verification/result, unresolved risk, rollback anchor, and exactly one next action. -- For long-lived work, update the relevant tracked document with `apply_patch`; the task guard does not own or force progress checkpoints. -- For a task expected to survive session/compaction, create one task-owned `docs/-.md`. Keep a short top or tail `Recovery anchor` containing the objective and acceptance criteria, plan status, current files/symbols, completed actions and results, unverified worktree edits, unresolved risks, and exactly one next action. -- Update the recovery anchor when material state changes or immediately before a likely compaction/long-running operation. The conversation plan is transient; the task document is the durable execution state. -- After compaction or resumption, recover in this order: workspace and Git evidence (`git status`, branch/HEAD, scoped diff) -> the latest recovery anchor/task document -> the compaction summary -> older conversation plan. Reconcile discrepancies and preserve existing unverified changes. -- Once recovery is reconciled, execute the documented single next action. Do not restart planning, repeat completed repository-wide searches, reread completed code paths, or discard partial edits unless the workspace or task document proves they are invalid. Never reconstruct exact hashes or decisions from memory. +- For long-lived work, update the relevant tracked document with `apply_patch`, then run `bash .codex/scripts/task_guard.sh checkpoint --document --message --next `. +- After compaction or resumption, read the latest checkpoint and reconcile it with `git status`, branch, and HEAD before doing new work. Never reconstruct exact hashes or decisions from memory. ## 6. Commit and recovery tag are part of code completion - One task guard session equals one atomic logical change. If a task needs another logical commit, finish this unit and start a new guard session. - A code change is not complete until its targeted verification is recorded, its task-owned files are committed atomically, and that commit has a unique annotated local recovery tag. -- **Explicit closure fast path:** when the user confirms the tested behavior is acceptable (for example, “没问题”, “通过”, or equivalent) and asks to commit or create a tag, that confirmation closes optional verification for the approved scenario. Stop immediately; do not capture more logs or screenshots, run adjacent-media/device cases, start another build/test, inspect upstream, perform reassurance checks, or broaden documentation before closing. -- After an explicit closure request, the next tool call must perform the closure unless one concrete unknown safety fact makes that impossible. The normal sequence is at most: one minimal patch to the existing task document only when required to record already-known results, then one `task_guard.sh finish` call that commits and tags, then report the commit and tag. Do not run a separate guard `check`, status/diff review, or tag verification because `finish` already enforces scope and tag creation. If the verified change is already committed, create the requested annotated tag immediately without reopening documentation. -- Treat this as a latency-critical execution target: normally complete the requested local commit/tag within 60 seconds and start the tag-producing command in the first possible tool round trip. A direct command failure or genuine safety blocker is the only reason to exceed this target; report and fix only that direct cause. User confirmation never authorizes skipping an actually failed required gate, but a missing optional or neighboring test is not a blocker after the user accepts the observed behavior. -- When the same message also authorizes a push, push only the named/current branch and newly created tag after local closure. Do not fetch, compare, or revalidate first unless the push itself reports a conflict or rejection. - Finish with `bash .codex/scripts/task_guard.sh finish --verified --commit-message `. The script must stage only task-owned paths and create `recovery//`; never hand-stage unrelated dirty work. - After the commit succeeds, create its recovery tag immediately with one local, non-interactive `git tag -a` command with tag signing disabled. The tag phase should normally finish within 5 seconds; do not insert builds, tests, searches, network calls, repeated diff reviews, or redundant tag checks between commit and tag. - Treat a successful tag command as sufficient. If it fails, capture the error once, fix its direct cause, and retry only after the command materially changes; never loop an unchanged tag command. -- If verification fails, scope overlaps pre-existing dirty work, or a safe atomic commit cannot be formed, do not commit/tag. Record durable state when needed and report the exact blocker. +- If verification fails, scope overlaps pre-existing dirty work, or a safe atomic commit cannot be formed, do not commit/tag. Checkpoint and report the exact blocker. - Assessment-only work does not authorize production edits. Tags point only to committed, verified states. Never push commits/tags, move a published tag, rewrite history, or publish artifacts without explicit authorization. ## 7. Best-practice design research @@ -84,17 +82,6 @@ The mandatory design-research gate below applies repository-wide to every materi For FFmpeg, media/Media3/nextlib, Exo, mpv, mpv-android, libplacebo, JNI, native binaries, locks, patches, or binary packaging, read and follow `.codex/skills/upstream-integration-governor/SKILL.md` and only the references it marks for the current lane. -`.codex/skills/upstream-integration-governor/` is the runtime canonical copy for this repository. `webhome-devkit/skills/upstream-integration-governor/` is a reference/distribution mirror for other developers and is not an additional instruction authority or loading path. Whenever the canonical Skill changes, update the mirror in the same governance-maintenance task and validate that the mirrored files are byte-identical; never let the two copies evolve independently. - -### New-session routing for the upstream merge plan - -- The user does not need to paste a bootstrap prompt. Treat short requests such as `下一个任务`, `继续上游播放器依赖合并计划`, `梳理新的上游更新`, or equivalent wording as sufficient invocation of the upstream Skill. The expected branch for this repository plan is `fongmi-sync`; inspect the actual branch/HEAD and preserve every pre-existing dirty path before any mutation. -- Unless the newest user message explicitly approves implementation, the first response is assessment-only and read-only. Fully read `README.md`, this file, the active `docs/upstream-player-dependency-merge-assessment-*.md`, and only the task documents directly related to the candidate. Use the assessment ledger, Git history, and current code to choose the next genuinely unfinished and unimplemented task in the order `Exo -> MPV -> common`. -- Before proposing work, determine whether another commit already implemented it, partially covered it, superseded it, or made it irrelevant. Recommend `忽略` directly when it is already covered or has no current product value; do not relabel it as new implementation work. -- Explain the user-visible playback capability in ordinary language. Include all related repositories and full 40-character commit IDs, cross-repository relationships, benefits, disadvantages and risks, effects on existing behavior, compatibility, performance and package size, best-practice status, required WebHTV adaptation, a clear `实施`/`暂缓`/`忽略` recommendation, and the smallest staged implementation and verification plan. -- Use this first-response shape and then stop for explicit approval: `任务编号和名称`, `所属分类`, `要实现的实际能力`, `当前项目已有实现`, `涉及仓库及完整 commit ID`, `收益`, `缺点与风险`, `与现有功能的关系`, `建议`, `最小实施步骤`, `预计需要的验证`. Do not modify code, locks, patches, artifacts, or runtime behavior during this selection response. -- A later explicit `开始实施` authorizes only the unambiguous currently recommended task. Treat all existing behavior, compatibility and material performance as acceptance contracts: the implementation must not knowingly cause direct or indirect regressions or reduce current performance. Adapt or reject the upstream design when needed, use the narrowest risk-proportionate verification, and do not claim completion while a material regression risk remains unresolved. - - Assessment and implementation are separate authorities. Enumerate every in-scope upstream commit with a full 40-character ID and a disposition; never implement an unapproved stage. - Group related cross-repository behavior into independently reversible `Exo`, `MPV`, or `common` stages. Preserve the decision/implementation order `Exo -> MPV`. - Continue the active assessment from `docs/upstream-player-dependency-merge-assessment-2026-08-20.md`; do not redo completed analysis unless source heads or material evidence changed. @@ -115,4 +102,4 @@ Before changing code, locks, build scripts, patches, artifacts, or runtime behav ## 9. Enforcement boundary -`AGENTS.md` is an instruction layer, not a security boundary. The task guard only enforces declared paths, protected initial dirty files, branch/HEAD stability, atomic task commits, and recovery tags when invoked. It does not enforce elapsed time, cycle counts, changed-file limits, checkpoints, replans, or state archives. Git hooks are not the primary mechanism because ordinary clones do not reliably enable tracked hooks and client hooks can be bypassed. CI remains the correct place for organization-wide mandatory checks. +`AGENTS.md` is an instruction layer, not a security boundary. The task guard can enforce progress reviews, declared paths, protected dirty files, atomic task commits, and recovery tags only when invoked. It must never convert elapsed time, token estimates, review count, or cycle count into permission to abandon a task or lower its completion criteria. Git hooks are not the primary mechanism because ordinary clones do not reliably enable tracked hooks and client hooks can be bypassed. CI remains the correct place for organization-wide mandatory checks. diff --git a/FFMPEG_MODE_SWITCH.md b/FFMPEG_MODE_SWITCH.md new file mode 100644 index 0000000000..95226693df --- /dev/null +++ b/FFMPEG_MODE_SWITCH.md @@ -0,0 +1,247 @@ +# FFmpeg 模式切换功能 + +## 功能说明 + +用户现在可以在设置中切换**四种**播放器渲染器实现: +- **NextLib**(手机 + TV 默认):使用第三方 `nextlib-media3ext` 库的自定义 FFmpeg 音频/视频渲染器 +- **Simple**:使用默认视频渲染路径,不使用 NextLib 视频渲染器;保留 FFmpeg 音频兜底 +- **Official**:仅使用 Media3 官方渲染器(保留自定义缓冲配置) +- **自动**:仅 EXO 内核生效。播放失败时在**当前解码**下自动遍历 NextLib → Simple → Official,三种都失败后再交给现有的解码/内核降级链(硬解→软解→换内核)。遍历不写盘,仅进程内临时覆盖;退出重播或换片后重置。 + +## 使用方法 + +### Leanback(电视端) +设置 → 播放器设置 → FFmpeg模式 → 点击循环切换 + +### Mobile(手机端) +设置 → 播放器 → FFmpeg模式 → 点击循环切换 + +显示:`NextLib` → `Official` → `Simple` → `自动` → `NextLib` ... +**注意**:切换后需退出视频重新播放才生效 + +**默认模式**:手机端与 TV 端均为 NextLib + +--- + +## 🎯 推荐使用顺序 + +**如果遇到卡顿掉帧,请按此顺序尝试:** + +### 1. **Simple 模式** +✅ 默认视频渲染路径接近 TV 项目 +✅ 无自定义 FFmpeg 视频渲染器 +✅ 保留 FFmpeg 音频兜底,避免系统解码器不支持的 iOS/HLS 音轨无声 +✅ 无自定义缓冲配置(使用 ExoPlayer 默认值) +✅ 最简单、最稳定 + +### 2. **NextLib 模式**(手机 + TV 默认) +⚠️ 使用第三方 FFmpeg 渲染器 +⚠️ 自定义缓冲配置 +⚠️ 最复杂,可能存在兼容性问题 +⚠️ 开始流畅后可能卡顿(过度缓冲) + +### 3. **Official 模式** +❌ 移除 NextLib 渲染器 +❌ 保留自定义缓冲配置 +❌ 可能从头卡到尾(某些格式不支持) + +--- + +## 技术实现 + +### 核心差异对比 + +| 特性 | NextLib | Official | Simple | +|------|---------|----------|--------| +| **FFmpeg 渲染器** | NextLib 音频 + 视频 | 无 | 仅音频兜底 | +| **LoadControl** | 自定义缓冲 | 自定义缓冲 | ExoPlayer 默认 | +| **ExoEnhanced** | 支持 | 支持 | 不支持 | +| **复杂度** | 高 | 中 | 低 | +| **与 TV 项目对比** | 完全不同 | 部分相似 | 视频路径相近,音频兜底增强 | + +### 1. NextLib 模式(mode = 0) + +```java +// 使用 FfmpegRenderersFactory 自定义渲染器 +private static class FfmpegRenderersFactory extends DefaultRenderersFactory { + @Override + protected void buildAudioRenderers(...) { + super.buildAudioRenderers(...); + out.add(..., new CompatFfmpegAudioRenderer(...)); + } + @Override + protected void buildVideoRenderers(...) { + super.buildVideoRenderers(...); + out.add(..., new FfmpegVideoRenderer(...)); + } +} + +// 自定义 LoadControl +builder.setLoadControl(buildLoadControl()); +``` + +**依赖**: `io.github.anilbeesetti:nextlib-media3ext:1.10.0-0.12.1` + +### 2. Official 模式(mode = 1) + +```java +// 仅使用标准 DefaultRenderersFactory +DefaultRenderersFactory factory = new DefaultRenderersFactory(App.get()); +return factory + .setEnableDecoderFallback(true) + .setExtensionRendererMode(Math.max(audioRenderMode, videoRenderMode)); + +// 自定义 LoadControl +builder.setLoadControl(buildLoadControl()); +``` + +### 3. Simple 模式(mode = 2) + +```java +// 使用默认视频路径,只追加 FFmpeg 音频兜底 +DefaultRenderersFactory factory = new FfmpegAudioFallbackRenderersFactory(App.get(), audioRenderMode, audioPrefer); +return factory + .setEnableDecoderFallback(true) + .setExtensionRendererMode(Math.max(audioRenderMode, videoRenderMode)); + +// 不设置 LoadControl,使用 ExoPlayer 默认值 +// builder.setLoadControl(...); // 注释掉 +``` + +**关键**:Simple 模式在 `buildPlayer` 中跳过 `setLoadControl`,但仍保留 FFmpeg 音频兜底。 + +--- + +## 修改文件列表 + +### 核心逻辑 +- `app/src/main/java/com/fongmi/android/tv/setting/PlayerSetting.java` + - 新增 `getFFmpegMode()` / `putFFmpegMode(int)` 方法 + - 支持 0=NextLib, 1=Official, 2=Simple + - **默认值:手机端与 TV 端均为 `0`(NextLib)** + +- `app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java` + - `buildPlayer` - Simple 模式跳过 `setLoadControl` + - `buildRenderersFactory` - 根据模式分发到三种实现 + - `buildNextLibRenderersFactory` - NextLib 模式 + - `buildOfficialRenderersFactory` - Official 模式 + - `buildSimpleRenderersFactory` - Simple 模式 + +### UI 界面(Leanback + Mobile) +- 布局文件 - 添加"FFmpeg模式"设置项 +- Activity/Fragment - 添加循环切换逻辑(0→1→2→0) +- 显示文本:`NextLib` / `Official` / `Simple` + +### 字符串资源 +- `strings.xml` (EN/CN/TW) - `player_ffmpeg_mode` + +--- + +## 问题诊断 + +### 用户反馈分析 + +**症状**: +- Official 模式:从头卡到尾(完全无法播放) +- NextLib 模式:开始流畅,后面卡顿掉帧 + +**原因分析**: +1. **Official 从头卡到尾** → 系统 MediaCodec 不支持该视频格式,需要 FFmpeg 软解 +2. **NextLib 开始流畅后卡顿** → FFmpeg 能解码,但可能是: + - 自定义缓冲配置过度缓冲,导致内存压力 + - 渲染器插入逻辑导致 AV 不同步 + - ExoEnhanced 参数过于激进 + +**解决方案**:两端统一默认 NextLib,卡顿时手动降级 +- ✅ 手机端与 TV 端均默认 NextLib,优先保证 iOS/HLS 等格式的音视频兼容 +- ✅ 遇到卡顿掉帧可手动切到 Simple,移除复杂的 NextLib 视频渲染器插入逻辑 +- ✅ Simple 模式仍保留 FFmpeg 音频兜底,避免系统音频解码器不支持时无声 +- ✅ Simple 模式使用 ExoPlayer 默认缓冲策略 + +--- + +## 技术细节 + +### LoadControl 差异 + +**TV 项目(Simple 模式):** +```java +ExoPlayer player = new ExoPlayer.Builder(App.get()) + .setTrackSelector(...) + .setRenderersFactory(...) + .setMediaSourceFactory(...) + .build(); // 不设置 LoadControl +``` + +**当前项目(NextLib/Official 模式):** +```java +builder.setLoadControl(new DefaultLoadControl.Builder() + .setBufferDurationsMs( + DEFAULT_MIN_BUFFER_MS * PlayerSetting.getBuffer(), // 用户设置放大 + DEFAULT_MAX_BUFFER_MS * PlayerSetting.getBuffer(), + ... + ) + .build()); +``` + +**问题**:用户设置的缓冲倍数可能导致过度缓冲,触发 GC 或内存压力。 + +### 渲染器链对比 + +**NextLib 模式:** +``` +1. MediaCodec 硬解渲染器(系统) +2. CompatFfmpegAudioRenderer(NextLib) +3. FfmpegVideoRenderer(NextLib) +``` + +**Official 模式:** +``` +1. MediaCodec 硬解渲染器(系统) +2. 无额外渲染器 +``` + +**Simple 模式:** +``` +1. MediaCodec 硬解渲染器(系统) +2. CompatFfmpegAudioRenderer(仅音频兜底) +``` + +通过 `setExtensionRendererMode` 控制是否优先使用扩展渲染器。 + +--- + +## 存储键 + +```java +Prefers.getInt("ffmpeg_mode", getDefaultFFmpegMode()) +// 0 = NextLib +// 1 = Official +// 2 = Simple +// 3 = 自动(AUTO):持久值;实际渲染时由 getEffectiveFFmpegMode() 解析成具体模式 +// getDefaultFFmpegMode(): mobile 与 leanback/TV 均为 0(NextLib) +``` + +## 自动(AUTO)模式 + +选择「自动」后,播放失败时会在**当前解码档位**下按 `FFMPEG_AUTO_ORDER`(NextLib → Simple → Official)依次重建播放器重试,用尽三种模式仍失败才交回既有的解码/内核降级链(硬解→软解→换内核)。整体次序为:硬解下遍历三模式 → 软解下遍历三模式 → 换下一个内核。 + +- 仅在 EXO 内核 + FFmpeg 模式为 AUTO 时生效(`fallbackFfmpegMode`)。 +- 只对**播放失败**(`PlaybackException`)触发;掉帧不触发(掉帧没有干净的布尔信号,且换渲染器需重建播放器,误判代价大)。 +- 遍历状态用 `ffmpegModeFallbackTried[]` 防重复;切软解与 `reset()` 时清空,运行时用进程内 `ffmpegModeOverride` 临时覆盖具体模式,不写盘。 + +--- + +## 未来优化建议 + +1. ~~**默认值调整**:手机端与 TV 端统一默认 NextLib~~ ✅ 已完成 +2. ~~**失败自动遍历**:新增「自动」模式,播放失败时遍历三种 FFmpeg 模式~~ ✅ 已完成 +3. **自动检测**:根据视频格式主动选择合适的模式(当前仅在失败后被动遍历) +4. **性能监控**:记录掉帧率,提示用户切换模式 + +--- + +**修改日期**: 2026-07-05 +**相关 Issue**: 用户反馈 TV 项目能正常播放的剧,当前项目卡顿掉帧 +**测试结论**: Official 从头卡到尾,NextLib 开始流畅后卡顿;Simple 保留音频兜底并移除 NextLib 视频渲染器 +**默认模式**: 手机端与 TV 端均为 NextLib diff --git a/README.md b/README.md index e52e9be802..ee26d508c1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ WebHomeTV 是基于 [FongMi](https://github.com/FongMi/TV) / CatVod 生态二次 - **观影记录同步**:增强功能中提供独立总览页,包含总开关、本机 API 修改开关、远端同步源和 Webhook 上报。爬虫可通过 `/api/playback/current` 读取当前播放记录,也可在用户开启修改后调用 `/api/playback/progress`、`/api/playback/progress/batch` 或 `/api/playback/progress/delete` 写入/清理本地进度;App 也可从用户配置的远端 API 拉取批量记录合并到本地历史,并通过删除墓碑同步清理记录。仓库内置的 Cloudflare、Deno、Vercel、Go、Rust 五种服务端都可用同一 URL 同时承接 Webhook 和增量拉取,分别使用 Durable Object SQLite、Deno KV、Redis REST 或本地原子文件持久化。完整协议见 `webhome-devkit/docs/应用完整开发文档.md` 的“观影记录同步”章节。 - **登录态学习**:用户手动开启后学习 Cookie、Token、接口 Jar 网盘登录文件等登录态路径,待确认项可在管理页查看/编辑,并可参与一键同步。 - **APP 代理**:配置代理地址和域名匹配规则,可按当前站点自动建议代理域名,用于改善特定站点、接口或播放链路的网络访问。 -- **调试日志**:本机和局域网日志查看入口,便于排查播放、代理、站源和 WebHome 相关问题;崩溃界面会显示可复制的局域网日志地址,未开启调试日志时也会自动开启临时崩溃会话供下载。 +- **调试日志**:本机和局域网日志查看入口,便于排查播放、代理、站源和 WebHome 相关问题。 以上能力集中在设置页的"增强功能"入口,手机端和电视端均为独立设置页。 @@ -38,8 +38,6 @@ https://github.com/user-attachments/assets/984c274f-8a9b-4857-b641-d251e061f5cc ## 文档 -按功能模块拆分的接入文档见 [**开发者接入文档**](docs/integration/README.md),覆盖总配置、点播源、Spider、Result/Vod、直播/EPG/回看、解析器、字幕、弹幕、播放结果、本地代理和扩展包。字段说明以当前仓库实际代码为准。 - 完整开发说明见 [**应用完整开发文档.md**](webhome-devkit/docs/应用完整开发文档.md),包含: - App 配置字段(点播、解析、直播、样式) @@ -78,8 +76,8 @@ WebHome 主页、扩展、模板、示例和 AI skills 统一放在 [webhome-dev ### 环境要求 -- 项目使用纯命令行工具链,不依赖任何 IDE。必须在 `JAVA_HOME`/`PATH` 中配置独立 JDK 21,并单独配置 Android SDK Command-line Tools。 -- 需要 JDK 21;当前 `sourceCompatibility` / `targetCompatibility` 均为 Java 21。 +- Android Studio 仅是可选 IDE,不是项目依赖。命令行构建不读取 Android Studio 的安装目录或内置 JBR,只需要 `JAVA_HOME`/`PATH` 中可用的 JDK 和独立配置的 Android SDK。 +- JDK 21。不要使用 JDK 17;当前 `sourceCompatibility` / `targetCompatibility` 均为 Java 21。 - Python 3.10。Chaquo 运行时和构建时 Python 均固定为 3.10,仅安装 Python 3.11/3.12/3.13 会失败。 - Android SDK Platform 37 和 Build Tools 37.0.0。当前 `compileSdk=37`、`minSdk=24`、`targetSdk=28`。 - Android NDK 29.0.14206865(r29)用于重建 MPV/FFmpeg/libplacebo 和 MPV JNI;NDK 28.2.13676358(r28c)继续用于 IJK/DVD。普通 Gradle 打包直接使用仓库已提交二进制,不要求安装 NDK。`scripts/build_mpv_player_jni.sh` 只重建 JNI 桥接库 `libplayer.so`,不会重编 `libmpv.so`、FFmpeg 或 libplacebo。 @@ -103,7 +101,7 @@ export all_proxy=socks5://127.0.0.1:7897 ### 从零 clone 到打包 -先安装或确认 Android SDK Command-line Tools,并通过 `ANDROID_HOME`、`ANDROID_SDK_ROOT` 或根目录 `local.properties` 告诉构建系统 SDK 的位置。`local.properties` 是普通文本配置,直接在仓库根目录创建即可。macOS 常见 SDK 路径示例: +先安装或确认 Android SDK Command-line Tools,并通过 `ANDROID_HOME`、`ANDROID_SDK_ROOT` 或根目录 `local.properties` 告诉构建系统 SDK 的位置。`local.properties` 是普通文本配置,直接在仓库根目录创建即可,不需要 Android Studio。macOS 常见 SDK 路径示例: ```bash export ANDROID_HOME="$HOME/Library/Android/sdk" @@ -111,7 +109,7 @@ export ANDROID_HOME="$HOME/Library/Android/sdk" Linux 常见路径是 `$HOME/Android/Sdk`;Windows 可手动创建 `local.properties`,内容类似 `sdk.dir=C\:\\Users\\你的用户名\\AppData\\Local\\Android\\Sdk`。 -如 SDK 未安装 API 37、Build Tools 或 Platform Tools,使用 Android SDK Command-line Tools 自带的 `sdkmanager` 安装: +如 SDK 未安装 API 37、Build Tools 或 Platform Tools,使用 Android SDK 自带的 `sdkmanager` 安装;Android Studio 的 SDK Manager 只是可选的图形界面: ```bash "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ @@ -194,13 +192,13 @@ bash gradlew :app:assembleMobileArm64_v8aDebug :app:assembleLeanbackArmeabi_v7aD | ABI | MPV | FFmpeg | libplacebo | 网络后端 | 说明 | | --- | --- | --- | --- | --- | --- | -| `arm64-v8a` | `0.41.0-940-gcca559b41` | `177f090e0503`(9.0.1-fongmi) | `7.375.0` / `b694a21bf2dc` | curl 8.21.0 + nghttp2 1.69.0 | C0-M 双 ABI、ELF、APK 和 arm64 手机播放/生命周期验证通过 | -| `armeabi-v7a` | `0.41.0-940-gcca559b41` | `177f090e0503`(9.0.1-fongmi) | `7.375.0` / `b694a21bf2dc` | curl 8.21.0 + nghttp2 1.69.0 | C0-M 独立重建及 ELF/资产验证通过,不能跨 ABI 复制 | +| `arm64-v8a` | `0.41.0-940-gcca559b41` | `04482c8d13ac`(9.0-fongmi) | `7.375.0` / `b694a21bf2dc` | curl 8.21.0 + nghttp2 1.69.0 | 截至 2026-08-17,当前 assets 已通过能力、ELF 与打包规则校验 | +| `armeabi-v7a` | `0.41.0-940-gcca559b41` | `04482c8d13ac`(9.0-fongmi) | `7.375.0` / `b694a21bf2dc` | curl 8.21.0 + nghttp2 1.69.0 | 截至 2026-08-17,当前 assets 已通过能力、ELF 与打包规则校验 | 替换或升级 MPV native 时必须遵守: - `libmpv.so`、FFmpeg(codec/device/filter/format/util/swresample/swscale)、静态链接进 MPV 的 libplacebo、curl、nghttp2、MbedTLS 和 `libc++_shared.so` 必须按同一 ABI、同一 lock 成套构建,不能再混用旧 `libmpv.so` 与新依赖作为正式方案。 -- 当前 native lock 使用 MPV `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`、FFmpeg 9.0.1-fongmi `177f090e0503b7e013922ca903bde14b1c375f18`、libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`(7.375.0/API 375)、mpv-android `99a60ad2141d5ace94453590903c2c6b9a0a2443` 和 NDK r29/API 24。C0-M 不启用 `dovi_rpu convert=p81`,并继续保留本地 Range、MediaCodec、DV、Vulkan、AudioTrack 和 JNI 保护。curl 使用 MbedTLS 3.6.7,只启用 HTTP/HTTPS 与 HTTP/2,不包含 HTTP/3、ngtcp2、nghttp3 或 quiche。 +- 当前 native lock 使用 MPV `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42`、FFmpeg 9.0-fongmi `04482c8d13ac27b2a9fe93f5d388929eef8af5f4`、libplacebo `b694a21bf2dc176c1e98b8a13c6421a0de5f3da5`(7.375.0/API 375)、mpv-android `99a60ad2141d5ace94453590903c2c6b9a0a2443` 和 NDK r29/API 24。curl 使用 MbedTLS 3.6.7,只启用 HTTP/HTTPS 与 HTTP/2,不包含 HTTP/3、ngtcp2、nghttp3 或 quiche。 - 最新 FongMi MPV 分支已经内建重写后的 AImageReader/AHardwareBuffer OpenGL/Vulkan 后端、异步 fence、HDR/Dolby Vision、双 Surface OSD 和 Android helper scheme;旧的 `fd679c81` 不是新分支祖先,原 `mpv-aimagereader-transient-buffer.patch` 已删除,不能在新分支上重复叠加。 - curl 与 nghttp2 静态链接进 `libmpv.so`,APK 不新增独立网络 `.so`。它增强 MPV 直接远程 HTTP/HTTPS 输入;App 自己处理的本地 HLS 代理、`stream_cb` 和 FFmpeg/lavf 路径仍按各自实现工作,不能把启用 curl 理解为所有播放请求都强制走同一后端。 - FFmpeg 文件名、ELF `SONAME` 和所有 `DT_NEEDED` 都要从 `libav*`/`libsw*` 等长改为 `libmv*`/`libmw*`,不能只重命名文件,否则会和 `nextlib-media3ext` 内置 FFmpeg 发生 Android linker 复用冲突。 @@ -368,7 +366,7 @@ keyPassword=your_key_password - `third_party/patches/nextlib-*.patch`:在 `anilbeesetti/nextlib@6ff6cf9d0820382b3c233d018c52e4163b09d345` 上叠加 FFmpeg 软解负载控制和 AV3A/libarcdav3a 支持。 - `third_party/mpv-player-jni`:MPV `libplayer.so` JNI 桥接源码,修改后用 `scripts/build_mpv_player_jni.sh` 重建。 - `app/src/*/assets/mpv-libs/*`:随 APK 打包的 MPV native 库和 JNI 桥接库。 -- `nextlib-media3ext`:`io.github.anilbeesetti:nextlib-media3ext:1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r1`,提供 FFmpeg renderer;内置 FongMi FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`(9.0.1)和静态链接的 `libarcdav3a`,Exo 可软解 `audio/av3a`,并在输出设备不接受源多声道 PCM 时下混到立体声。MPV C0-M 现在使用同一 FFmpeg 源 revision,但仍独立构建 `libmv*`/`libmw*`,不能共用该 AAR 内的 `.so`。 +- `nextlib-media3ext`:`io.github.anilbeesetti:nextlib-media3ext:1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r1`,提供 FFmpeg renderer;内置 FongMi FFmpeg `177f090e0503b7e013922ca903bde14b1c375f18`(9.0.1)和静态链接的 `libarcdav3a`,Exo 可软解 `audio/av3a`,并在输出设备不接受源多声道 PCM 时下混到立体声。MPV native 仍按其独立 lock 使用旧 FFmpeg,不能共用该 AAR 内的 `.so`。 - `ExoplayerHdrUtils`:`com.suyashbelekar:exoplayerhdrutils:0.4.0`,提供基于 libdovi 的实时 HEVC RPU 转换。Exo 按“原生 DV7 硬解 → P8.1 转换硬解 → HDR10/HEVC 硬解”选择整次播放路径:原生 DV7 可用时保持原码流;原生 DV7 不可用但 P8.1 可用时使用 mode 2 转换并移除增强层;两者都不可用而设备支持 HDR10/HEVC 时,起播前直接使用 HDR10 基底层。能力查询误报或 P8.1 实际初始化/解码失败时,同一会话最多回退 HDR10 一次,不自动切软解或循环重试;用户选择 HDR10 模式时仍整次使用 HDR10 基底层。 `settings.gradle` 中的依赖顺序是仓库本地 `third_party/maven`、Maven Central、Google Maven、`app/libs` 和 JitPack。`app/build.gradle` 会强制所有 `androidx.media3` 依赖使用 `1.11.0-alpha01-fongmi`,避免传递依赖拉回官方版本。 @@ -424,7 +422,7 @@ other/ Logo 图片和辅助工具 | 仓库 | 分支 | Commit | | --- | --- | --- | | [TV](https://github.com/FongMi/TV) | `fongmi` | `1a19fee278fa2234da725d61a53bf59b69fe9127`(`560 / 5.6.0`) | -| [FFmpeg](https://github.com/FongMi/FFmpeg) | `release-9.0-fongmi` | `177f090e0503b7e013922ca903bde14b1c375f18` | +| [FFmpeg](https://github.com/FongMi/FFmpeg) | `release-9.0-fongmi` | `04482c8d13ac27b2a9fe93f5d388929eef8af5f4` | | [mpv-android](https://github.com/FongMi/mpv-android) | `fongmi` | `99a60ad2141d5ace94453590903c2c6b9a0a2443` | | [media](https://github.com/FongMi/media) | `release` | `2bc207851df311340767e913931ca7b28cab1794` | | [mpv](https://github.com/FongMi/mpv) | `fongmi` | `cca559b41ceb0bb7731cf6ef2e1f33276cd30c42` | diff --git a/app/build.gradle b/app/build.gradle index 50d66e3821..16d8d071d2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,23 +16,11 @@ def buildTimestamp = new Date().format('yyyyMMddHHmm', TimeZone.getTimeZone('Asi def buildTime = fastRelease ? "fast-${buildTimestamp}" : buildTimestamp def releaseTag = (System.getenv('WEBHTV_RELEASE_TAG') ?: '').replace('\\', '\\\\').replace('"', '\\"') def apkSuffix = (System.getenv('WEBHTV_APK_SUFFIX') ?: '').replaceAll('[^A-Za-z0-9._-]', '') -def gitRevision = 'unavailable' -def gitState = 'unavailable' -def mpvSourceLock = new groovy.json.JsonSlurper().parse(rootProject.file('third_party/mpv-native-lock.json')) -def mpvSourceRevisions = ['mpv', 'ffmpeg', 'libplacebo'].collect { name -> "${name}=${mpvSourceLock.sources[name].commit}" }.join(' ') + " framework=${mpvSourceLock.builder.commit}" -try { - def revision = providers.exec { workingDir rootDir; commandLine 'git', 'rev-parse', 'HEAD' }.standardOutput.asText.get().trim() - if (revision ==~ /[0-9a-f]{40}/) { - gitRevision = revision - gitState = providers.exec { workingDir rootDir; commandLine 'git', 'status', '--porcelain', '--untracked-files=no' }.standardOutput.asText.get().trim().isEmpty() ? 'clean' : 'dirty' - } -} catch (Exception ignored) { /* Source archives may not contain Git metadata. */ } android { namespace = 'com.fongmi.android.tv' compileSdk libs.versions.compileSdk.get().toInteger() - ndkVersion "29.0.14206865" defaultConfig { applicationId "com.silent.android.webhtv" @@ -40,24 +28,14 @@ android { targetSdk libs.versions.targetSdk.get().toInteger() versionCode 560 versionName "5.6.0" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" buildConfigField "String", "BUILD_TIME", "\"${buildTime}\"" buildConfigField "String", "BUILD_TAG", "\"${releaseTag}\"" - buildConfigField "String", "GIT_REVISION", "\"${gitRevision}\"" - buildConfigField "String", "GIT_STATE", "\"${gitState}\"" - buildConfigField "String", "MPV_NATIVE_SOURCES", "\"${mpvSourceRevisions}\"" buildConfigField "String", "MEDIA3_VERSION", "\"${libs.versions.media3.get()}\"" javaCompileOptions { annotationProcessorOptions { arguments = ["room.schemaLocation": "$projectDir/schemas".toString(), "eventBusIndex": "com.fongmi.android.tv.event.EventIndex"] } } - externalNativeBuild { - cmake { - arguments "-DANDROID_PLATFORM=android-26" - cppFlags "-std=c++20", "-Wall", "-Wextra", "-Werror=return-type" - } - } } flavorDimensions = ["mode", "abi"] @@ -84,14 +62,6 @@ android { viewBinding = true } - testOptions { - unitTests.includeAndroidResources = true - } - - // ASS rendering is part of normal builds; package the matching prebuilt ABI. - sourceSets.main.jniLibs.srcDir(rootProject.file('third_party/exo-ass-native/prebuilt')) - sourceSets.androidTest.assets.srcDir(files("$projectDir/schemas").files) - signingConfigs { release { if (hasReleaseSigning) { @@ -138,13 +108,6 @@ android { sourceCompatibility JavaVersion.VERSION_21 targetCompatibility JavaVersion.VERSION_21 } - - externalNativeBuild { - cmake { - path file("src/main/cpp/CMakeLists.txt") - version "3.22.1" - } - } } androidComponents { @@ -164,11 +127,6 @@ dependencies { implementation project(':chaquo') implementation project(':nodejs') implementation project(':quickjs') - // quickjs 模块用 implementation 引入 wrapper,不会向 app 传递; - // QuickJsPluginRuntime 需要直接用 QuickJSContext/JSFunction,故此处显式声明。 - // 未改成 api:那会扩大 quickjs 模块的公开面,影响所有未来的消费者。 - implementation libs.quickjs.android - implementation libs.quickjs.java implementation libs.appcompat implementation libs.lifecycle.service implementation libs.media @@ -178,7 +136,6 @@ dependencies { implementation libs.palette implementation libs.recyclerview implementation libs.room.runtime - implementation libs.work.runtime implementation libs.splashscreen implementation libs.swiperefreshlayout implementation libs.viewpager2 @@ -201,6 +158,7 @@ dependencies { implementation libs.simple.xml implementation libs.textdrawable implementation libs.tensorflow.lite + implementation libs.tink.android implementation libs.zxing.core leanbackImplementation libs.leanback leanbackImplementation libs.androidautosize @@ -208,18 +166,10 @@ dependencies { mobileImplementation libs.biometric mobileImplementation libs.zxing.android.embedded testImplementation libs.junit - testImplementation libs.work.testing annotationProcessor libs.room.compiler annotationProcessor libs.eventbus.annotation.processor annotationProcessor libs.glide.compiler coreLibraryDesugaring libs.desugar - testLeanbackImplementation 'org.robolectric:robolectric:4.16' - androidTestImplementation libs.junit - androidTestImplementation libs.room.testing - androidTestImplementation libs.work.testing + testImplementation libs.junit testImplementation libs.okhttp.mockwebserver - androidTestImplementation libs.androidx.test.runner - androidTestImplementation libs.androidx.test.rules - androidTestImplementation libs.androidx.test.core - androidTestImplementation libs.androidx.test.ext.junit } diff --git a/app/proguard-rules-media.pro b/app/proguard-rules-media.pro index 20ed725962..b496b1c3ad 100644 --- a/app/proguard-rules-media.pro +++ b/app/proguard-rules-media.pro @@ -197,6 +197,3 @@ public androidx.appcompat.app.AlertDialog$Builder setNegativeButton(int, android.content.DialogInterface$OnClickListener); public androidx.appcompat.app.AlertDialog create(); } --keepclasseswithmembernames class com.fongmi.android.tv.player.exo.ass.AssNative { - native ; -} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index c464f1750a..4bd03e33e5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -72,11 +72,6 @@ -keep class com.github.catvod.Proxy { *; } -keep class com.github.catvod.crawler.** { *; } -keep class * extends com.github.catvod.crawler.Spider -# Chaquopy bridge loaded by name from CatVod jar spiders (for example the -# csp_PyProxy sites in external configs). spring.jar resolves -# com.fongmi.chaquo.Loader and com.fongmi.chaquo.Spider by their literal class -# names via reflection, so R8 must not shrink or rename them in release builds. --keep class com.fongmi.chaquo.** { *; } # Jianpian -keep class com.p2p.** { *; } diff --git a/app/schemas/com.fongmi.android.tv.db.AppDatabase/44.json b/app/schemas/com.fongmi.android.tv.db.AppDatabase/44.json deleted file mode 100644 index 138f420ba6..0000000000 --- a/app/schemas/com.fongmi.android.tv.db.AppDatabase/44.json +++ /dev/null @@ -1,690 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 44, - "identityHash": "2868d4276f9718c3102b142c99dfd3e3", - "entities": [ - { - "tableName": "Keep", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `siteName` TEXT, `vodName` TEXT, `vodPic` TEXT, `createTime` INTEGER NOT NULL, `type` INTEGER NOT NULL, `cid` INTEGER NOT NULL, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteName", - "columnName": "siteName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Site", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `searchable` INTEGER, `changeable` INTEGER, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchable", - "columnName": "searchable", - "affinity": "INTEGER" - }, - { - "fieldPath": "changeable", - "columnName": "changeable", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Live", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `keep` TEXT, `boot` INTEGER NOT NULL, `pass` INTEGER NOT NULL, PRIMARY KEY(`name`))", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keep", - "columnName": "keep", - "affinity": "TEXT" - }, - { - "fieldPath": "boot", - "columnName": "boot", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pass", - "columnName": "pass", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "name" - ] - } - }, - { - "tableName": "Track", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `key` TEXT, `name` TEXT, `format` TEXT, `selected` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "format", - "columnName": "format", - "affinity": "TEXT" - }, - { - "fieldPath": "selected", - "columnName": "selected", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Track_key_type", - "unique": true, - "columnNames": [ - "key", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Track_key_type` ON `${TABLE_NAME}` (`key`, `type`)" - } - ] - }, - { - "tableName": "Config", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `time` INTEGER NOT NULL, `url` TEXT, `json` TEXT, `name` TEXT, `logo` TEXT, `home` TEXT, `parse` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT" - }, - { - "fieldPath": "json", - "columnName": "json", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "logo", - "columnName": "logo", - "affinity": "TEXT" - }, - { - "fieldPath": "home", - "columnName": "home", - "affinity": "TEXT" - }, - { - "fieldPath": "parse", - "columnName": "parse", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Config_url_type", - "unique": true, - "columnNames": [ - "url", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_url_type` ON `${TABLE_NAME}` (`url`, `type`)" - } - ] - }, - { - "tableName": "Device", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `uuid` TEXT, `name` TEXT, `ip` TEXT, `type` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER" - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "ip", - "columnName": "ip", - "affinity": "TEXT" - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Device_uuid_name", - "unique": true, - "columnNames": [ - "uuid", - "name" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Device_uuid_name` ON `${TABLE_NAME}` (`uuid`, `name`)" - } - ] - }, - { - "tableName": "History", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `vodPic` TEXT, `wallPic` TEXT, `vodName` TEXT, `vodFlag` TEXT, `vodRemarks` TEXT, `episodeUrl` TEXT, `revSort` INTEGER NOT NULL, `revPlay` INTEGER NOT NULL, `createTime` INTEGER NOT NULL, `opening` INTEGER NOT NULL, `ending` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `speed` REAL NOT NULL, `speedOverride` INTEGER NOT NULL DEFAULT 0, `scale` INTEGER NOT NULL, `cid` INTEGER NOT NULL, `typeName` TEXT, `area` TEXT, `actor` TEXT, `director` TEXT, `year` TEXT, `tmdbId` INTEGER NOT NULL DEFAULT 0, `mediaType` TEXT DEFAULT '', `legacyKey` TEXT DEFAULT '', `tmdbSeasonNumber` INTEGER NOT NULL DEFAULT 0, `tmdbEpisodeNumber` INTEGER NOT NULL DEFAULT 0, `player` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "wallPic", - "columnName": "wallPic", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodFlag", - "columnName": "vodFlag", - "affinity": "TEXT" - }, - { - "fieldPath": "vodRemarks", - "columnName": "vodRemarks", - "affinity": "TEXT" - }, - { - "fieldPath": "episodeUrl", - "columnName": "episodeUrl", - "affinity": "TEXT" - }, - { - "fieldPath": "revSort", - "columnName": "revSort", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "revPlay", - "columnName": "revPlay", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opening", - "columnName": "opening", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ending", - "columnName": "ending", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "speed", - "columnName": "speed", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "speedOverride", - "columnName": "speedOverride", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "scale", - "columnName": "scale", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "typeName", - "columnName": "typeName", - "affinity": "TEXT" - }, - { - "fieldPath": "area", - "columnName": "area", - "affinity": "TEXT" - }, - { - "fieldPath": "actor", - "columnName": "actor", - "affinity": "TEXT" - }, - { - "fieldPath": "director", - "columnName": "director", - "affinity": "TEXT" - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "TEXT" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "legacyKey", - "columnName": "legacyKey", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "tmdbSeasonNumber", - "columnName": "tmdbSeasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "tmdbEpisodeNumber", - "columnName": "tmdbEpisodeNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "player", - "columnName": "player", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "PlaybackDeleteTombstone", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `configKey` TEXT NOT NULL, `scope` TEXT NOT NULL, `historyKey` TEXT NOT NULL, `siteKey` TEXT NOT NULL, `vodId` TEXT NOT NULL, `mediaType` TEXT NOT NULL DEFAULT '', `tmdbId` INTEGER NOT NULL DEFAULT 0, `seasonNumber` INTEGER NOT NULL DEFAULT -1, `deletedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "configKey", - "columnName": "configKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "scope", - "columnName": "scope", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "historyKey", - "columnName": "historyKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "siteKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vodId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "deletedAt", - "columnName": "deletedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_PlaybackDeleteTombstone_deletedAt", - "unique": false, - "columnNames": [ - "deletedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_PlaybackDeleteTombstone_deletedAt` ON `${TABLE_NAME}` (`deletedAt`)" - } - ] - }, - { - "tableName": "TmdbSeasonProgress", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cid` INTEGER NOT NULL, `mediaType` TEXT NOT NULL, `tmdbId` INTEGER NOT NULL, `seasonNumber` INTEGER NOT NULL, `episodeNumber` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `sourceFlag` TEXT NOT NULL, `sourceEpisodeName` TEXT NOT NULL, `sourceEpisodeUrl` TEXT NOT NULL, `sourceHistoryKey` TEXT NOT NULL, `sourceBindingKey` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`cid`, `mediaType`, `tmdbId`, `seasonNumber`))", - "fields": [ - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "episodeNumber", - "columnName": "episodeNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sourceFlag", - "columnName": "sourceFlag", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeName", - "columnName": "sourceEpisodeName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeUrl", - "columnName": "sourceEpisodeUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceHistoryKey", - "columnName": "sourceHistoryKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceBindingKey", - "columnName": "sourceBindingKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "seasonNumber" - ] - }, - "indices": [ - { - "name": "index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt", - "unique": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "sourceHistoryKey", - "updatedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt` ON `${TABLE_NAME}` (`cid`, `mediaType`, `tmdbId`, `sourceHistoryKey`, `updatedAt`)" - }, - { - "name": "index_TmdbSeasonProgress_cid_sourceHistoryKey", - "unique": false, - "columnNames": [ - "cid", - "sourceHistoryKey" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_sourceHistoryKey` ON `${TABLE_NAME}` (`cid`, `sourceHistoryKey`)" - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2868d4276f9718c3102b142c99dfd3e3')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.db.AppDatabase/45.json b/app/schemas/com.fongmi.android.tv.db.AppDatabase/45.json deleted file mode 100644 index 079b3197cd..0000000000 --- a/app/schemas/com.fongmi.android.tv.db.AppDatabase/45.json +++ /dev/null @@ -1,696 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 45, - "identityHash": "b28bd3ade9e71166248e9ef1c1b72156", - "entities": [ - { - "tableName": "Keep", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `siteName` TEXT, `vodName` TEXT, `vodPic` TEXT, `createTime` INTEGER NOT NULL, `type` INTEGER NOT NULL, `cid` INTEGER NOT NULL, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteName", - "columnName": "siteName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Site", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `searchable` INTEGER, `changeable` INTEGER, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchable", - "columnName": "searchable", - "affinity": "INTEGER" - }, - { - "fieldPath": "changeable", - "columnName": "changeable", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Live", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `keep` TEXT, `boot` INTEGER NOT NULL, `pass` INTEGER NOT NULL, PRIMARY KEY(`name`))", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keep", - "columnName": "keep", - "affinity": "TEXT" - }, - { - "fieldPath": "boot", - "columnName": "boot", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pass", - "columnName": "pass", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "name" - ] - } - }, - { - "tableName": "Track", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `key` TEXT, `name` TEXT, `format` TEXT, `selected` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "format", - "columnName": "format", - "affinity": "TEXT" - }, - { - "fieldPath": "selected", - "columnName": "selected", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Track_key_type", - "unique": true, - "columnNames": [ - "key", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Track_key_type` ON `${TABLE_NAME}` (`key`, `type`)" - } - ] - }, - { - "tableName": "Config", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `time` INTEGER NOT NULL, `url` TEXT, `json` TEXT, `name` TEXT, `logo` TEXT, `home` TEXT, `parse` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT" - }, - { - "fieldPath": "json", - "columnName": "json", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "logo", - "columnName": "logo", - "affinity": "TEXT" - }, - { - "fieldPath": "home", - "columnName": "home", - "affinity": "TEXT" - }, - { - "fieldPath": "parse", - "columnName": "parse", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Config_url_type", - "unique": true, - "columnNames": [ - "url", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_url_type` ON `${TABLE_NAME}` (`url`, `type`)" - } - ] - }, - { - "tableName": "Device", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `uuid` TEXT, `name` TEXT, `ip` TEXT, `type` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER" - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "ip", - "columnName": "ip", - "affinity": "TEXT" - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Device_uuid_name", - "unique": true, - "columnNames": [ - "uuid", - "name" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Device_uuid_name` ON `${TABLE_NAME}` (`uuid`, `name`)" - } - ] - }, - { - "tableName": "History", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `vodPic` TEXT, `wallPic` TEXT, `vodName` TEXT, `vodFlag` TEXT, `vodRemarks` TEXT, `episodeUrl` TEXT, `revSort` INTEGER NOT NULL, `revPlay` INTEGER NOT NULL, `createTime` INTEGER NOT NULL, `opening` INTEGER NOT NULL, `ending` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `speed` REAL NOT NULL, `speedOverride` INTEGER NOT NULL DEFAULT 0, `scale` INTEGER NOT NULL, `cid` INTEGER NOT NULL, `typeName` TEXT, `area` TEXT, `actor` TEXT, `director` TEXT, `year` TEXT, `tmdbId` INTEGER NOT NULL DEFAULT 0, `mediaType` TEXT DEFAULT '', `legacyKey` TEXT DEFAULT '', `tmdbSeasonNumber` INTEGER NOT NULL DEFAULT 0, `tmdbEpisodeNumber` INTEGER NOT NULL DEFAULT 0, `player` INTEGER NOT NULL DEFAULT -1, `subtitleSource` TEXT DEFAULT '', PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "wallPic", - "columnName": "wallPic", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodFlag", - "columnName": "vodFlag", - "affinity": "TEXT" - }, - { - "fieldPath": "vodRemarks", - "columnName": "vodRemarks", - "affinity": "TEXT" - }, - { - "fieldPath": "episodeUrl", - "columnName": "episodeUrl", - "affinity": "TEXT" - }, - { - "fieldPath": "revSort", - "columnName": "revSort", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "revPlay", - "columnName": "revPlay", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opening", - "columnName": "opening", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ending", - "columnName": "ending", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "speed", - "columnName": "speed", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "speedOverride", - "columnName": "speedOverride", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "scale", - "columnName": "scale", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "typeName", - "columnName": "typeName", - "affinity": "TEXT" - }, - { - "fieldPath": "area", - "columnName": "area", - "affinity": "TEXT" - }, - { - "fieldPath": "actor", - "columnName": "actor", - "affinity": "TEXT" - }, - { - "fieldPath": "director", - "columnName": "director", - "affinity": "TEXT" - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "TEXT" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "legacyKey", - "columnName": "legacyKey", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "tmdbSeasonNumber", - "columnName": "tmdbSeasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "tmdbEpisodeNumber", - "columnName": "tmdbEpisodeNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "player", - "columnName": "player", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "subtitleSource", - "columnName": "subtitleSource", - "affinity": "TEXT", - "defaultValue": "''" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "PlaybackDeleteTombstone", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `configKey` TEXT NOT NULL, `scope` TEXT NOT NULL, `historyKey` TEXT NOT NULL, `siteKey` TEXT NOT NULL, `vodId` TEXT NOT NULL, `mediaType` TEXT NOT NULL DEFAULT '', `tmdbId` INTEGER NOT NULL DEFAULT 0, `seasonNumber` INTEGER NOT NULL DEFAULT -1, `deletedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "configKey", - "columnName": "configKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "scope", - "columnName": "scope", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "historyKey", - "columnName": "historyKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "siteKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vodId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "deletedAt", - "columnName": "deletedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_PlaybackDeleteTombstone_deletedAt", - "unique": false, - "columnNames": [ - "deletedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_PlaybackDeleteTombstone_deletedAt` ON `${TABLE_NAME}` (`deletedAt`)" - } - ] - }, - { - "tableName": "TmdbSeasonProgress", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cid` INTEGER NOT NULL, `mediaType` TEXT NOT NULL, `tmdbId` INTEGER NOT NULL, `seasonNumber` INTEGER NOT NULL, `episodeNumber` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `sourceFlag` TEXT NOT NULL, `sourceEpisodeName` TEXT NOT NULL, `sourceEpisodeUrl` TEXT NOT NULL, `sourceHistoryKey` TEXT NOT NULL, `sourceBindingKey` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`cid`, `mediaType`, `tmdbId`, `seasonNumber`))", - "fields": [ - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "episodeNumber", - "columnName": "episodeNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sourceFlag", - "columnName": "sourceFlag", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeName", - "columnName": "sourceEpisodeName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeUrl", - "columnName": "sourceEpisodeUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceHistoryKey", - "columnName": "sourceHistoryKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceBindingKey", - "columnName": "sourceBindingKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "seasonNumber" - ] - }, - "indices": [ - { - "name": "index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt", - "unique": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "sourceHistoryKey", - "updatedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt` ON `${TABLE_NAME}` (`cid`, `mediaType`, `tmdbId`, `sourceHistoryKey`, `updatedAt`)" - }, - { - "name": "index_TmdbSeasonProgress_cid_sourceHistoryKey", - "unique": false, - "columnNames": [ - "cid", - "sourceHistoryKey" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_sourceHistoryKey` ON `${TABLE_NAME}` (`cid`, `sourceHistoryKey`)" - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b28bd3ade9e71166248e9ef1c1b72156')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.db.AppDatabase/46.json b/app/schemas/com.fongmi.android.tv.db.AppDatabase/46.json deleted file mode 100644 index 0788f1ed38..0000000000 --- a/app/schemas/com.fongmi.android.tv.db.AppDatabase/46.json +++ /dev/null @@ -1,716 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 46, - "identityHash": "ce01b0c863cbc5536a42153868287927", - "entities": [ - { - "tableName": "Keep", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `siteName` TEXT, `vodName` TEXT, `vodPic` TEXT, `createTime` INTEGER NOT NULL, `type` INTEGER NOT NULL, `cid` INTEGER NOT NULL, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteName", - "columnName": "siteName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Site", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `searchable` INTEGER, `changeable` INTEGER, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchable", - "columnName": "searchable", - "affinity": "INTEGER" - }, - { - "fieldPath": "changeable", - "columnName": "changeable", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Live", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `keep` TEXT, `boot` INTEGER NOT NULL, `pass` INTEGER NOT NULL, PRIMARY KEY(`name`))", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keep", - "columnName": "keep", - "affinity": "TEXT" - }, - { - "fieldPath": "boot", - "columnName": "boot", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pass", - "columnName": "pass", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "name" - ] - } - }, - { - "tableName": "Track", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `key` TEXT, `name` TEXT, `format` TEXT, `selected` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "format", - "columnName": "format", - "affinity": "TEXT" - }, - { - "fieldPath": "selected", - "columnName": "selected", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Track_key_type", - "unique": true, - "columnNames": [ - "key", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Track_key_type` ON `${TABLE_NAME}` (`key`, `type`)" - } - ] - }, - { - "tableName": "Config", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `time` INTEGER NOT NULL, `url` TEXT, `interfaceKey` TEXT, `urlsJson` TEXT, `json` TEXT, `name` TEXT, `logo` TEXT, `home` TEXT, `parse` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT" - }, - { - "fieldPath": "interfaceKey", - "columnName": "interfaceKey", - "affinity": "TEXT" - }, - { - "fieldPath": "urlsJson", - "columnName": "urlsJson", - "affinity": "TEXT" - }, - { - "fieldPath": "json", - "columnName": "json", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "logo", - "columnName": "logo", - "affinity": "TEXT" - }, - { - "fieldPath": "home", - "columnName": "home", - "affinity": "TEXT" - }, - { - "fieldPath": "parse", - "columnName": "parse", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Config_url_type", - "unique": false, - "columnNames": [ - "url", - "type" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_Config_url_type` ON `${TABLE_NAME}` (`url`, `type`)" - }, - { - "name": "index_Config_interfaceKey_type", - "unique": true, - "columnNames": [ - "interfaceKey", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_interfaceKey_type` ON `${TABLE_NAME}` (`interfaceKey`, `type`)" - } - ] - }, - { - "tableName": "Device", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `uuid` TEXT, `name` TEXT, `ip` TEXT, `type` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER" - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "ip", - "columnName": "ip", - "affinity": "TEXT" - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Device_uuid_name", - "unique": true, - "columnNames": [ - "uuid", - "name" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Device_uuid_name` ON `${TABLE_NAME}` (`uuid`, `name`)" - } - ] - }, - { - "tableName": "History", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `vodPic` TEXT, `wallPic` TEXT, `vodName` TEXT, `vodFlag` TEXT, `vodRemarks` TEXT, `episodeUrl` TEXT, `revSort` INTEGER NOT NULL, `revPlay` INTEGER NOT NULL, `createTime` INTEGER NOT NULL, `opening` INTEGER NOT NULL, `ending` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `speed` REAL NOT NULL, `speedOverride` INTEGER NOT NULL DEFAULT 0, `scale` INTEGER NOT NULL, `cid` INTEGER NOT NULL, `typeName` TEXT, `area` TEXT, `actor` TEXT, `director` TEXT, `year` TEXT, `tmdbId` INTEGER NOT NULL DEFAULT 0, `mediaType` TEXT DEFAULT '', `legacyKey` TEXT DEFAULT '', `tmdbSeasonNumber` INTEGER NOT NULL DEFAULT 0, `tmdbEpisodeNumber` INTEGER NOT NULL DEFAULT 0, `player` INTEGER NOT NULL DEFAULT -1, `subtitleSource` TEXT DEFAULT '', PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "wallPic", - "columnName": "wallPic", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodFlag", - "columnName": "vodFlag", - "affinity": "TEXT" - }, - { - "fieldPath": "vodRemarks", - "columnName": "vodRemarks", - "affinity": "TEXT" - }, - { - "fieldPath": "episodeUrl", - "columnName": "episodeUrl", - "affinity": "TEXT" - }, - { - "fieldPath": "revSort", - "columnName": "revSort", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "revPlay", - "columnName": "revPlay", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opening", - "columnName": "opening", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ending", - "columnName": "ending", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "speed", - "columnName": "speed", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "speedOverride", - "columnName": "speedOverride", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "scale", - "columnName": "scale", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "typeName", - "columnName": "typeName", - "affinity": "TEXT" - }, - { - "fieldPath": "area", - "columnName": "area", - "affinity": "TEXT" - }, - { - "fieldPath": "actor", - "columnName": "actor", - "affinity": "TEXT" - }, - { - "fieldPath": "director", - "columnName": "director", - "affinity": "TEXT" - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "TEXT" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "legacyKey", - "columnName": "legacyKey", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "tmdbSeasonNumber", - "columnName": "tmdbSeasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "tmdbEpisodeNumber", - "columnName": "tmdbEpisodeNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "player", - "columnName": "player", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "subtitleSource", - "columnName": "subtitleSource", - "affinity": "TEXT", - "defaultValue": "''" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "PlaybackDeleteTombstone", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `configKey` TEXT NOT NULL, `scope` TEXT NOT NULL, `historyKey` TEXT NOT NULL, `siteKey` TEXT NOT NULL, `vodId` TEXT NOT NULL, `mediaType` TEXT NOT NULL DEFAULT '', `tmdbId` INTEGER NOT NULL DEFAULT 0, `seasonNumber` INTEGER NOT NULL DEFAULT -1, `deletedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "configKey", - "columnName": "configKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "scope", - "columnName": "scope", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "historyKey", - "columnName": "historyKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "siteKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vodId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "deletedAt", - "columnName": "deletedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_PlaybackDeleteTombstone_deletedAt", - "unique": false, - "columnNames": [ - "deletedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_PlaybackDeleteTombstone_deletedAt` ON `${TABLE_NAME}` (`deletedAt`)" - } - ] - }, - { - "tableName": "TmdbSeasonProgress", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cid` INTEGER NOT NULL, `mediaType` TEXT NOT NULL, `tmdbId` INTEGER NOT NULL, `seasonNumber` INTEGER NOT NULL, `episodeNumber` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `sourceFlag` TEXT NOT NULL, `sourceEpisodeName` TEXT NOT NULL, `sourceEpisodeUrl` TEXT NOT NULL, `sourceHistoryKey` TEXT NOT NULL, `sourceBindingKey` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`cid`, `mediaType`, `tmdbId`, `seasonNumber`))", - "fields": [ - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "episodeNumber", - "columnName": "episodeNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sourceFlag", - "columnName": "sourceFlag", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeName", - "columnName": "sourceEpisodeName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeUrl", - "columnName": "sourceEpisodeUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceHistoryKey", - "columnName": "sourceHistoryKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceBindingKey", - "columnName": "sourceBindingKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "seasonNumber" - ] - }, - "indices": [ - { - "name": "index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt", - "unique": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "sourceHistoryKey", - "updatedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt` ON `${TABLE_NAME}` (`cid`, `mediaType`, `tmdbId`, `sourceHistoryKey`, `updatedAt`)" - }, - { - "name": "index_TmdbSeasonProgress_cid_sourceHistoryKey", - "unique": false, - "columnNames": [ - "cid", - "sourceHistoryKey" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_sourceHistoryKey` ON `${TABLE_NAME}` (`cid`, `sourceHistoryKey`)" - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ce01b0c863cbc5536a42153868287927')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.db.AppDatabase/47.json b/app/schemas/com.fongmi.android.tv.db.AppDatabase/47.json deleted file mode 100644 index baebb6c7e9..0000000000 --- a/app/schemas/com.fongmi.android.tv.db.AppDatabase/47.json +++ /dev/null @@ -1,722 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 47, - "identityHash": "f8c0db8cfea66fafbad2c20d377becc8", - "entities": [ - { - "tableName": "Keep", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `siteName` TEXT, `vodName` TEXT, `vodPic` TEXT, `createTime` INTEGER NOT NULL, `type` INTEGER NOT NULL, `cid` INTEGER NOT NULL, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteName", - "columnName": "siteName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Site", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `searchable` INTEGER, `changeable` INTEGER, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchable", - "columnName": "searchable", - "affinity": "INTEGER" - }, - { - "fieldPath": "changeable", - "columnName": "changeable", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Live", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `keep` TEXT, `boot` INTEGER NOT NULL, `pass` INTEGER NOT NULL, PRIMARY KEY(`name`))", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keep", - "columnName": "keep", - "affinity": "TEXT" - }, - { - "fieldPath": "boot", - "columnName": "boot", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pass", - "columnName": "pass", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "name" - ] - } - }, - { - "tableName": "Track", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `key` TEXT, `name` TEXT, `format` TEXT, `selected` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "format", - "columnName": "format", - "affinity": "TEXT" - }, - { - "fieldPath": "selected", - "columnName": "selected", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Track_key_type", - "unique": true, - "columnNames": [ - "key", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Track_key_type` ON `${TABLE_NAME}` (`key`, `type`)" - } - ] - }, - { - "tableName": "Config", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `time` INTEGER NOT NULL, `url` TEXT, `interfaceKey` TEXT, `urlsJson` TEXT, `json` TEXT, `name` TEXT, `logo` TEXT, `home` TEXT, `parse` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT" - }, - { - "fieldPath": "interfaceKey", - "columnName": "interfaceKey", - "affinity": "TEXT" - }, - { - "fieldPath": "urlsJson", - "columnName": "urlsJson", - "affinity": "TEXT" - }, - { - "fieldPath": "json", - "columnName": "json", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "logo", - "columnName": "logo", - "affinity": "TEXT" - }, - { - "fieldPath": "home", - "columnName": "home", - "affinity": "TEXT" - }, - { - "fieldPath": "parse", - "columnName": "parse", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Config_url_type", - "unique": false, - "columnNames": [ - "url", - "type" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_Config_url_type` ON `${TABLE_NAME}` (`url`, `type`)" - }, - { - "name": "index_Config_interfaceKey_type", - "unique": true, - "columnNames": [ - "interfaceKey", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_interfaceKey_type` ON `${TABLE_NAME}` (`interfaceKey`, `type`)" - } - ] - }, - { - "tableName": "Device", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `uuid` TEXT, `name` TEXT, `ip` TEXT, `type` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER" - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "ip", - "columnName": "ip", - "affinity": "TEXT" - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Device_uuid_name", - "unique": true, - "columnNames": [ - "uuid", - "name" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Device_uuid_name` ON `${TABLE_NAME}` (`uuid`, `name`)" - } - ] - }, - { - "tableName": "History", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `vodPic` TEXT, `wallPic` TEXT, `vodName` TEXT, `vodFlag` TEXT, `vodRemarks` TEXT, `episodeUrl` TEXT, `revSort` INTEGER NOT NULL, `revPlay` INTEGER NOT NULL, `createTime` INTEGER NOT NULL, `opening` INTEGER NOT NULL, `ending` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `speed` REAL NOT NULL, `speedOverride` INTEGER NOT NULL DEFAULT 0, `scale` INTEGER NOT NULL, `cid` INTEGER NOT NULL, `typeName` TEXT, `area` TEXT, `actor` TEXT, `director` TEXT, `year` TEXT, `tmdbId` INTEGER NOT NULL DEFAULT 0, `mediaType` TEXT DEFAULT '', `legacyKey` TEXT DEFAULT '', `tmdbSeasonNumber` INTEGER NOT NULL DEFAULT 0, `tmdbEpisodeNumber` INTEGER NOT NULL DEFAULT 0, `player` INTEGER NOT NULL DEFAULT -1, `subtitleSource` TEXT DEFAULT '', `sourceBindingKey` TEXT DEFAULT '', PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "wallPic", - "columnName": "wallPic", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodFlag", - "columnName": "vodFlag", - "affinity": "TEXT" - }, - { - "fieldPath": "vodRemarks", - "columnName": "vodRemarks", - "affinity": "TEXT" - }, - { - "fieldPath": "episodeUrl", - "columnName": "episodeUrl", - "affinity": "TEXT" - }, - { - "fieldPath": "revSort", - "columnName": "revSort", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "revPlay", - "columnName": "revPlay", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opening", - "columnName": "opening", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ending", - "columnName": "ending", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "speed", - "columnName": "speed", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "speedOverride", - "columnName": "speedOverride", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "scale", - "columnName": "scale", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "typeName", - "columnName": "typeName", - "affinity": "TEXT" - }, - { - "fieldPath": "area", - "columnName": "area", - "affinity": "TEXT" - }, - { - "fieldPath": "actor", - "columnName": "actor", - "affinity": "TEXT" - }, - { - "fieldPath": "director", - "columnName": "director", - "affinity": "TEXT" - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "TEXT" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "legacyKey", - "columnName": "legacyKey", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "tmdbSeasonNumber", - "columnName": "tmdbSeasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "tmdbEpisodeNumber", - "columnName": "tmdbEpisodeNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "player", - "columnName": "player", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "subtitleSource", - "columnName": "subtitleSource", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "sourceBindingKey", - "columnName": "sourceBindingKey", - "affinity": "TEXT", - "defaultValue": "''" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "PlaybackDeleteTombstone", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `configKey` TEXT NOT NULL, `scope` TEXT NOT NULL, `historyKey` TEXT NOT NULL, `siteKey` TEXT NOT NULL, `vodId` TEXT NOT NULL, `mediaType` TEXT NOT NULL DEFAULT '', `tmdbId` INTEGER NOT NULL DEFAULT 0, `seasonNumber` INTEGER NOT NULL DEFAULT -1, `deletedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "configKey", - "columnName": "configKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "scope", - "columnName": "scope", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "historyKey", - "columnName": "historyKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "siteKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vodId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "deletedAt", - "columnName": "deletedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_PlaybackDeleteTombstone_deletedAt", - "unique": false, - "columnNames": [ - "deletedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_PlaybackDeleteTombstone_deletedAt` ON `${TABLE_NAME}` (`deletedAt`)" - } - ] - }, - { - "tableName": "TmdbSeasonProgress", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cid` INTEGER NOT NULL, `mediaType` TEXT NOT NULL, `tmdbId` INTEGER NOT NULL, `seasonNumber` INTEGER NOT NULL, `episodeNumber` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `sourceFlag` TEXT NOT NULL, `sourceEpisodeName` TEXT NOT NULL, `sourceEpisodeUrl` TEXT NOT NULL, `sourceHistoryKey` TEXT NOT NULL, `sourceBindingKey` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`cid`, `mediaType`, `tmdbId`, `seasonNumber`))", - "fields": [ - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "episodeNumber", - "columnName": "episodeNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sourceFlag", - "columnName": "sourceFlag", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeName", - "columnName": "sourceEpisodeName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeUrl", - "columnName": "sourceEpisodeUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceHistoryKey", - "columnName": "sourceHistoryKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceBindingKey", - "columnName": "sourceBindingKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "seasonNumber" - ] - }, - "indices": [ - { - "name": "index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt", - "unique": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "sourceHistoryKey", - "updatedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt` ON `${TABLE_NAME}` (`cid`, `mediaType`, `tmdbId`, `sourceHistoryKey`, `updatedAt`)" - }, - { - "name": "index_TmdbSeasonProgress_cid_sourceHistoryKey", - "unique": false, - "columnNames": [ - "cid", - "sourceHistoryKey" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_sourceHistoryKey` ON `${TABLE_NAME}` (`cid`, `sourceHistoryKey`)" - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f8c0db8cfea66fafbad2c20d377becc8')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.db.AppDatabase/48.json b/app/schemas/com.fongmi.android.tv.db.AppDatabase/48.json deleted file mode 100644 index e7ca7148af..0000000000 --- a/app/schemas/com.fongmi.android.tv.db.AppDatabase/48.json +++ /dev/null @@ -1,737 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 48, - "identityHash": "bbde8a33386ce26c0e0897467faa465d", - "entities": [ - { - "tableName": "Keep", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `siteName` TEXT, `vodName` TEXT, `vodPic` TEXT, `createTime` INTEGER NOT NULL, `type` INTEGER NOT NULL, `cid` INTEGER NOT NULL, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteName", - "columnName": "siteName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Site", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `searchable` INTEGER, `changeable` INTEGER, PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchable", - "columnName": "searchable", - "affinity": "INTEGER" - }, - { - "fieldPath": "changeable", - "columnName": "changeable", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "Live", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `keep` TEXT, `boot` INTEGER NOT NULL, `pass` INTEGER NOT NULL, PRIMARY KEY(`name`))", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keep", - "columnName": "keep", - "affinity": "TEXT" - }, - { - "fieldPath": "boot", - "columnName": "boot", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pass", - "columnName": "pass", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "name" - ] - } - }, - { - "tableName": "Track", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `key` TEXT, `name` TEXT, `format` TEXT, `selected` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "format", - "columnName": "format", - "affinity": "TEXT" - }, - { - "fieldPath": "selected", - "columnName": "selected", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Track_key_type", - "unique": true, - "columnNames": [ - "key", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Track_key_type` ON `${TABLE_NAME}` (`key`, `type`)" - } - ] - }, - { - "tableName": "Config", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `time` INTEGER NOT NULL, `url` TEXT, `interfaceKey` TEXT, `urlsJson` TEXT, `legacyConfigKeysJson` TEXT, `addressMatchAliasesJson` TEXT, `identityResolutionState` TEXT, `json` TEXT, `name` TEXT, `logo` TEXT, `home` TEXT, `parse` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT" - }, - { - "fieldPath": "interfaceKey", - "columnName": "interfaceKey", - "affinity": "TEXT" - }, - { - "fieldPath": "urlsJson", - "columnName": "urlsJson", - "affinity": "TEXT" - }, - { - "fieldPath": "legacyConfigKeysJson", - "columnName": "legacyConfigKeysJson", - "affinity": "TEXT" - }, - { - "fieldPath": "addressMatchAliasesJson", - "columnName": "addressMatchAliasesJson", - "affinity": "TEXT" - }, - { - "fieldPath": "identityResolutionState", - "columnName": "identityResolutionState", - "affinity": "TEXT" - }, - { - "fieldPath": "json", - "columnName": "json", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "logo", - "columnName": "logo", - "affinity": "TEXT" - }, - { - "fieldPath": "home", - "columnName": "home", - "affinity": "TEXT" - }, - { - "fieldPath": "parse", - "columnName": "parse", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Config_url_type", - "unique": false, - "columnNames": [ - "url", - "type" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_Config_url_type` ON `${TABLE_NAME}` (`url`, `type`)" - }, - { - "name": "index_Config_interfaceKey_type", - "unique": true, - "columnNames": [ - "interfaceKey", - "type" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_interfaceKey_type` ON `${TABLE_NAME}` (`interfaceKey`, `type`)" - } - ] - }, - { - "tableName": "Device", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `uuid` TEXT, `name` TEXT, `ip` TEXT, `type` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER" - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "ip", - "columnName": "ip", - "affinity": "TEXT" - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Device_uuid_name", - "unique": true, - "columnNames": [ - "uuid", - "name" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Device_uuid_name` ON `${TABLE_NAME}` (`uuid`, `name`)" - } - ] - }, - { - "tableName": "History", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `vodPic` TEXT, `wallPic` TEXT, `vodName` TEXT, `vodFlag` TEXT, `vodRemarks` TEXT, `episodeUrl` TEXT, `revSort` INTEGER NOT NULL, `revPlay` INTEGER NOT NULL, `createTime` INTEGER NOT NULL, `opening` INTEGER NOT NULL, `ending` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `speed` REAL NOT NULL, `speedOverride` INTEGER NOT NULL DEFAULT 0, `scale` INTEGER NOT NULL, `cid` INTEGER NOT NULL, `typeName` TEXT, `area` TEXT, `actor` TEXT, `director` TEXT, `year` TEXT, `tmdbId` INTEGER NOT NULL DEFAULT 0, `mediaType` TEXT DEFAULT '', `legacyKey` TEXT DEFAULT '', `tmdbSeasonNumber` INTEGER NOT NULL DEFAULT 0, `tmdbEpisodeNumber` INTEGER NOT NULL DEFAULT 0, `player` INTEGER NOT NULL DEFAULT -1, `subtitleSource` TEXT DEFAULT '', `sourceBindingKey` TEXT DEFAULT '', PRIMARY KEY(`key`))", - "fields": [ - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vodPic", - "affinity": "TEXT" - }, - { - "fieldPath": "wallPic", - "columnName": "wallPic", - "affinity": "TEXT" - }, - { - "fieldPath": "vodName", - "columnName": "vodName", - "affinity": "TEXT" - }, - { - "fieldPath": "vodFlag", - "columnName": "vodFlag", - "affinity": "TEXT" - }, - { - "fieldPath": "vodRemarks", - "columnName": "vodRemarks", - "affinity": "TEXT" - }, - { - "fieldPath": "episodeUrl", - "columnName": "episodeUrl", - "affinity": "TEXT" - }, - { - "fieldPath": "revSort", - "columnName": "revSort", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "revPlay", - "columnName": "revPlay", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createTime", - "columnName": "createTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "opening", - "columnName": "opening", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ending", - "columnName": "ending", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "speed", - "columnName": "speed", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "speedOverride", - "columnName": "speedOverride", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "scale", - "columnName": "scale", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "typeName", - "columnName": "typeName", - "affinity": "TEXT" - }, - { - "fieldPath": "area", - "columnName": "area", - "affinity": "TEXT" - }, - { - "fieldPath": "actor", - "columnName": "actor", - "affinity": "TEXT" - }, - { - "fieldPath": "director", - "columnName": "director", - "affinity": "TEXT" - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "TEXT" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "legacyKey", - "columnName": "legacyKey", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "tmdbSeasonNumber", - "columnName": "tmdbSeasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "tmdbEpisodeNumber", - "columnName": "tmdbEpisodeNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "player", - "columnName": "player", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "subtitleSource", - "columnName": "subtitleSource", - "affinity": "TEXT", - "defaultValue": "''" - }, - { - "fieldPath": "sourceBindingKey", - "columnName": "sourceBindingKey", - "affinity": "TEXT", - "defaultValue": "''" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "key" - ] - } - }, - { - "tableName": "PlaybackDeleteTombstone", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `configKey` TEXT NOT NULL, `scope` TEXT NOT NULL, `historyKey` TEXT NOT NULL, `siteKey` TEXT NOT NULL, `vodId` TEXT NOT NULL, `mediaType` TEXT NOT NULL DEFAULT '', `tmdbId` INTEGER NOT NULL DEFAULT 0, `seasonNumber` INTEGER NOT NULL DEFAULT -1, `deletedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "configKey", - "columnName": "configKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "scope", - "columnName": "scope", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "historyKey", - "columnName": "historyKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "siteKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vodId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "deletedAt", - "columnName": "deletedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_PlaybackDeleteTombstone_deletedAt", - "unique": false, - "columnNames": [ - "deletedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_PlaybackDeleteTombstone_deletedAt` ON `${TABLE_NAME}` (`deletedAt`)" - } - ] - }, - { - "tableName": "TmdbSeasonProgress", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cid` INTEGER NOT NULL, `mediaType` TEXT NOT NULL, `tmdbId` INTEGER NOT NULL, `seasonNumber` INTEGER NOT NULL, `episodeNumber` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `sourceFlag` TEXT NOT NULL, `sourceEpisodeName` TEXT NOT NULL, `sourceEpisodeUrl` TEXT NOT NULL, `sourceHistoryKey` TEXT NOT NULL, `sourceBindingKey` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`cid`, `mediaType`, `tmdbId`, `seasonNumber`))", - "fields": [ - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "mediaType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdbId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonNumber", - "columnName": "seasonNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "episodeNumber", - "columnName": "episodeNumber", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sourceFlag", - "columnName": "sourceFlag", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeName", - "columnName": "sourceEpisodeName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceEpisodeUrl", - "columnName": "sourceEpisodeUrl", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceHistoryKey", - "columnName": "sourceHistoryKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceBindingKey", - "columnName": "sourceBindingKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "seasonNumber" - ] - }, - "indices": [ - { - "name": "index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt", - "unique": false, - "columnNames": [ - "cid", - "mediaType", - "tmdbId", - "sourceHistoryKey", - "updatedAt" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_mediaType_tmdbId_sourceHistoryKey_updatedAt` ON `${TABLE_NAME}` (`cid`, `mediaType`, `tmdbId`, `sourceHistoryKey`, `updatedAt`)" - }, - { - "name": "index_TmdbSeasonProgress_cid_sourceHistoryKey", - "unique": false, - "columnNames": [ - "cid", - "sourceHistoryKey" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_TmdbSeasonProgress_cid_sourceHistoryKey` ON `${TABLE_NAME}` (`cid`, `sourceHistoryKey`)" - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bbde8a33386ce26c0e0897467faa465d')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/1.json b/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/1.json deleted file mode 100644 index ed151c9ec3..0000000000 --- a/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/1.json +++ /dev/null @@ -1,428 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 1, - "identityHash": "7e868fdbdbc43809b2189d43aa0f766b", - "entities": [ - { - "tableName": "following", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identity_key` TEXT NOT NULL, `series_key` TEXT NOT NULL, `cid` INTEGER NOT NULL, `site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, `vod_pic` TEXT NOT NULL, `media_type` TEXT NOT NULL, `tmdb_id` INTEGER NOT NULL, `tracked_season` INTEGER NOT NULL, `tracked_episode` INTEGER NOT NULL, `watched_season` INTEGER NOT NULL, `watched_episode` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `official_status` TEXT NOT NULL, `latest_released_season` INTEGER NOT NULL, `latest_released_episode` INTEGER NOT NULL, `season_total_episodes` INTEGER NOT NULL, `season_released_episodes` INTEGER NOT NULL, `series_total_episodes` INTEGER NOT NULL, `next_air_season` INTEGER NOT NULL, `next_air_episode` INTEGER NOT NULL, `next_air_at` INTEGER NOT NULL, `last_observed_episode` INTEGER NOT NULL, `read_watermark_episode` INTEGER NOT NULL, `last_notified_episode` INTEGER NOT NULL, `last_notified_at` INTEGER NOT NULL, `has_update` INTEGER NOT NULL, `unwatched_count` INTEGER NOT NULL, `notify_enabled` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `metadata_updated_at` INTEGER NOT NULL, `last_checked_at` INTEGER NOT NULL, `next_check_at` INTEGER NOT NULL, `failure_count` INTEGER NOT NULL, `last_error` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`identity_key`))", - "fields": [ - { - "fieldPath": "identityKey", - "columnName": "identity_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "seriesKey", - "columnName": "series_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "site_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vod_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodName", - "columnName": "vod_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vod_pic", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "media_type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdb_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trackedSeason", - "columnName": "tracked_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trackedEpisode", - "columnName": "tracked_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "watchedSeason", - "columnName": "watched_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "watchedEpisode", - "columnName": "watched_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "officialStatus", - "columnName": "official_status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "latestReleasedSeason", - "columnName": "latest_released_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "latestReleasedEpisode", - "columnName": "latest_released_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonTotalEpisodes", - "columnName": "season_total_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonReleasedEpisodes", - "columnName": "season_released_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seriesTotalEpisodes", - "columnName": "series_total_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirSeason", - "columnName": "next_air_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirEpisode", - "columnName": "next_air_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirAt", - "columnName": "next_air_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastObservedEpisode", - "columnName": "last_observed_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "readWatermarkEpisode", - "columnName": "read_watermark_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastNotifiedEpisode", - "columnName": "last_notified_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastNotifiedAt", - "columnName": "last_notified_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasUpdate", - "columnName": "has_update", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "unwatchedCount", - "columnName": "unwatched_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "notifyEnabled", - "columnName": "notify_enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "enabled", - "columnName": "enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "metadataUpdatedAt", - "columnName": "metadata_updated_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastCheckedAt", - "columnName": "last_checked_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextCheckAt", - "columnName": "next_check_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "failureCount", - "columnName": "failure_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastError", - "columnName": "last_error", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "created_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updated_at", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "identity_key" - ] - }, - "indices": [ - { - "name": "index_following_series_key", - "unique": false, - "columnNames": [ - "series_key" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_series_key` ON `${TABLE_NAME}` (`series_key`)" - }, - { - "name": "index_following_enabled_next_check_at", - "unique": false, - "columnNames": [ - "enabled", - "next_check_at" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_enabled_next_check_at` ON `${TABLE_NAME}` (`enabled`, `next_check_at`)" - }, - { - "name": "index_following_has_update_updated_at", - "unique": false, - "columnNames": [ - "has_update", - "updated_at" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_has_update_updated_at` ON `${TABLE_NAME}` (`has_update`, `updated_at`)" - }, - { - "name": "index_following_cid_site_key_vod_id", - "unique": false, - "columnNames": [ - "cid", - "site_key", - "vod_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_cid_site_key_vod_id` ON `${TABLE_NAME}` (`cid`, `site_key`, `vod_id`)" - }, - { - "name": "index_following_tmdb_id_media_type_tracked_season", - "unique": false, - "columnNames": [ - "tmdb_id", - "media_type", - "tracked_season" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_tmdb_id_media_type_tracked_season` ON `${TABLE_NAME}` (`tmdb_id`, `media_type`, `tracked_season`)" - } - ] - }, - { - "tableName": "following_source", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`following_key` TEXT NOT NULL, `cid` INTEGER NOT NULL, `site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, `vod_pic` TEXT NOT NULL, `vod_flag` TEXT NOT NULL, `playable_season` INTEGER NOT NULL, `playable_episode` INTEGER NOT NULL, `playable_count` INTEGER NOT NULL, `preferred` INTEGER NOT NULL, `last_probe_at` INTEGER NOT NULL, `last_error` TEXT NOT NULL, PRIMARY KEY(`following_key`, `cid`, `site_key`, `vod_id`))", - "fields": [ - { - "fieldPath": "followingKey", - "columnName": "following_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "site_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vod_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodName", - "columnName": "vod_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vod_pic", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodFlag", - "columnName": "vod_flag", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "playableSeason", - "columnName": "playable_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playableEpisode", - "columnName": "playable_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playableCount", - "columnName": "playable_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "preferred", - "columnName": "preferred", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastProbeAt", - "columnName": "last_probe_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastError", - "columnName": "last_error", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "following_key", - "cid", - "site_key", - "vod_id" - ] - }, - "indices": [ - { - "name": "index_following_source_following_key_preferred", - "unique": false, - "columnNames": [ - "following_key", - "preferred" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_source_following_key_preferred` ON `${TABLE_NAME}` (`following_key`, `preferred`)" - }, - { - "name": "index_following_source_cid_site_key_vod_id", - "unique": false, - "columnNames": [ - "cid", - "site_key", - "vod_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_source_cid_site_key_vod_id` ON `${TABLE_NAME}` (`cid`, `site_key`, `vod_id`)" - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e868fdbdbc43809b2189d43aa0f766b')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/2.json b/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/2.json deleted file mode 100644 index 202d43b897..0000000000 --- a/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/2.json +++ /dev/null @@ -1,434 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 2, - "identityHash": "335206a4549d61c15ae0792885264d71", - "entities": [ - { - "tableName": "following", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identity_key` TEXT NOT NULL, `series_key` TEXT NOT NULL, `cid` INTEGER NOT NULL, `site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, `vod_pic` TEXT NOT NULL, `media_type` TEXT NOT NULL, `tmdb_id` INTEGER NOT NULL, `tracked_season` INTEGER NOT NULL, `tracked_episode` INTEGER NOT NULL, `watched_season` INTEGER NOT NULL, `watched_episode` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `official_status` TEXT NOT NULL, `latest_released_season` INTEGER NOT NULL, `latest_released_episode` INTEGER NOT NULL, `season_total_episodes` INTEGER NOT NULL, `season_released_episodes` INTEGER NOT NULL, `series_total_episodes` INTEGER NOT NULL, `next_air_season` INTEGER NOT NULL, `next_air_episode` INTEGER NOT NULL, `next_air_at` INTEGER NOT NULL, `next_air_weekday` INTEGER NOT NULL, `last_observed_episode` INTEGER NOT NULL, `read_watermark_episode` INTEGER NOT NULL, `last_notified_episode` INTEGER NOT NULL, `last_notified_at` INTEGER NOT NULL, `has_update` INTEGER NOT NULL, `unwatched_count` INTEGER NOT NULL, `notify_enabled` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `metadata_updated_at` INTEGER NOT NULL, `last_checked_at` INTEGER NOT NULL, `next_check_at` INTEGER NOT NULL, `failure_count` INTEGER NOT NULL, `last_error` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`identity_key`))", - "fields": [ - { - "fieldPath": "identityKey", - "columnName": "identity_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "seriesKey", - "columnName": "series_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "site_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vod_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodName", - "columnName": "vod_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vod_pic", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "media_type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdb_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trackedSeason", - "columnName": "tracked_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trackedEpisode", - "columnName": "tracked_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "watchedSeason", - "columnName": "watched_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "watchedEpisode", - "columnName": "watched_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "officialStatus", - "columnName": "official_status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "latestReleasedSeason", - "columnName": "latest_released_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "latestReleasedEpisode", - "columnName": "latest_released_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonTotalEpisodes", - "columnName": "season_total_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonReleasedEpisodes", - "columnName": "season_released_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seriesTotalEpisodes", - "columnName": "series_total_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirSeason", - "columnName": "next_air_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirEpisode", - "columnName": "next_air_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirAt", - "columnName": "next_air_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirWeekday", - "columnName": "next_air_weekday", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastObservedEpisode", - "columnName": "last_observed_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "readWatermarkEpisode", - "columnName": "read_watermark_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastNotifiedEpisode", - "columnName": "last_notified_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastNotifiedAt", - "columnName": "last_notified_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasUpdate", - "columnName": "has_update", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "unwatchedCount", - "columnName": "unwatched_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "notifyEnabled", - "columnName": "notify_enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "enabled", - "columnName": "enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "metadataUpdatedAt", - "columnName": "metadata_updated_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastCheckedAt", - "columnName": "last_checked_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextCheckAt", - "columnName": "next_check_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "failureCount", - "columnName": "failure_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastError", - "columnName": "last_error", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "created_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updated_at", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "identity_key" - ] - }, - "indices": [ - { - "name": "index_following_series_key", - "unique": false, - "columnNames": [ - "series_key" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_series_key` ON `${TABLE_NAME}` (`series_key`)" - }, - { - "name": "index_following_enabled_next_check_at", - "unique": false, - "columnNames": [ - "enabled", - "next_check_at" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_enabled_next_check_at` ON `${TABLE_NAME}` (`enabled`, `next_check_at`)" - }, - { - "name": "index_following_has_update_updated_at", - "unique": false, - "columnNames": [ - "has_update", - "updated_at" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_has_update_updated_at` ON `${TABLE_NAME}` (`has_update`, `updated_at`)" - }, - { - "name": "index_following_cid_site_key_vod_id", - "unique": false, - "columnNames": [ - "cid", - "site_key", - "vod_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_cid_site_key_vod_id` ON `${TABLE_NAME}` (`cid`, `site_key`, `vod_id`)" - }, - { - "name": "index_following_tmdb_id_media_type_tracked_season", - "unique": false, - "columnNames": [ - "tmdb_id", - "media_type", - "tracked_season" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_tmdb_id_media_type_tracked_season` ON `${TABLE_NAME}` (`tmdb_id`, `media_type`, `tracked_season`)" - } - ] - }, - { - "tableName": "following_source", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`following_key` TEXT NOT NULL, `cid` INTEGER NOT NULL, `site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, `vod_pic` TEXT NOT NULL, `vod_flag` TEXT NOT NULL, `playable_season` INTEGER NOT NULL, `playable_episode` INTEGER NOT NULL, `playable_count` INTEGER NOT NULL, `preferred` INTEGER NOT NULL, `last_probe_at` INTEGER NOT NULL, `last_error` TEXT NOT NULL, PRIMARY KEY(`following_key`, `cid`, `site_key`, `vod_id`))", - "fields": [ - { - "fieldPath": "followingKey", - "columnName": "following_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "site_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vod_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodName", - "columnName": "vod_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vod_pic", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodFlag", - "columnName": "vod_flag", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "playableSeason", - "columnName": "playable_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playableEpisode", - "columnName": "playable_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playableCount", - "columnName": "playable_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "preferred", - "columnName": "preferred", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastProbeAt", - "columnName": "last_probe_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastError", - "columnName": "last_error", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "following_key", - "cid", - "site_key", - "vod_id" - ] - }, - "indices": [ - { - "name": "index_following_source_following_key_preferred", - "unique": false, - "columnNames": [ - "following_key", - "preferred" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_source_following_key_preferred` ON `${TABLE_NAME}` (`following_key`, `preferred`)" - }, - { - "name": "index_following_source_cid_site_key_vod_id", - "unique": false, - "columnNames": [ - "cid", - "site_key", - "vod_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_source_cid_site_key_vod_id` ON `${TABLE_NAME}` (`cid`, `site_key`, `vod_id`)" - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '335206a4549d61c15ae0792885264d71')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/3.json b/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/3.json deleted file mode 100644 index 393e26d110..0000000000 --- a/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/3.json +++ /dev/null @@ -1,449 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 3, - "identityHash": "e6686de6034760ce3215db6810c9b2e0", - "entities": [ - { - "tableName": "following", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identity_key` TEXT NOT NULL, `series_key` TEXT NOT NULL, `cid` INTEGER NOT NULL, `site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, `vod_pic` TEXT NOT NULL, `media_type` TEXT NOT NULL, `tmdb_id` INTEGER NOT NULL, `tracked_season` INTEGER NOT NULL, `tracked_episode` INTEGER NOT NULL, `watched_season` INTEGER NOT NULL, `watched_episode` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `official_status` TEXT NOT NULL, `latest_released_season` INTEGER NOT NULL, `latest_released_episode` INTEGER NOT NULL, `season_total_episodes` INTEGER NOT NULL, `season_released_episodes` INTEGER NOT NULL, `series_total_episodes` INTEGER NOT NULL, `next_air_season` INTEGER NOT NULL, `next_air_episode` INTEGER NOT NULL, `next_air_at` INTEGER NOT NULL, `next_air_weekday` INTEGER NOT NULL, `last_observed_episode` INTEGER NOT NULL, `read_watermark_episode` INTEGER NOT NULL, `last_notified_episode` INTEGER NOT NULL, `last_notified_at` INTEGER NOT NULL, `has_update` INTEGER NOT NULL, `unwatched_count` INTEGER NOT NULL, `notify_enabled` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL, `metadata_updated_at` INTEGER NOT NULL, `last_checked_at` INTEGER NOT NULL, `next_check_at` INTEGER NOT NULL, `failure_count` INTEGER NOT NULL, `last_error` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`identity_key`))", - "fields": [ - { - "fieldPath": "identityKey", - "columnName": "identity_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "seriesKey", - "columnName": "series_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "site_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vod_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodName", - "columnName": "vod_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vod_pic", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mediaType", - "columnName": "media_type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tmdbId", - "columnName": "tmdb_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trackedSeason", - "columnName": "tracked_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trackedEpisode", - "columnName": "tracked_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "watchedSeason", - "columnName": "watched_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "watchedEpisode", - "columnName": "watched_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "officialStatus", - "columnName": "official_status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "latestReleasedSeason", - "columnName": "latest_released_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "latestReleasedEpisode", - "columnName": "latest_released_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonTotalEpisodes", - "columnName": "season_total_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seasonReleasedEpisodes", - "columnName": "season_released_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seriesTotalEpisodes", - "columnName": "series_total_episodes", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirSeason", - "columnName": "next_air_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirEpisode", - "columnName": "next_air_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirAt", - "columnName": "next_air_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextAirWeekday", - "columnName": "next_air_weekday", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastObservedEpisode", - "columnName": "last_observed_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "readWatermarkEpisode", - "columnName": "read_watermark_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastNotifiedEpisode", - "columnName": "last_notified_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastNotifiedAt", - "columnName": "last_notified_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasUpdate", - "columnName": "has_update", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "unwatchedCount", - "columnName": "unwatched_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "notifyEnabled", - "columnName": "notify_enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "enabled", - "columnName": "enabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "deletedAt", - "columnName": "deleted_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "metadataUpdatedAt", - "columnName": "metadata_updated_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastCheckedAt", - "columnName": "last_checked_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nextCheckAt", - "columnName": "next_check_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "failureCount", - "columnName": "failure_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastError", - "columnName": "last_error", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "created_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updated_at", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "identity_key" - ] - }, - "indices": [ - { - "name": "index_following_series_key", - "unique": false, - "columnNames": [ - "series_key" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_series_key` ON `${TABLE_NAME}` (`series_key`)" - }, - { - "name": "index_following_enabled_next_check_at", - "unique": false, - "columnNames": [ - "enabled", - "next_check_at" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_enabled_next_check_at` ON `${TABLE_NAME}` (`enabled`, `next_check_at`)" - }, - { - "name": "index_following_has_update_updated_at", - "unique": false, - "columnNames": [ - "has_update", - "updated_at" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_has_update_updated_at` ON `${TABLE_NAME}` (`has_update`, `updated_at`)" - }, - { - "name": "index_following_cid_site_key_vod_id", - "unique": false, - "columnNames": [ - "cid", - "site_key", - "vod_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_cid_site_key_vod_id` ON `${TABLE_NAME}` (`cid`, `site_key`, `vod_id`)" - }, - { - "name": "index_following_tmdb_id_media_type_tracked_season", - "unique": false, - "columnNames": [ - "tmdb_id", - "media_type", - "tracked_season" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_tmdb_id_media_type_tracked_season` ON `${TABLE_NAME}` (`tmdb_id`, `media_type`, `tracked_season`)" - }, - { - "name": "index_following_deleted_at", - "unique": false, - "columnNames": [ - "deleted_at" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_deleted_at` ON `${TABLE_NAME}` (`deleted_at`)" - } - ] - }, - { - "tableName": "following_source", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`following_key` TEXT NOT NULL, `cid` INTEGER NOT NULL, `site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, `vod_pic` TEXT NOT NULL, `vod_flag` TEXT NOT NULL, `playable_season` INTEGER NOT NULL, `playable_episode` INTEGER NOT NULL, `playable_count` INTEGER NOT NULL, `preferred` INTEGER NOT NULL, `last_probe_at` INTEGER NOT NULL, `last_error` TEXT NOT NULL, PRIMARY KEY(`following_key`, `cid`, `site_key`, `vod_id`))", - "fields": [ - { - "fieldPath": "followingKey", - "columnName": "following_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "cid", - "columnName": "cid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "siteKey", - "columnName": "site_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodId", - "columnName": "vod_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodName", - "columnName": "vod_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodPic", - "columnName": "vod_pic", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "vodFlag", - "columnName": "vod_flag", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "playableSeason", - "columnName": "playable_season", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playableEpisode", - "columnName": "playable_episode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playableCount", - "columnName": "playable_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "preferred", - "columnName": "preferred", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastProbeAt", - "columnName": "last_probe_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastError", - "columnName": "last_error", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "following_key", - "cid", - "site_key", - "vod_id" - ] - }, - "indices": [ - { - "name": "index_following_source_following_key_preferred", - "unique": false, - "columnNames": [ - "following_key", - "preferred" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_source_following_key_preferred` ON `${TABLE_NAME}` (`following_key`, `preferred`)" - }, - { - "name": "index_following_source_cid_site_key_vod_id", - "unique": false, - "columnNames": [ - "cid", - "site_key", - "vod_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_following_source_cid_site_key_vod_id` ON `${TABLE_NAME}` (`cid`, `site_key`, `vod_id`)" - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e6686de6034760ce3215db6810c9b2e0')" - ] - } -} \ No newline at end of file diff --git a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-6798.png b/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-6798.png deleted file mode 100644 index 032de0fe20..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-6798.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-7170.png b/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-7170.png deleted file mode 100644 index 5aca798fbf..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-7170.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-8170.png b/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-8170.png deleted file mode 100644 index cef0d9cd1e..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced-8170.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced.ass b/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced.ass deleted file mode 100644 index 12e573c334..0000000000 --- a/app/src/androidTest/assets/exo-ass/official/357-k-and-kf-desynced.ass +++ /dev/null @@ -1,15 +0,0 @@ -[Script Info] -ScriptType: v4.00+ -ScaledBorderAndShadow: yes -PlayResX: 1920 -PlayResY: 1080 -YCbCr Matrix: TV.601 - -[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Arimo,95,&H00008AFF,&H00FFFFFF,&H00000000,&H00666666,-1,0,0,0,100,100,0,0,1,6,0,8,40,40,60,1 -Style: Default Bas,Arial,95,&H00008AFF,&H00FFFFFF,&H00000000,&H00666666,-1,0,0,0,100,100,0,0,1,6,0,8,40,40,740,1 - -[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -Dialogue: 0,0:00:04.17,0:00:09.82,Default,,0,0,0,fx,{\k90\fad(300,200)}{\k42}So {\k42}we {\k43}can {\k46}find {\k37}our {\kf245}way diff --git a/app/src/androidTest/assets/exo-ass/official/Aileron-Regular.otf b/app/src/androidTest/assets/exo-ass/official/Aileron-Regular.otf deleted file mode 100644 index 4248f6ff3e..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/Aileron-Regular.otf and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/Arimo-Bold.ttf b/app/src/androidTest/assets/exo-ass/official/Arimo-Bold.ttf deleted file mode 100644 index 627f8676b8..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/Arimo-Bold.ttf and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/Arimo-OFL.txt b/app/src/androidTest/assets/exo-ass/official/Arimo-OFL.txt deleted file mode 100644 index dee6ac7d17..0000000000 --- a/app/src/androidTest/assets/exo-ass/official/Arimo-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2026 The Arimo Project Authors (https://github.com/googlefonts/arimo) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://openfontlicense.org - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/androidTest/assets/exo-ass/official/Arimo-Regular.ttf b/app/src/androidTest/assets/exo-ass/official/Arimo-Regular.ttf deleted file mode 100644 index 522cad58c7..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/Arimo-Regular.ttf and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/FONT-NOTICES.txt b/app/src/androidTest/assets/exo-ass/official/FONT-NOTICES.txt deleted file mode 100644 index 23822c12ea..0000000000 --- a/app/src/androidTest/assets/exo-ass/official/FONT-NOTICES.txt +++ /dev/null @@ -1,6 +0,0 @@ -Test-only fixed fonts from libass/libass-tests 10edd9ecd8054f2c4678d36b379d8372d0e573c6. -Aileron-Regular.otf name table copyright: No Rights Reserved. -Arimo-Bold.ttf and Arimo-Regular.ttf name table copyright: Copyright 2010 Google Inc. All Rights Reserved. -Both Arimo files explicitly declare SIL Open Font License 1.1 (name IDs 13/14). -Arimo-OFL.txt contains the standard OFL 1.1 text retrieved from google/fonts/ofl/arimo/OFL.txt on 2026-09-14; its current project copyright header is supplemental to the 2010 embedded notices above. -All file identities and source URLs are recorded in sources.json. These fonts are not shipped in production APKs. diff --git a/app/src/androidTest/assets/exo-ass/official/blur+t-1000.png b/app/src/androidTest/assets/exo-ass/official/blur+t-1000.png deleted file mode 100644 index dd98b8f84d..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/blur+t-1000.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/blur+t-1500.png b/app/src/androidTest/assets/exo-ass/official/blur+t-1500.png deleted file mode 100644 index ed427f6a40..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/blur+t-1500.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/blur+t-1900.png b/app/src/androidTest/assets/exo-ass/official/blur+t-1900.png deleted file mode 100644 index 48bbee14aa..0000000000 Binary files a/app/src/androidTest/assets/exo-ass/official/blur+t-1900.png and /dev/null differ diff --git a/app/src/androidTest/assets/exo-ass/official/blur+t.ass b/app/src/androidTest/assets/exo-ass/official/blur+t.ass deleted file mode 100644 index 6c3d5ada55..0000000000 --- a/app/src/androidTest/assets/exo-ass/official/blur+t.ass +++ /dev/null @@ -1,12 +0,0 @@ -[Script Info] -PlayResX: 800 -PlayResY: 600 -ScaledBorderAndShadow: yes - -[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Aileron,100,&H00000000,&H00000000,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,30,30,10,1 - -[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,{\q2\t(\blur0.125)}+ · ¶ {\t(\blur0.25)}+ · ¶ {\t(\blur0.5)}+ · ¶ \N\N {\t(\blur1)}+ · ¶ {\t(\blur2)}+ · ¶ {\t(\blur4)}+ · ¶ \N\N {\t(\blur8)}+ · ¶ {\t(\blur16)}+ · ¶ {\t(\blur32)}+ · ¶ diff --git a/app/src/androidTest/assets/exo-ass/official/sources.json b/app/src/androidTest/assets/exo-ass/official/sources.json deleted file mode 100644 index b4b01339fa..0000000000 --- a/app/src/androidTest/assets/exo-ass/official/sources.json +++ /dev/null @@ -1,57 +0,0 @@ -[ - { - "path": "regression/.fonts/Aileron-Regular.otf", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/.fonts/Aileron-Regular.otf", - "sha256": "2762f4fc2ebad8323264aea52ffa2260b86c9677493d3ce2dc4f34e5851d2aa2" - }, - { - "path": "regression/.fonts/Arimo-Bold.ttf", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/.fonts/Arimo-Bold.ttf", - "sha256": "0b5422b854f9a76ff56f894dc76d440eaca6489c119168f00cd176a5a8617013" - }, - { - "path": "regression/.fonts/Arimo-Regular.ttf", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/.fonts/Arimo-Regular.ttf", - "sha256": "e9aae1ebba96d3a4137e5b355040506d025ee690db24213fcad650200dbbbf42" - }, - { - "path": "regression/blurs/blur+t.ass", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/blurs/blur%2Bt.ass", - "sha256": "85e2085db7d8ec26ac264214a02211b62025fcafb42f96b125d8bf13cc5dc18f" - }, - { - "path": "regression/karaoke/357-k-and-kf-desynced.ass", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/karaoke/357-k-and-kf-desynced.ass", - "sha256": "534d2d14ebcab56046569007387488f1aae7b3649e2213247a1e8511c2c2ac89" - }, - { - "path": "regression/blurs/blur+t-1000.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/blurs/blur%2Bt-1000.png", - "sha256": "6a9b95a3806361c90a0f046f15dd3b0a85a36dbec7c93375bbd102c078eecc39" - }, - { - "path": "regression/blurs/blur+t-1500.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/blurs/blur%2Bt-1500.png", - "sha256": "76041ba11928ed1715c867610c8fc2b4a561180c7671f7cddd6795667ead33e4" - }, - { - "path": "regression/blurs/blur+t-1900.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/blurs/blur%2Bt-1900.png", - "sha256": "49622343cf17fe56bd1ede65e446b8aaddabcb285c5e3b39745afd2a12a46998" - }, - { - "path": "regression/karaoke/357-k-and-kf-desynced-6798.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/karaoke/357-k-and-kf-desynced-6798.png", - "sha256": "2aa7b22593900f717e500c840e0f5627d78f3f1680f9fcdc73873415c88041bf" - }, - { - "path": "regression/karaoke/357-k-and-kf-desynced-7170.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/karaoke/357-k-and-kf-desynced-7170.png", - "sha256": "9328d2259972374025b603723f6dcfc06f7fb1ceb4e8ac818196354779a864c1" - }, - { - "path": "regression/karaoke/357-k-and-kf-desynced-8170.png", - "url": "https://raw.githubusercontent.com/libass/libass-tests/10edd9ecd8054f2c4678d36b379d8372d0e573c6/regression/karaoke/357-k-and-kf-desynced-8170.png", - "sha256": "57e38c4dbdcc9853496d0514724add7610a9cd22e0cabe5279131ffc44b36dbb" - } -] diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingActivityDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingActivityDeviceTest.java deleted file mode 100644 index 763d7b115a..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingActivityDeviceTest.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; -import android.view.View; -import android.widget.TextView; - -import androidx.recyclerview.widget.RecyclerView; -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.activity.FollowingActivity; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -@RunWith(AndroidJUnit4.class) -public class FollowingActivityDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private Context context; - private List identityKeys; - - @Before - public void setUp() { - context = ApplicationProvider.getApplicationContext(); - FollowingSettings.setEnabled(true); - String updatedIdentity = "tmdb:tv:900001:s1"; - String quietIdentity = "tmdb:tv:900002:s1"; - identityKeys = List.of(updatedIdentity, quietIdentity); - FollowingDatabase database = FollowingDatabase.get(); - database.getFollowingSourceDao().deleteAll(); - database.getFollowingDao().deleteAll(); - Following item = new Following(); - item.identityKey = updatedIdentity; - item.seriesKey = "tmdb:tv:900001"; - item.cid = 0; - item.siteKey = "test"; - item.vodId = "vod"; - item.vodName = "设备验证剧集"; - item.trackedSeason = 1; - item.latestReleasedSeason = 1; - item.latestReleasedEpisode = 5; - item.seasonReleasedEpisodes = 5; - item.readWatermarkEpisode = 1; - item.watchedEpisode = 2; - item.unwatchedCount = 3; - item.hasUpdate = true; - item.enabled = true; - item.nextCheckAt = Long.MAX_VALUE; - item.updatedAt = System.currentTimeMillis(); - FollowingSource source = new FollowingSource(); - source.followingKey = updatedIdentity; - source.siteKey = "test"; - source.vodId = "vod"; - source.preferred = true; - source.playableSeason = 1; - source.playableEpisode = 4; - source.playableCount = 4; - FollowingStore.saveNew(item, source); - - Following quiet = item.copy(); - quiet.identityKey = quietIdentity; - quiet.seriesKey = "tmdb:tv:900002"; - quiet.vodId = "vod2"; - quiet.vodName = "无更新剧集"; - quiet.watchedEpisode = 5; - quiet.readWatermarkEpisode = 5; - quiet.unwatchedCount = 0; - quiet.hasUpdate = false; - FollowingSource quietSource = source.copy(); - quietSource.followingKey = quietIdentity; - quietSource.vodId = "vod2"; - FollowingStore.saveNew(quiet, quietSource); - } - - @After - public void tearDown() { - for (String identityKey : identityKeys) FollowingStore.delete(identityKey); - } - - @Test - public void activityRendersStoredFollowingAndFiltersUpdates() { - try (ActivityScenario scenario = ActivityScenario.launch(FollowingActivity.class)) { - assertTrue(await(scenario, activity -> { - RecyclerView recycler = activity.findViewById(R.id.recycler); - return recycler != null && recycler.getAdapter() != null && recycler.getAdapter().getItemCount() == 2; - })); - - assertTrue(await(scenario, activity -> { - TextView summary = activity.findViewById(R.id.summary); - return summary != null && "追更 2 部 · 未读 0 部".contentEquals(summary.getText()); - })); - scenario.onActivity(activity -> { - TextView summary = activity.findViewById(R.id.summary); - assertEquals("追更 2 部 · 未读 0 部", summary.getText().toString()); - RecyclerView recycler = activity.findViewById(R.id.recycler); - RecyclerView.ViewHolder holder = recycler.findViewHolderForAdapterPosition(0); - assertNotNull(holder); - assertEquals(View.GONE, holder.itemView.findViewById(R.id.read).getVisibility()); - assertEquals(View.GONE, holder.itemView.findViewById(R.id.badge).getVisibility()); - activity.findViewById(R.id.filter).performClick(); - }); - - assertTrue(await(scenario, activity -> { - TextView filter = activity.findViewById(R.id.filter); - RecyclerView recycler = activity.findViewById(R.id.recycler); - return filter != null && "只看更新".contentEquals(filter.getText()) - && recycler != null && recycler.getAdapter() != null - && recycler.getAdapter().getItemCount() == 1; - })); - } - assertFalse(FollowingStore.find(identityKeys.get(0)).hasUpdate); - } - - private boolean await(ActivityScenario scenario, Check check) { - boolean[] result = new boolean[1]; - for (int i = 0; i < 30; i++) { - result[0] = false; - scenario.onActivity(activity -> result[0] = check.value(activity)); - if (result[0]) return true; - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - fail("interrupted while waiting for FollowingActivity"); - } - } - return false; - } - - private interface Check { - boolean value(FollowingActivity activity); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingBackupDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingBackupDeviceTest.java deleted file mode 100644 index 9b7160c1a3..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingBackupDeviceTest.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; - -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -@RunWith(AndroidJUnit4.class) -public class FollowingBackupDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private Context context; - private String identityKey; - - @Before - public void setUp() { - context = ApplicationProvider.getApplicationContext(); - identityKey = "tmdb:tv:992234:s1"; - FollowingStore.replaceAll(List.of(), List.of()); - } - - @After - public void tearDown() { - FollowingStore.replaceAll(List.of(), List.of()); - } - - @Test - public void realDatabaseRestoreAndMergeKeepIdentityAndWatermarks() { - Following local = following(); - local.readWatermarkEpisode = 3; - local.lastNotifiedEpisode = 3; - local.watchedEpisode = 2; - local.nextCheckAt = 999; - local.lastError = "local only"; - FollowingSource source = source(); - FollowingStore.saveNew(local, source); - - FollowingBackupCodec.Payload payload = FollowingBackupCodec.capture(); - assertEquals(1, payload.following().size()); - assertEquals(1, payload.sources().size()); - - Following remote = local.copy(); - remote.readWatermarkEpisode = 7; - remote.lastNotifiedEpisode = 6; - remote.watchedEpisode = 5; - remote.updatedAt = local.updatedAt + 1; - FollowingSource remoteSource = source.copy(); - remoteSource.playableEpisode = 8; - remoteSource.playableCount = 8; - - FollowingBackupCodec.merge(List.of(remote), List.of(remoteSource)); - - Following merged = FollowingStore.find(identityKey); - FollowingSource mergedSource = FollowingStore.preferredSource(identityKey); - assertNotNull(merged); - assertEquals(7, merged.readWatermarkEpisode); - assertEquals(6, merged.lastNotifiedEpisode); - assertEquals(5, merged.watchedEpisode); - assertEquals(999, merged.nextCheckAt); - assertEquals("local only", merged.lastError); - assertEquals(8, mergedSource.playableEpisode); - - FollowingBackupCodec.restoreFull(payload.following(), payload.sources()); - assertEquals(1, FollowingStore.list().size()); - assertEquals(3, FollowingStore.find(identityKey).readWatermarkEpisode); - assertEquals(1, FollowingStore.sources(identityKey).size()); - } - - private Following following() { - Following item = new Following(); - item.identityKey = identityKey; - item.seriesKey = "tmdb:tv:992234"; - item.cid = 1; - item.siteKey = "site"; - item.vodId = "vod"; - item.vodName = "备份测试"; - item.trackedSeason = 1; - item.latestReleasedSeason = 1; - item.latestReleasedEpisode = 8; - item.enabled = true; - item.createdAt = 100; - item.updatedAt = 100; - return item; - } - - private FollowingSource source() { - FollowingSource item = new FollowingSource(); - item.followingKey = identityKey; - item.cid = 1; - item.siteKey = "site"; - item.vodId = "vod"; - item.vodName = "备份测试"; - item.preferred = true; - item.playableEpisode = 5; - item.playableCount = 5; - return item; - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingCheckDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingCheckDeviceTest.java deleted file mode 100644 index e87b5689b9..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingCheckDeviceTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.view.View; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.activity.FollowingActivity; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingCheckDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - @Before - public void setUp() { - FollowingSettings.setEnabled(true); - FollowingStore.replaceAll(List.of(), List.of()); - } - - @After - public void tearDown() { - FollowingStore.replaceAll(List.of(), List.of()); - } - - @Test - public void checkAllStartedOnMainThreadDoesNotAccessRoomOnMainThread() throws Exception { - try (ActivityScenario scenario = ActivityScenario.launch(FollowingActivity.class)) { - scenario.onActivity(activity -> activity.findViewById(R.id.check).performClick()); - assertTrue(await(scenario, activity -> { - View check = activity.findViewById(R.id.check); - return check != null && check.isEnabled(); - })); - } - } - - private boolean await(ActivityScenario scenario, Check check) throws Exception { - for (int i = 0; i < 50; i++) { - AtomicBoolean result = new AtomicBoolean(); - scenario.onActivity(activity -> result.set(check.value(activity))); - if (result.get()) return true; - Thread.sleep(100); - } - return false; - } - - private interface Check { - boolean value(FollowingActivity activity); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingContinueDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingContinueDeviceTest.java deleted file mode 100644 index 51c99fa1a4..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingContinueDeviceTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.db.AppDatabase; - -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.After; -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -@RunWith(AndroidJUnit4.class) -public class FollowingContinueDeviceTest { - - private static final String HISTORY_KEY = "source-a@@@vod-a"; - - @After - public void tearDown() { - AppDatabase.get().getHistoryDao().delete(0, HISTORY_KEY); - } - - @Test - public void historyForFallsBackToTmdbProgressFromAnotherSource() { - AppDatabase.get().getHistoryDao().delete(0, HISTORY_KEY); - History history = new History(); - history.setKey(HISTORY_KEY); - history.setCid(0); - history.setVodName("跨源续播验证"); - history.setMediaType("tv"); - history.setTmdbId(910003); - history.setTmdbEpisodePosition(1, 4); - history.setPosition(8_000); - history.setDuration(24_000); - history.setCreateTime(100); - AppDatabase.get().getHistoryDao().insertOrUpdate(history); - - Following item = new Following(); - item.cid = 0; - item.mediaType = "tv"; - item.tmdbId = 910003; - item.trackedSeason = 1; - FollowingSource source = new FollowingSource(); - source.siteKey = "source-b"; - source.vodId = "vod-b"; - - History restored = FollowingStore.historyFor(item, source); - - assertNotNull(restored); - assertEquals(HISTORY_KEY, restored.getKey()); - assertEquals(4, restored.getTmdbEpisodeNumber()); - assertEquals(8_000, restored.getPosition()); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDatabaseTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDatabaseTest.java deleted file mode 100644 index 2b1fdcf881..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDatabaseTest.java +++ /dev/null @@ -1,214 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; -import android.database.sqlite.SQLiteDatabase; - -import androidx.test.core.app.ApplicationProvider; -import androidx.room.Room; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingDatabaseTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private Context context; - private FollowingDatabase database; - @Before - public void setUp() { - context = ApplicationProvider.getApplicationContext(); - database = Room.inMemoryDatabaseBuilder(context, FollowingDatabase.class).build(); - } - - @After - public void tearDown() { - if (database != null) database.close(); - } - - @Test - public void createsIndependentDatabaseAndStoresFollowAndSource() { - Following item = new Following(); - item.identityKey = "tmdb:tv:1:s1"; - item.seriesKey = "tmdb:tv:1"; - item.vodName = "Example"; - item.trackedSeason = 1; - item.enabled = true; - item.nextCheckAt = 10; - database.getFollowingDao().insertOrUpdate(item); - - FollowingSource source = new FollowingSource(); - source.followingKey = item.identityKey; - source.siteKey = "site"; - source.vodId = "vod"; - source.preferred = true; - database.getFollowingSourceDao().insertOrUpdate(source); - - assertNotNull(database.getFollowingDao().find(item.identityKey)); - assertEquals(item.identityKey, database.getFollowingDao().findDue(20).get(0).identityKey); - assertEquals("vod", database.getFollowingSourceDao().findPreferred(item.identityKey).vodId); - } - - @Test - public void migratesV1FollowingDataToV2PreservingRows() throws Exception { - String identityKey = "tmdb:tv:1:s1"; - String databaseName = FollowingDatabase.NAME + "-migration-test.db"; - context.deleteDatabase(databaseName); - - SQLiteDatabase raw = context.openOrCreateDatabase(databaseName, Context.MODE_PRIVATE, null); - try { - raw.execSQL("CREATE TABLE IF NOT EXISTS `following` (`identity_key` TEXT NOT NULL, `series_key` TEXT NOT NULL, " - + "`cid` INTEGER NOT NULL, `site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, " - + "`vod_pic` TEXT NOT NULL, `media_type` TEXT NOT NULL, `tmdb_id` INTEGER NOT NULL, " - + "`tracked_season` INTEGER NOT NULL, `tracked_episode` INTEGER NOT NULL, `watched_season` INTEGER NOT NULL, " - + "`watched_episode` INTEGER NOT NULL, `position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, " - + "`official_status` TEXT NOT NULL, `latest_released_season` INTEGER NOT NULL, " - + "`latest_released_episode` INTEGER NOT NULL, `season_total_episodes` INTEGER NOT NULL, " - + "`season_released_episodes` INTEGER NOT NULL, `series_total_episodes` INTEGER NOT NULL, " - + "`next_air_season` INTEGER NOT NULL, `next_air_episode` INTEGER NOT NULL, `next_air_at` INTEGER NOT NULL, " - + "`last_observed_episode` INTEGER NOT NULL, `read_watermark_episode` INTEGER NOT NULL, " - + "`last_notified_episode` INTEGER NOT NULL, `last_notified_at` INTEGER NOT NULL, `has_update` INTEGER NOT NULL, " - + "`unwatched_count` INTEGER NOT NULL, `notify_enabled` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, " - + "`metadata_updated_at` INTEGER NOT NULL, `last_checked_at` INTEGER NOT NULL, `next_check_at` INTEGER NOT NULL, " - + "`failure_count` INTEGER NOT NULL, `last_error` TEXT NOT NULL, `created_at` INTEGER NOT NULL, " - + "`updated_at` INTEGER NOT NULL, PRIMARY KEY(`identity_key`))"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_series_key` ON `following` (`series_key`)"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_enabled_next_check_at` ON `following` (`enabled`, `next_check_at`)"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_has_update_updated_at` ON `following` (`has_update`, `updated_at`)"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_cid_site_key_vod_id` ON `following` (`cid`, `site_key`, `vod_id`)"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_tmdb_id_media_type_tracked_season` ON `following` (`tmdb_id`, `media_type`, `tracked_season`)"); - raw.execSQL("CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)"); - raw.execSQL("INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e868fdbdbc43809b2189d43aa0f766b')"); - raw.execSQL("CREATE TABLE IF NOT EXISTS `following_source` (`following_key` TEXT NOT NULL, `cid` INTEGER NOT NULL, " - + "`site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, `vod_pic` TEXT NOT NULL, " - + "`vod_flag` TEXT NOT NULL, `playable_season` INTEGER NOT NULL, `playable_episode` INTEGER NOT NULL, " - + "`playable_count` INTEGER NOT NULL, `preferred` INTEGER NOT NULL, `last_probe_at` INTEGER NOT NULL, " - + "`last_error` TEXT NOT NULL, PRIMARY KEY(`following_key`, `cid`, `site_key`, `vod_id`))"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_source_following_key_preferred` ON `following_source` (`following_key`, `preferred`)"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_source_cid_site_key_vod_id` ON `following_source` (`cid`, `site_key`, `vod_id`)"); - raw.setVersion(1); - raw.execSQL("INSERT INTO following (identity_key, series_key, cid, site_key, vod_id, vod_name, vod_pic, media_type, " - + "tmdb_id, tracked_season, tracked_episode, watched_season, watched_episode, position, duration, " - + "official_status, latest_released_season, latest_released_episode, season_total_episodes, " - + "season_released_episodes, series_total_episodes, next_air_season, next_air_episode, next_air_at, " - + "last_observed_episode, read_watermark_episode, last_notified_episode, last_notified_at, has_update, " - + "unwatched_count, notify_enabled, enabled, metadata_updated_at, last_checked_at, next_check_at, " - + "failure_count, last_error, created_at, updated_at) VALUES (?, '', 0, '', '', 'Example', '', 'tv', 1, 1, 0, " - + "0, 0, 0, 0, 'UNKNOWN', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 10, 0, '', 0, 0)", - new Object[]{identityKey}); - } finally { - raw.close(); - } - - FollowingDatabase migrated = null; - try { - migrated = Room.databaseBuilder(context, FollowingDatabase.class, databaseName) - .addMigrations(FollowingDatabase.MIGRATION_1_2, FollowingDatabase.MIGRATION_2_3) - .allowMainThreadQueries() - .build(); - Following item = migrated.getFollowingDao().find(identityKey); - assertNotNull(item); - assertEquals(0, item.nextAirWeekday); - assertEquals(0, item.deletedAt); - assertFalse(item.isDeleted()); - } finally { - if (migrated != null) migrated.close(); - context.deleteDatabase(databaseName); - } - } - - @Test - public void migratesV2FollowingDataToV3MarkingDeletedRowsAsTombstones() throws Exception { - String databaseName = FollowingDatabase.NAME + "-migration-v2-to-v3-test.db"; - String identityKey = "tmdb:tv:777001:s1"; - String disabledKey = "tmdb:tv:777001:s2"; - context.deleteDatabase(databaseName); - - SQLiteDatabase raw = context.openOrCreateDatabase(databaseName, Context.MODE_PRIVATE, null); - try { - // v2 结构(含 next_air_weekday,无 deleted_at) - raw.execSQL("CREATE TABLE IF NOT EXISTS `following` (`identity_key` TEXT NOT NULL, `series_key` TEXT NOT NULL, " - + "`cid` INTEGER NOT NULL, `site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, " - + "`vod_pic` TEXT NOT NULL, `media_type` TEXT NOT NULL, `tmdb_id` INTEGER NOT NULL, `tracked_season` INTEGER NOT NULL, " - + "`tracked_episode` INTEGER NOT NULL, `watched_season` INTEGER NOT NULL, `watched_episode` INTEGER NOT NULL, " - + "`position` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `official_status` TEXT NOT NULL, " - + "`latest_released_season` INTEGER NOT NULL, `latest_released_episode` INTEGER NOT NULL, " - + "`season_total_episodes` INTEGER NOT NULL, `season_released_episodes` INTEGER NOT NULL, " - + "`series_total_episodes` INTEGER NOT NULL, `next_air_season` INTEGER NOT NULL, `next_air_episode` INTEGER NOT NULL, " - + "`next_air_at` INTEGER NOT NULL, `next_air_weekday` INTEGER NOT NULL, `last_observed_episode` INTEGER NOT NULL, " - + "`read_watermark_episode` INTEGER NOT NULL, `last_notified_episode` INTEGER NOT NULL, `last_notified_at` INTEGER NOT NULL, " - + "`has_update` INTEGER NOT NULL, `unwatched_count` INTEGER NOT NULL, `notify_enabled` INTEGER NOT NULL, " - + "`enabled` INTEGER NOT NULL, `metadata_updated_at` INTEGER NOT NULL, `last_checked_at` INTEGER NOT NULL, " - + "`next_check_at` INTEGER NOT NULL, `failure_count` INTEGER NOT NULL, `last_error` TEXT NOT NULL, " - + "`created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`identity_key`))"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_series_key` ON `following` (`series_key`)"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_enabled_next_check_at` ON `following` (`enabled`, `next_check_at`)"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_has_update_updated_at` ON `following` (`has_update`, `updated_at`)"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_cid_site_key_vod_id` ON `following` (`cid`, `site_key`, `vod_id`)"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_tmdb_id_media_type_tracked_season` ON `following` (`tmdb_id`, `media_type`, `tracked_season`)"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_next_air_weekday` ON `following` (`next_air_weekday`)"); - raw.execSQL("CREATE TABLE IF NOT EXISTS `following_source` (`following_key` TEXT NOT NULL, `cid` INTEGER NOT NULL, " - + "`site_key` TEXT NOT NULL, `vod_id` TEXT NOT NULL, `vod_name` TEXT NOT NULL, `vod_pic` TEXT NOT NULL, " - + "`vod_flag` TEXT NOT NULL, `playable_season` INTEGER NOT NULL, `playable_episode` INTEGER NOT NULL, " - + "`playable_count` INTEGER NOT NULL, `preferred` INTEGER NOT NULL, `last_probe_at` INTEGER NOT NULL, " - + "`last_error` TEXT NOT NULL, PRIMARY KEY(`following_key`, `cid`, `site_key`, `vod_id`))"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_source_following_key_preferred` ON `following_source` (`following_key`, `preferred`)"); - raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_source_cid_site_key_vod_id` ON `following_source` (`cid`, `site_key`, `vod_id`)"); - raw.execSQL("CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)"); - raw.execSQL("INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '335206a4549d61c15ae0792885264d71')"); - raw.execSQL("INSERT INTO following (identity_key, series_key, cid, site_key, vod_id, vod_name, vod_pic, media_type, " - + "tmdb_id, tracked_season, tracked_episode, watched_season, watched_episode, position, duration, " - + "official_status, latest_released_season, latest_released_episode, season_total_episodes, " - + "season_released_episodes, series_total_episodes, next_air_season, next_air_episode, next_air_at, " - + "next_air_weekday, last_observed_episode, read_watermark_episode, last_notified_episode, last_notified_at, " - + "has_update, unwatched_count, notify_enabled, enabled, metadata_updated_at, last_checked_at, next_check_at, " - + "failure_count, last_error, created_at, updated_at) VALUES (?, '', 0, '', '', 'Active', '', 'tv', 777001, 1, 0, " - + "0, 0, 0, 0, 'UNKNOWN', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 10, 0, '', 100, 100)", - new Object[]{identityKey}); - raw.execSQL("INSERT INTO following (identity_key, series_key, cid, site_key, vod_id, vod_name, vod_pic, media_type, " - + "tmdb_id, tracked_season, tracked_episode, watched_season, watched_episode, position, duration, " - + "official_status, latest_released_season, latest_released_episode, season_total_episodes, " - + "season_released_episodes, series_total_episodes, next_air_season, next_air_episode, next_air_at, " - + "next_air_weekday, last_observed_episode, read_watermark_episode, last_notified_episode, last_notified_at, " - + "has_update, unwatched_count, notify_enabled, enabled, metadata_updated_at, last_checked_at, next_check_at, " - + "failure_count, last_error, created_at, updated_at) VALUES (?, '', 0, '', '', 'Disabled', '', 'tv', 777001, 2, 0, " - + "0, 0, 0, 0, 'UNKNOWN', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, '', 100, 100)", - new Object[]{disabledKey}); - raw.setVersion(2); - } finally { - raw.close(); - } - - FollowingDatabase migrated = null; - try { - migrated = Room.databaseBuilder(context, FollowingDatabase.class, databaseName) - .addMigrations(FollowingDatabase.MIGRATION_1_2, FollowingDatabase.MIGRATION_2_3) - .allowMainThreadQueries() - .build(); - Following active = migrated.getFollowingDao().find(identityKey); - assertNotNull(active); - assertFalse(active.isDeleted()); - assertEquals(0, active.deletedAt); - Following disabled = migrated.getFollowingDao().find(disabledKey); - assertNotNull(disabled); - // v2 时代 enabled=0 的行几乎必然来自旧版墓碑传播;迁移后视为墓碑,不回 UI。 - assertTrue(disabled.deletedAt > 0); - assertTrue(disabled.isDeleted()); - } finally { - if (migrated != null) migrated.close(); - context.deleteDatabase(databaseName); - } - } - -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeleteDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeleteDeviceTest.java deleted file mode 100644 index 6b9cd81e89..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeleteDeviceTest.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.fongmi.android.tv.following; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -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; - -@RunWith(AndroidJUnit4.class) -public class FollowingDeleteDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private static final String IDENTITY = "tmdb:tv:993344:s1"; - - @Before - public void setUp() { - FollowingStore.replaceAll(List.of(), List.of()); - Following item = new Following(); - item.identityKey = IDENTITY; - item.seriesKey = "tmdb:tv:993344"; - item.vodName = "取消追更测试"; - item.trackedSeason = 1; - item.enabled = true; - FollowingSource source = new FollowingSource(); - source.followingKey = IDENTITY; - source.siteKey = "site"; - source.vodId = "vod"; - FollowingStore.saveNew(item, source); - } - - @After - public void tearDown() { - FollowingStore.replaceAll(List.of(), List.of()); - } - - @Test - public void deleteStartedOnMainThreadCompletesWithoutRoomMainThreadCrash() throws Exception { - CountDownLatch latch = new CountDownLatch(1); - AtomicReference failure = new AtomicReference<>(); - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> - FollowingPlaybackBridge.deleteAsync(IDENTITY, error -> { - failure.set(error); - latch.countDown(); - })); - - assertTrue(latch.await(5, TimeUnit.SECONDS)); - assertNull(failure.get()); - // 删除写墓碑:UI 查不到,但原始行仍在(墓碑随同步传播)。 - assertNull(FollowingStore.find(IDENTITY)); - assertNotNull(FollowingStore.findAny(IDENTITY)); - assertTrue(FollowingStore.findAny(IDENTITY).isDeleted()); - } - - @Test - public void tombstoneSurvivesRemoteSnapshotMergeWithoutDeletedRow() throws Exception { - // 对端快照不含已取消项(旧版本设备):本地墓碑不能被 union 合并复活。 - awaitDelete(); - FollowingStore.mergeAll(List.of(), List.of()); - assertNotNull(FollowingStore.findAny(IDENTITY)); - assertTrue(FollowingStore.findAny(IDENTITY).isDeleted()); - assertNull(FollowingStore.find(IDENTITY)); - - // 对端快照携带同一墓碑:墓碑保留,不产生重复行。 - Following tombstone = FollowingStore.findAny(IDENTITY).copy(); - FollowingStore.mergeAll(List.of(tombstone), List.of()); - assertNotNull(FollowingStore.findAny(IDENTITY)); - assertTrue(FollowingStore.findAny(IDENTITY).isDeleted()); - assertEquals(tombstone.deletedAt, FollowingStore.findAny(IDENTITY).deletedAt); - } - - @Test - public void reFollowRevivesTombstoneWithFreshCreatedAt() throws Exception { - awaitDelete(); - long deletedAt = FollowingStore.findAny(IDENTITY).deletedAt; - - CountDownLatch latch = new CountDownLatch(1); - AtomicReference revived = new AtomicReference<>(); - Following fresh = FollowingDeviceDataRule.newFollowing(IDENTITY); - FollowingPlaybackBridge.addAsync(fresh, null, (saved, error) -> { - revived.set(saved); - latch.countDown(); - }); - assertTrue(latch.await(5, TimeUnit.SECONDS)); - assertNotNull(revived.get()); - - Following active = FollowingStore.find(IDENTITY); - assertNotNull(active); - assertFalse(active.isDeleted()); - assertTrue(active.createdAt > deletedAt); - } - - @Test - public void alistSubscriptionImportSkipsTombstonedIdentity() throws Exception { - // 用户已取消的追更,订阅导入不得复活墓碑,也不得新建同 identityKey 行。 - awaitDelete(); - AlistSubscriptionImporter.Candidate candidate = new AlistSubscriptionImporter.Candidate(); - candidate.title = "取消追更测试"; - candidate.season = 1; - candidate.tmdbId = 993344; - - AlistSubscriptionImporter.ImportResult result = AlistSubscriptionImporter.importCandidates(List.of(candidate)); - - assertEquals(0, result.created); - assertNull(FollowingStore.find(IDENTITY)); - assertTrue(FollowingStore.findAny(IDENTITY).isDeleted()); - } - - private void awaitDelete() throws Exception { - CountDownLatch latch = new CountDownLatch(1); - AtomicReference failure = new AtomicReference<>(); - FollowingPlaybackBridge.deleteAsync(IDENTITY, error -> { - failure.set(error); - latch.countDown(); - }); - assertTrue(latch.await(5, TimeUnit.SECONDS)); - assertNull(failure.get()); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDetailDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDetailDeviceTest.java deleted file mode 100644 index 10866b0157..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDetailDeviceTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.fongmi.android.tv.following; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingDetailDeviceTest { - - @Test - public void databaseLookupCanStartOnMainThreadWithoutMainThreadRoomAccess() throws Exception { - CountDownLatch latch = new CountDownLatch(1); - AtomicBoolean mainThread = new AtomicBoolean(); - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> - FollowingPlaybackBridge.findAsync("missing:following:key", item -> { - mainThread.set(android.os.Looper.myLooper() == android.os.Looper.getMainLooper()); - latch.countDown(); - })); - - assertTrue(latch.await(5, TimeUnit.SECONDS)); - assertTrue("callback should return to main thread", mainThread.get()); - assertFalse(FollowingStore.database().getFollowingDao().findAll().stream() - .anyMatch(item -> "missing:following:key".equals(item.identityKey))); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeviceDataRule.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeviceDataRule.java deleted file mode 100644 index 79e6f3e56e..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeviceDataRule.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.fongmi.android.tv.following; - -import org.junit.rules.TestWatcher; -import org.junit.runner.Description; - -import java.util.ArrayList; -import java.util.List; - -/** Preserves the real device following database around tests that use it as a fixture store. */ -public final class FollowingDeviceDataRule extends TestWatcher { - - private Snapshot snapshot = Snapshot.empty(); - - /** 测试用新建体:不带 Room 依赖,createdAt 由调用方设置。 */ - public static Following newFollowing(String identityKey) { - Following item = new Following(); - item.identityKey = identityKey; - item.seriesKey = identityKey; - item.trackedSeason = 1; - item.enabled = true; - item.createdAt = System.currentTimeMillis(); - item.updatedAt = item.createdAt; - return item; - } - - @Override - protected void starting(Description description) { - snapshot = Snapshot.capture(); - } - - @Override - protected void finished(Description description) { - snapshot.restore(); - } - - private static final class Snapshot { - private final List following; - private final List sources; - - private Snapshot(List following, List sources) { - this.following = following; - this.sources = sources; - } - - static Snapshot empty() { - return new Snapshot(List.of(), List.of()); - } - - static Snapshot capture() { - List following = new ArrayList<>(); - for (Following item : FollowingStore.database().getFollowingDao().findAll()) { - if (item != null) following.add(item.copy()); - } - List sources = new ArrayList<>(); - for (FollowingSource source : FollowingStore.database().getFollowingSourceDao().findAll()) { - if (source != null) sources.add(source.copy()); - } - return new Snapshot(following, sources); - } - - void restore() { - FollowingStore.replaceAll(following, sources); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingMigrationDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingMigrationDeviceTest.java deleted file mode 100644 index f2e7a5205c..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingMigrationDeviceTest.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.fongmi.android.tv.bean.TmdbItem; - -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.After; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -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; - -@RunWith(AndroidJUnit4.class) -public class FollowingMigrationDeviceTest { - - private static final String SOURCE_KEY = "source:9:migrate:930001:s0"; - private static final String TARGET_KEY = "tmdb:tv:930001:s1"; - - @After - public void tearDown() { - // 测试清理用物理删除:墓碑会永久阻止同 identityKey 的迁移断言(resolveTmdb 墓碑防护)。 - FollowingStore.purge(SOURCE_KEY); - FollowingStore.purge(TARGET_KEY); - } - - @Test - public void sourceFallbackMigratesToMatchedTmdbIdentity() { - FollowingStore.purge(SOURCE_KEY); - FollowingStore.purge(TARGET_KEY); - Following source = new Following(); - source.identityKey = SOURCE_KEY; - source.seriesKey = "source:9:migrate:930001"; - source.cid = 9; - source.siteKey = "migrate"; - source.vodId = "930001"; - source.vodName = "迁移验证剧集"; - source.mediaType = "tv"; - source.trackedSeason = 0; - source.watchedEpisode = 1; - source.readWatermarkEpisode = 1; - source.enabled = true; - source.nextCheckAt = Long.MAX_VALUE; - FollowingSource binding = new FollowingSource(); - binding.followingKey = SOURCE_KEY; - binding.cid = source.cid; - binding.siteKey = source.siteKey; - binding.vodId = source.vodId; - binding.vodName = source.vodName; - binding.playableEpisode = 16; - binding.playableCount = 16; - binding.preferred = true; - FollowingStore.saveNew(source, binding); - - FollowingMetadataSnapshot snapshot = new FollowingMetadataSnapshot(); - snapshot.source = "tmdb"; - snapshot.status = FollowingMetadataSnapshot.RETURNING; - snapshot.latestReleasedSeason = 1; - snapshot.latestReleasedEpisode = 12; - snapshot.seasonTotalEpisodes = 18; - snapshot.seasonReleasedEpisodes = 12; - snapshot.seriesTotalEpisodes = 18; - snapshot.fetchedAt = System.currentTimeMillis(); - - Following migrated = FollowingStore.resolveTmdb( - new TmdbItem(930001, "tv", source.vodName, "", "", "", ""), 1, - source.cid, source.siteKey, source.vodId, snapshot); - - assertNotNull(migrated); - assertNull(FollowingStore.find(SOURCE_KEY)); - Following target = FollowingStore.find(TARGET_KEY); - assertNotNull(target); - assertEquals(930001, target.tmdbId); - assertEquals(1, target.trackedSeason); - assertEquals(12, target.latestReleasedEpisode); - assertEquals(FollowingMetadataSnapshot.RETURNING, target.officialStatus); - FollowingSource moved = FollowingStore.preferredSource(TARGET_KEY); - assertNotNull(moved); - assertEquals(1, moved.playableSeason); - assertEquals(16, moved.playableEpisode); - } - - /** - * 锁定调用方必须依赖的契约:目标已是墓碑时 `resolveTmdb` 会**故意返回墓碑行而不是 null** - * (C9 防复活守卫)。因此任何“已追更?”判定都不能写成 `item != null`, - * 否则取消追更后按钮仍显示“已追更”,内联播放中再点只会重复写墓碑、无法重新追更。 - */ - @Test - public void tombstonedTargetIsReturnedAsTombstoneNotAsNull() throws Exception { - FollowingStore.purge(SOURCE_KEY); - FollowingStore.purge(TARGET_KEY); - - Following target = new Following(); - target.identityKey = TARGET_KEY; - target.seriesKey = "tmdb:tv:930001"; - target.cid = 9; - target.siteKey = "migrate"; - target.vodId = "930001"; - target.vodName = "墓碑判定验证"; - target.mediaType = "tv"; - target.tmdbId = 930001; - target.trackedSeason = 1; - target.enabled = true; - target.nextCheckAt = Long.MAX_VALUE; - FollowingStore.saveNew(target, null); - FollowingStore.delete(TARGET_KEY); - - // 活跃查询看不到墓碑,但原始行仍在。 - assertNull(FollowingStore.find(TARGET_KEY)); - assertNotNull(FollowingStore.findAny(TARGET_KEY)); - assertTrue(FollowingStore.findAny(TARGET_KEY).isDeleted()); - - Following resolved = FollowingStore.resolveTmdb( - new TmdbItem(930001, "tv", target.vodName, "", "", "", ""), 1, - 9, "migrate", "930001", null); - - assertNotNull("resolveTmdb 对墓碑目标必须返回墓碑行而非 null", resolved); - assertTrue("返回行必须是墓碑,调用方据此判定为未追更", resolved.isDeleted()); - - // 完整闭环:UI 的 isFollowed() 会把墓碑判为未追更 → 走 addFollowing → - // FollowingPlaybackBridge.addAsync → FollowingStore.saveNew 的复活分支。 - // 这条链路必须能把已取消的追更重新追回来。 - Following fresh = new Following(); - fresh.identityKey = TARGET_KEY; - fresh.seriesKey = "tmdb:tv:930001"; - fresh.cid = 9; - fresh.siteKey = "migrate"; - fresh.vodId = "930001"; - fresh.vodName = target.vodName; - fresh.mediaType = "tv"; - fresh.tmdbId = 930001; - fresh.trackedSeason = 1; - fresh.enabled = true; - fresh.createdAt = System.currentTimeMillis(); - fresh.nextCheckAt = Long.MAX_VALUE; - - CountDownLatch latch = new CountDownLatch(1); - AtomicReference failure = new AtomicReference<>(); - FollowingPlaybackBridge.addAsync(fresh, null, (saved, error) -> { - failure.set(error); - latch.countDown(); - }); - assertTrue(latch.await(5, TimeUnit.SECONDS)); - assertNull(failure.get()); - - Following revived = FollowingStore.find(TARGET_KEY); - assertNotNull("点击“加入追更”后必须复活为活跃行", revived); - assertFalse("复活后不得再是墓碑", revived.isDeleted()); - assertTrue(revived.enabled); - assertEquals(930001, revived.tmdbId); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingNotifierDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingNotifierDeviceTest.java deleted file mode 100644 index de70d1eda3..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingNotifierDeviceTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.fongmi.android.tv.following; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.fongmi.android.tv.ui.activity.FollowingActivity; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; - -@RunWith(AndroidJUnit4.class) -public class FollowingNotifierDeviceTest { - - private boolean originalNotificationsEnabled; - - @Before - public void setUp() { - originalNotificationsEnabled = FollowingSettings.isNotificationsEnabled(); - FollowingSettings.setEnabled(true); - FollowingSettings.setNotificationsEnabled(true); - } - - @After - public void tearDown() { - FollowingSettings.setNotificationsEnabled(originalNotificationsEnabled); - } - - @Test - public void foregroundSuppressesNotificationWithoutAdvancingWatermark() { - Following item = new Following(); - item.identityKey = "tmdb:tv:910002:s1"; - item.vodName = "前台通知验证"; - item.trackedSeason = 1; - item.latestReleasedSeason = 1; - item.latestReleasedEpisode = 5; - item.readWatermarkEpisode = 4; - item.lastNotifiedEpisode = 2; - item.notifyEnabled = true; - item.enabled = true; - - try (ActivityScenario scenario = ActivityScenario.launch(FollowingActivity.class)) { - scenario.onActivity(activity -> { - assertFalse(FollowingNotifier.notifyUpdate(item, null)); - assertEquals(2, item.lastNotifiedEpisode); - }); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingProjectionDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingProjectionDeviceTest.java deleted file mode 100644 index ee85817ea9..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingProjectionDeviceTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.db.AppDatabase; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.After; -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingProjectionDeviceTest { - - private static final String IDENTITY_KEY = "tmdb:tv:910001:s1"; - private static final String HISTORY_KEY = "projection@@@item"; - - @After - public void tearDown() { - FollowingStore.delete(IDENTITY_KEY); - AppDatabase.get().getHistoryDao().delete(0, HISTORY_KEY); - } - - @Test - public void mainThreadProjectionQueuesWithoutMainThreadRoomAccess() throws Exception { - FollowingStore.delete(IDENTITY_KEY); - AppDatabase.get().getHistoryDao().delete(0, HISTORY_KEY); - - Following item = new Following(); - item.identityKey = IDENTITY_KEY; - item.seriesKey = "tmdb:tv:910001"; - item.siteKey = "projection"; - item.vodId = "item"; - item.mediaType = "tv"; - item.tmdbId = 910001; - item.trackedSeason = 1; - item.enabled = true; - item.nextCheckAt = Long.MAX_VALUE; - FollowingStore.saveNew(item, null); - - History history = new History(); - history.setKey(HISTORY_KEY); - history.setCid(0); - history.setVodName("投影验证剧集"); - history.setMediaType("tv"); - history.setTmdbId(910001); - history.setTmdbEpisodePosition(1, 3); - history.setPosition(12_000); - history.setDuration(24_000); - history.setCreateTime(System.currentTimeMillis()); - AppDatabase.get().getHistoryDao().insertOrUpdate(history); - - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> FollowingStore.project(history)); - - Following projected = awaitProjected(); - assertEquals(3, projected.watchedEpisode); - assertEquals(12_000, projected.position); - } - - private Following awaitProjected() throws Exception { - for (int i = 0; i < 50; i++) { - Following item = FollowingStore.find(IDENTITY_KEY); - if (item != null && item.watchedEpisode == 3) return item; - Thread.sleep(100); - } - throw new AssertionError("following projection did not complete"); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingSchedulerDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingSchedulerDeviceTest.java deleted file mode 100644 index 645cb0255d..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingSchedulerDeviceTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; - -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.work.WorkInfo; -import androidx.work.WorkManager; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingSchedulerDeviceTest { - - private Context context; - private boolean originalEnabled; - - @Before - public void setUp() { - context = ApplicationProvider.getApplicationContext(); - originalEnabled = FollowingSettings.isEnabled(); - FollowingSettings.setEnabled(true); - FollowingScheduler.cancelAll(context); - } - - @After - public void tearDown() { - FollowingScheduler.cancelAll(context); - FollowingSettings.setEnabled(originalEnabled); - } - - @Test - public void uniquePeriodicAndOneShotWorkAreEnqueuedAndCanceled() throws Exception { - FollowingScheduler.ensurePeriodic(context); - FollowingScheduler.enqueueDueNow(context); - - assertTrue(awaitState(FollowingScheduler.PERIODIC_WORK, false)); - assertTrue(awaitState(FollowingScheduler.ONE_SHOT_WORK, false)); - - FollowingSettings.setEnabled(false); - FollowingScheduler.cancelAll(context); - - assertTrue(awaitState(FollowingScheduler.PERIODIC_WORK, true)); - assertTrue(awaitState(FollowingScheduler.ONE_SHOT_WORK, true)); - } - - private boolean awaitState(String uniqueName, boolean canceled) throws Exception { - for (int i = 0; i < 30; i++) { - List infos = WorkManager.getInstance(context) - .getWorkInfosForUniqueWork(uniqueName) - .get(5, TimeUnit.SECONDS); - if (canceled && infos.isEmpty()) return true; - if (!infos.isEmpty() && infos.stream().allMatch(info -> canceled - ? info.getState() == WorkInfo.State.CANCELLED - : info.getState() == WorkInfo.State.ENQUEUED || info.getState() == WorkInfo.State.RUNNING)) { - return true; - } - Thread.sleep(100); - } - return false; - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingUpdateCoordinatorDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingUpdateCoordinatorDeviceTest.java deleted file mode 100644 index 5bc2575457..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingUpdateCoordinatorDeviceTest.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.fongmi.android.tv.following; - -import android.content.Context; - -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.IOException; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -@RunWith(AndroidJUnit4.class) -public class FollowingUpdateCoordinatorDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private Context context; - private String identityKey; - - @Before - public void setUp() { - context = ApplicationProvider.getApplicationContext(); - FollowingSettings.setEnabled(true); - identityKey = "tmdb:tv:991234:s1"; - FollowingDatabase database = FollowingDatabase.get(); - database.getFollowingSourceDao().deleteAll(); - database.getFollowingDao().deleteAll(); - - Following item = new Following(); - item.identityKey = identityKey; - item.seriesKey = "tmdb:tv:991234"; - item.cid = 1; - item.siteKey = "test-site"; - item.vodId = "test-vod"; - item.vodName = "TMDB 降级测试"; - item.mediaType = "tv"; - item.tmdbId = 991234; - item.trackedSeason = 1; - item.latestReleasedSeason = 1; - item.latestReleasedEpisode = 6; - item.seasonReleasedEpisodes = 6; - item.officialStatus = FollowingMetadataSnapshot.RETURNING; - item.metadataUpdatedAt = 1_000; - item.enabled = true; - item.nextCheckAt = Long.MAX_VALUE; - item.createdAt = 1_000; - item.updatedAt = 1_000; - - FollowingSource source = new FollowingSource(); - source.followingKey = identityKey; - source.cid = item.cid; - source.siteKey = item.siteKey; - source.vodId = item.vodId; - source.vodName = item.vodName; - source.preferred = true; - FollowingStore.saveNew(item, source); - } - - @After - public void tearDown() { - FollowingScheduler.cancelNext(context, identityKey); - FollowingStore.delete(identityKey); - } - - @Test - public void tmdbFailureFallsBackToBoundSourceWithoutOverwritingOfficialState() { - AtomicInteger probes = new AtomicInteger(); - FollowingMetadataClient metadata = new FollowingMetadataClient() { - @Override - public FollowingMetadataSnapshot fetch(Following item, boolean refresh) throws Exception { - throw new IOException("TMDB unavailable"); - } - }; - FollowingSourceProbe probe = new FollowingSourceProbe() { - @Override - public FollowingSourceSnapshot probe(Following item, FollowingSource source) { - probes.incrementAndGet(); - source.playableSeason = item.trackedSeason; - source.playableEpisode = 8; - source.playableCount = 8; - source.lastProbeAt = System.currentTimeMillis(); - source.lastError = ""; - FollowingSourceSnapshot snapshot = new FollowingSourceSnapshot(); - snapshot.source = source; - snapshot.playableEpisode = 8; - snapshot.playableCount = 8; - snapshot.probedAt = source.lastProbeAt; - return snapshot; - } - }; - - Following item = FollowingStore.find(identityKey); - assertTrue(new FollowingUpdateCoordinator(metadata, probe).check(item, false)); - - Following stored = FollowingStore.find(identityKey); - FollowingSource source = FollowingStore.preferredSource(identityKey); - assertEquals(FollowingMetadataSnapshot.RETURNING, stored.officialStatus); - assertEquals(6, stored.latestReleasedEpisode); - assertEquals(0, stored.failureCount); - assertEquals("TMDB不可用,已回退原站", stored.lastError); - assertEquals(8, source.playableEpisode); - assertEquals(1, probes.get()); - } - - @Test - public void failureKeepsPreviousSourceAndOfficialSnapshot() { - // 只锁「失败不覆盖旧快照 + 失败态记账」;失败分支内 update→reconcile 的先后顺序不在此用例覆盖范围, - // 由 FollowingFailureReconcileContractTest#reconcileRunsAfterTheFailureWrite 锁定。 - AtomicInteger probes = new AtomicInteger(); - FollowingSource seeded = FollowingStore.preferredSource(identityKey); - seeded.playableSeason = 1; - seeded.playableEpisode = 8; - seeded.playableCount = 8; - seeded.lastProbeAt = 1_000L; - seeded.lastError = ""; - FollowingStore.updateSource(seeded); - - FollowingMetadataClient metadata = new FollowingMetadataClient() { - @Override - public FollowingMetadataSnapshot fetch(Following item, boolean refresh) throws Exception { - throw new IOException("TMDB unavailable"); - } - }; - FollowingSourceProbe probe = new FollowingSourceProbe() { - @Override - public FollowingSourceSnapshot probe(Following item, FollowingSource source) { - probes.incrementAndGet(); - throw new IllegalStateException("来源没有可播放线路"); - } - }; - - long before = System.currentTimeMillis(); - Following item = FollowingStore.find(identityKey); - assertFalse(new FollowingUpdateCoordinator(metadata, probe).check(item, true, false)); - - Following stored = FollowingStore.find(identityKey); - FollowingSource source = FollowingStore.preferredSource(identityKey); - assertEquals(1, source.playableSeason); - assertEquals(8, source.playableEpisode); - assertEquals(8, source.playableCount); - assertEquals(FollowingMetadataSnapshot.RETURNING, stored.officialStatus); - assertEquals(6, stored.latestReleasedEpisode); - assertEquals(1, stored.failureCount); - assertTrue(stored.lastCheckedAt >= before); - assertTrue(stored.lastError != null && !stored.lastError.isBlank()); - assertTrue(stored.nextCheckAt > before); - assertEquals(1, probes.get()); - assertEquals("来源没有可播放线路", source.lastError); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/gitcloud/secure/GitCloudTokenStoreTest.java b/app/src/androidTest/java/com/fongmi/android/tv/gitcloud/secure/GitCloudTokenStoreTest.java deleted file mode 100644 index 3fc44c12dc..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/gitcloud/secure/GitCloudTokenStoreTest.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.fongmi.android.tv.gitcloud.secure; - -import android.content.Context; -import android.content.ContextWrapper; -import android.util.Base64; - -import androidx.test.platform.app.InstrumentationRegistry; - -import junit.framework.TestCase; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; -import java.io.OutputStream; -import java.security.Key; -import java.security.KeyStoreException; -import java.security.KeyStoreSpi; -import java.security.Provider; -import java.security.Security; -import java.security.cert.Certificate; -import java.util.Collections; -import java.util.Date; -import java.util.Enumeration; - -import javax.crypto.spec.SecretKeySpec; - -public class GitCloudTokenStoreTest extends TestCase { - private static final String KEYSTORE = "AndroidKeyStore"; - private static final String PREFIX = "local-v1:"; - private Provider original; - private int position; - private File directory; - private Context context; - - @Override - protected void setUp() throws Exception { - super.setUp(); - original = Security.getProvider(KEYSTORE); - Provider[] providers = Security.getProviders(); - for (int i = 0; i < providers.length; i++) if (providers[i] == original) position = i + 1; - Security.removeProvider(KEYSTORE); - Context target = InstrumentationRegistry.getInstrumentation().getTargetContext(); - directory = new File(target.getCacheDir(), "git-token-test-" + System.nanoTime()); - assertTrue(directory.mkdirs()); - context = new ContextWrapper(target) { - @Override public File getNoBackupFilesDir() { return directory; } - }; - } - - @Override - protected void tearDown() throws Exception { - try { - Security.removeProvider(KEYSTORE); - if (original != null) Security.insertProviderAt(original, position); - File[] files = directory == null ? null : directory.listFiles(); - if (files != null) for (File file : files) assertTrue(file.delete()); - if (directory != null) assertTrue(directory.delete()); - } finally { - super.tearDown(); - } - } - - public void testGithubAndCnbRoundTripWithoutKeystore() throws Exception { - String github = "test-only-github-token"; - String cnb = "test-only-cnb-token"; - String first = GitCloudTokenStore.encrypt(context, github); - String second = GitCloudTokenStore.encrypt(context, cnb); - assertTrue(first.startsWith(PREFIX)); - assertTrue(second.startsWith(PREFIX)); - assertEquals(github, GitCloudTokenStore.decrypt(context, first)); - assertEquals(cnb, GitCloudTokenStore.decrypt(context, second)); - assertFalse(first.equals(GitCloudTokenStore.encrypt(context, github))); - assertEquals(32, keyFile().length()); - } - - public void testLocalCiphertextRemainsReadableWhenKeystoreReturns() throws Exception { - String value = GitCloudTokenStore.encrypt(context, "test-only-token"); - registerExistingKey(); - assertEquals("test-only-token", GitCloudTokenStore.decrypt(context, value)); - } - - public void testAvailableKeystoreRemainsPreferred() throws Exception { - if (original != null) Security.insertProviderAt(original, position); - else registerExistingKey(); - String value = GitCloudTokenStore.encrypt(context, "existing-system-key-path"); - assertFalse(value.startsWith(PREFIX)); - assertEquals("existing-system-key-path", GitCloudTokenStore.decrypt(context, value)); - assertFalse(keyFile().exists()); - } - - public void testLegacyCiphertextFormatWithNistAes256GcmVector() throws Exception { - registerExistingKey(); - // NIST AES-256-GCM: zero key, 96-bit zero IV, one zero plaintext block. - byte[] body = hex("000000000000000000000000" - + "cea7403d4d606b6e074ec5d3baf39d18d0d1c8a799996bf0265b98b5d48ab919"); - String legacy = Base64.encodeToString(body, Base64.NO_WRAP); - assertEquals(new String(new char[16]), GitCloudTokenStore.decrypt(context, legacy)); - assertFalse(keyFile().exists()); - } - - public void testRegisteredButUnusableProviderDoesNotFallBack() throws Exception { - Security.addProvider(new Provider(KEYSTORE, 1, "unusable test provider") {{ - put("KeyStore.AndroidKeyStore", "missing.test.KeyStoreImplementation"); - }}); - try { - GitCloudTokenStore.encrypt(context, "test-only-token"); - fail("A registered but broken provider must not change key ownership"); - } catch (KeyStoreException expected) { - assertFalse(keyFile().exists()); - } - } - - public void testMissingLocalKeyIsNotRecreatedWhileReading() throws Exception { - String value = GitCloudTokenStore.encrypt(context, "test-only-token"); - assertTrue(keyFile().delete()); - try { - GitCloudTokenStore.decrypt(context, value); - fail("Missing key must fail without creating a replacement"); - } catch (KeyStoreException expected) { - assertFalse(keyFile().exists()); - } - } - - public void testDamagedLocalKeyIsNotOverwritten() throws Exception { - try (FileOutputStream output = new FileOutputStream(keyFile())) { output.write(7); } - try { - GitCloudTokenStore.encrypt(context, "test-only-token"); - fail("Damaged key must not be replaced"); - } catch (KeyStoreException expected) { - assertEquals(1, keyFile().length()); - } - } - - public void testTamperedCiphertextFailsAuthentication() throws Exception { - String value = GitCloudTokenStore.encrypt(context, "test-only-token"); - byte[] bytes = Base64.decode(value.substring(PREFIX.length()), Base64.DEFAULT); - bytes[bytes.length - 1] ^= 1; - try { - GitCloudTokenStore.decrypt(context, PREFIX + Base64.encodeToString(bytes, Base64.NO_WRAP)); - fail("GCM must reject a modified tag"); - } catch (javax.crypto.BadPaddingException expected) { - assertEquals(32, keyFile().length()); - } - } - - private File keyFile() { return new File(directory, "git_cloud_token.key"); } - - private void registerExistingKey() { - Security.addProvider(new Provider(KEYSTORE, 1, "existing key test provider") {{ - put("KeyStore.AndroidKeyStore", ExistingKeyStore.class.getName()); - }}); - } - - private static byte[] hex(String value) { - byte[] out = new byte[value.length() / 2]; - for (int i = 0; i < out.length; i++) out[i] = (byte) Integer.parseInt(value.substring(i * 2, i * 2 + 2), 16); - return out; - } - - public static final class ExistingKeyStore extends KeyStoreSpi { - @Override public Key engineGetKey(String alias, char[] password) { return new SecretKeySpec(new byte[32], "AES"); } - @Override public boolean engineContainsAlias(String alias) { return "webhtv_git_cloud_token".equals(alias); } - @Override public void engineLoad(InputStream stream, char[] password) { } - @Override public void engineStore(OutputStream stream, char[] password) { } - @Override public Certificate[] engineGetCertificateChain(String alias) { return null; } - @Override public Certificate engineGetCertificate(String alias) { return null; } - @Override public Date engineGetCreationDate(String alias) { return new Date(0); } - @Override public void engineSetKeyEntry(String alias, Key key, char[] password, Certificate[] chain) { throw new UnsupportedOperationException(); } - @Override public void engineSetKeyEntry(String alias, byte[] key, Certificate[] chain) { throw new UnsupportedOperationException(); } - @Override public void engineSetCertificateEntry(String alias, Certificate certificate) { throw new UnsupportedOperationException(); } - @Override public void engineDeleteEntry(String alias) { throw new UnsupportedOperationException(); } - @Override public Enumeration engineAliases() { return Collections.enumeration(Collections.singleton("webhtv_git_cloud_token")); } - @Override public int engineSize() { return 1; } - @Override public boolean engineIsKeyEntry(String alias) { return engineContainsAlias(alias); } - @Override public boolean engineIsCertificateEntry(String alias) { return false; } - @Override public String engineGetCertificateAlias(Certificate certificate) { return null; } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssAttachmentTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssAttachmentTest.java deleted file mode 100644 index c89ff89ec4..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssAttachmentTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import androidx.media3.common.C; -import androidx.media3.extractor.DefaultExtractorInput; -import androidx.media3.extractor.DiscardingTrackOutput; -import androidx.media3.extractor.ExtractorOutput; -import androidx.media3.extractor.SeekMap; -import androidx.media3.extractor.TrackOutput; -import androidx.test.platform.app.InstrumentationRegistry; -import junit.framework.TestCase; -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.concurrent.atomic.AtomicInteger; - -/** Real font bytes and production JNI/provider selection; no guessed substitute typeface. */ -public class AssAttachmentTest extends TestCase { - private byte[] font() throws Exception { - try (InputStream input = InstrumentationRegistry.getInstrumentation().getContext() - .getAssets().open("exo-ass/official/Aileron-Regular.otf")) { return input.readAllBytes(); } - } - - public void testUnorderedAttachmentDeduplicationAndClosedMedia() throws Exception { - AtomicInteger changes = new AtomicInteger(); - AssFontSet set = new AssFontSet(ignored -> changes.incrementAndGet()); - AssFontMatroskaExtractor extractor = new AssFontMatroskaExtractor( - androidx.media3.extractor.mkv.MatroskaExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA, false, set); - extractor.init(new ExtractorOutput() { - @Override public TrackOutput track(int id, int type) { return new DiscardingTrackOutput(); } - @Override public void endTracks() { } - @Override public void seekMap(SeekMap map) { } - }); - byte[] data = font(); - for (int pass = 0; pass < 2; pass++) { - extractor.startMasterElement(0x61A7, 0, data.length + 100); - ByteArrayInputStream bytes = new ByteArrayInputStream(data); - extractor.binaryElement(0x465C, data.length, - new DefaultExtractorInput(bytes::read, 0, data.length)); - extractor.stringElement(0x4660, "application/octet-stream"); - extractor.stringElement(0x466E, "renamed-container-attachment.otf"); - extractor.endMasterElement(0x61A7); - } - assertEquals(1, changes.get()); - assertEquals(1, set.snapshot().names().length); - assertTrue(Arrays.equals(data, set.snapshot().data()[0])); - assertFalse(set.canRead(AssFontSet.MAX_FONT_BYTES + 1)); - set.close(); - set.add("stale.otf", data); - assertEquals(0, set.snapshot().names().length); - assertEquals(1, changes.get()); - } - - private byte[] pixels(long handle) { - String script = "[Script Info]\nScriptType: v4.00+\nPlayResX: 640\nPlayResY: 360\n" - + "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" - + "Style: Default,Aileron,38,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,20,1\n" - + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - + "Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,Attachment faces ABC xyz\n"; - assertTrue(handle != 0); - assertTrue(AssNative.load(handle, script.getBytes(StandardCharsets.UTF_8))); - assertTrue(AssNative.testSurface(handle, 640, 360)); - assertTrue(AssNative.render(handle, 1000, 640, 360, 640, 360, 1, - C.COLOR_SPACE_BT709, C.COLOR_RANGE_LIMITED, true, new long[6]) > 0); - return AssNative.readPixels(handle); - } - - public void testAttachedFaceWinsOverSystemFallback() throws Exception { - AssNative.ensureLoaded(); - String[] names = {"unrelated-filename.otf"}; - byte[][] fonts = {font()}; - String config = AssFonts.prepare(InstrumentationRegistry.getInstrumentation().getTargetContext()); - long reference = 0, attached = 0, fallback = 0; - try { - reference = AssNative.createTestFonts(names, fonts); - byte[] expected = pixels(reference); - AssNative.destroy(reference); reference = 0; - attached = AssNative.create(config, names, fonts); - byte[] actual = pixels(attached); - AssNative.destroy(attached); attached = 0; - fallback = AssNative.create(config, new String[0], new byte[0][]); - byte[] substitute = pixels(fallback); - boolean visible = false; - for (byte channel : substitute) if (channel != 0) { visible = true; break; } - assertTrue("Unavailable script fonts still require a visible system fallback", visible); - assertTrue("Internal family must select the attached face even with a different filename", - Arrays.equals(expected, actual)); - assertFalse("System fallback must not masquerade as the script's original font", - Arrays.equals(expected, substitute)); - } finally { - if (reference != 0) AssNative.destroy(reference); - if (attached != 0) AssNative.destroy(attached); - if (fallback != 0) AssNative.destroy(fallback); - new File(config).delete(); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssNativeGoldenTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssNativeGoldenTest.java deleted file mode 100644 index a4c06b3639..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssNativeGoldenTest.java +++ /dev/null @@ -1,233 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import androidx.media3.common.C; -import androidx.test.platform.app.InstrumentationRegistry; -import junit.framework.TestCase; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; - -/** Fixed upstream PNGs + fixed fonts, with system font lookup explicitly disabled. */ -public class AssNativeGoldenTest extends TestCase { - private byte[] asset(String name) throws Exception { - try (InputStream input = InstrumentationRegistry.getInstrumentation().getContext().getAssets().open("exo-ass/official/" + name); - ByteArrayOutputStream output = new ByteArrayOutputStream()) { - byte[] buffer = new byte[8192]; int count; - while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count); - return output.toByteArray(); - } - } - - private long create() throws Exception { - AssNative.ensureLoaded(); - long handle = AssNative.createTestFonts(new String[]{"Aileron-Regular.otf", "Arimo-Bold.ttf", "Arimo-Regular.ttf"}, - new byte[][]{asset("Aileron-Regular.otf"), asset("Arimo-Bold.ttf"), asset("Arimo-Regular.ttf")}); - assertTrue("Native initialization with fixed fonts", handle != 0); - return handle; - } - - private void compare(long handle, String base, int time, int width, int height) throws Exception { - long[] stats = new long[6]; - assertTrue("A changed frame must be submitted", AssNative.render(handle, time, width, height, width, height, - 1, 2, 2, true, stats) > 0); - byte[] pixels = AssNative.readPixels(handle); - assertNotNull(pixels); - String name = base + "-" + time; - File directory = new File(InstrumentationRegistry.getInstrumentation().getTargetContext().getFilesDir(), "exo-ass-golden"); - assertTrue(directory.isDirectory() || directory.mkdirs()); - try (FileOutputStream output = new FileOutputStream(new File(directory, name + ".rgba"))) { output.write(pixels); } - byte[] png = asset(name + ".png"); - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inPremultiplied = false; - Bitmap reference = BitmapFactory.decodeByteArray(png, 0, png.length, options); - assertEquals(width, reference.getWidth()); assertEquals(height, reference.getHeight()); - int[] expected = new int[width * height]; - reference.getPixels(expected, 0, width, 0, 0, width, height); - reference.recycle(); - long error = 0, union = 0, bad = 0; - for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) { - int offset = ((height - 1 - y) * width + x) * 4; - int argb = expected[y * width + x]; - int a = argb >>> 24; - int actualA = pixels[offset + 3] & 255; - if (a == 0 && actualA == 0) continue; - union++; - int[] referencePixel = { ((argb >>> 16 & 255) * a + 127) / 255, - ((argb >>> 8 & 255) * a + 127) / 255, ((argb & 255) * a + 127) / 255, a }; - int maximum = 0; - for (int channel = 0; channel < 4; channel++) { - int difference = Math.abs(referencePixel[channel] - (pixels[offset + channel] & 255)); - error += difference; maximum = Math.max(maximum, difference); - } - if (maximum > 16) bad++; - } - assertTrue("Reference must contain a visible subtitle", union > 1000); - double mean = error / (union * 4.0); - double badRatio = bad / (double) union; - String report = name + " foregroundMae=" + mean + " badPixelRatio=" + badRatio - + " renderUs=" + stats[0] + " uploadUs=" + stats[1]; - android.util.Log.i("ExoAssGolden", report); - // Frozen before inspecting candidate pixels. Transparent background does not dilute error. - assertTrue(report, mean <= 1.5 && badRatio <= .02); - } - - public void testOfficialBlurTransformFrames() throws Exception { - long handle = create(); - try { - assertTrue(AssNative.load(handle, AssInput.normalize(asset("blur+t.ass")))); - assertTrue(AssNative.testSurface(handle, 800, 600)); - for (int time : new int[]{1000, 1500, 1900}) compare(handle, "blur+t", time, 800, 600); - } finally { AssNative.destroy(handle); } - } - - public void testHdrSdrRgbColorsAndVideoColorTransitions() throws Exception { - // A solid ASS drawing gives an independent color/alpha reference without font variance. - String script = "[Script Info]\nScriptType: v4.00+\nPlayResX: 64\nPlayResY: 64\n" - + "YCbCr Matrix: TV.601\n[V4+ Styles]\n" - + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, " - + "Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, " - + "Alignment, MarginL, MarginR, MarginV, Encoding\n" - + "Style: Default,Arimo,20,&H00C08040,&H00C08040,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n" - + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - + "Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,," - + "{\\an7\\pos(0,0)\\bord0\\shad0\\1a&H80&\\p1}m 0 0 l 64 0 64 64 0 64\n"; - long handle = create(); - long[] stats = new long[6]; - try { - assertTrue(AssNative.load(handle, AssInput.normalize(script.getBytes(StandardCharsets.UTF_8)))); - assertTrue(AssNative.testSurface(handle, 64, 64)); - assertTrue(AssNative.render(handle, 1000, 64, 64, 64, 64, 1, - AssNative.COLOR_SPACE_SDR_RGB, C.COLOR_RANGE_FULL, false, stats) > 0); - byte[] hdr = AssNative.readPixels(handle); - assertSdrRgbPixel(hdr); - assertTrue("Color-mode changes redraw even at a paused timestamp", - AssNative.render(handle, 1000, 64, 64, 64, 64, 1, - C.COLOR_SPACE_BT709, C.COLOR_RANGE_LIMITED, false, stats) > 0); - byte[] sdr = AssNative.readPixels(handle); - int pixel = (32 * 64 + 32) * 4; - assertTrue("SDR retains the historical TV.601 to TV.709 conversion", - Math.abs((hdr[pixel] & 255) - (sdr[pixel] & 255)) >= 2); - assertEquals(hdr[pixel + 3], sdr[pixel + 3]); - assertTrue(AssNative.render(handle, 1000, 64, 64, 64, 64, 1, - AssNative.COLOR_SPACE_SDR_RGB, C.COLOR_RANGE_FULL, false, stats) > 0); - assertSdrRgbPixel(AssNative.readPixels(handle)); - } finally { AssNative.destroy(handle); } - } - - private void assertSdrRgbPixel(byte[] pixels) { - assertNotNull(pixels); - int pixel = (32 * 64 + 32) * 4; - // ASS &H80C08040 is RGB(64,128,192), opacity 127/255, stored premultiplied. - int[] expected = {32, 64, 96, 127}; - for (int channel = 0; channel < 4; channel++) - assertTrue("SDR RGB channel " + channel + " actual=" + (pixels[pixel + channel] & 255), - Math.abs(expected[channel] - (pixels[pixel + channel] & 255)) <= 1); - } - - public void testOfficialKaraokeFrames() throws Exception { - long handle = create(); - try { - assertTrue(AssNative.load(handle, AssInput.normalize(asset("357-k-and-kf-desynced.ass")))); - assertTrue(AssNative.testSurface(handle, 1920, 1080)); - for (int time : new int[]{6798, 7170, 8170}) compare(handle, "357-k-and-kf-desynced", time, 1920, 1080); - } finally { AssNative.destroy(handle); } - } - - private record PacketScript(byte[] header, List packets) { } - - private static long scriptTimeMs(String value) { - String[] parts = value.trim().split("[:.]"); - return Long.parseLong(parts[0]) * 3_600_000 + Long.parseLong(parts[1]) * 60_000 - + Long.parseLong(parts[2]) * 1000 + Long.parseLong(parts[3]) * 10; - } - - private PacketScript packetize(String file) throws Exception { - String script = new String(AssInput.normalize(asset(file)), StandardCharsets.UTF_8); - StringBuilder header = new StringBuilder(); - AssPacketInput input = new AssPacketInput(); - int order = 0; - for (String line : script.split("\n")) { - if (!line.startsWith("Dialogue:")) { header.append(line).append('\n'); continue; } - String[] fields = line.substring("Dialogue:".length()).trim().split(",", 10); - long startMs = scriptTimeMs(fields[1]), duration = scriptTimeMs(fields[2]) - startMs; - String durationText = String.format(Locale.ROOT, "%d:%02d:%02d:%02d", - duration / 3_600_000, duration / 60_000 % 60, duration / 1000 % 60, duration / 10 % 100); - String sample = "Dialogue: 0:00:00:00," + durationText + "," + order++ + "," + fields[0] - + "," + String.join(",", Arrays.copyOfRange(fields, 3, 10)); - assertTrue(input.add(ByteBuffer.wrap(sample.getBytes(StandardCharsets.UTF_8)), - 1_000_000_000_000L + startMs * 1000, 1_000_000_000_000L)); - } - return new PacketScript(AssInput.normalizeHeader(header.toString().getBytes(StandardCharsets.UTF_8)), input.after(0)); - } - - private void loadPackets(long handle, PacketScript script) { - assertTrue(AssNative.loadHeader(handle, script.header())); - for (AssPacketInput.Packet packet : script.packets()) - assertTrue(AssNative.chunk(handle, packet.data(), packet.startMs(), packet.durationMs())); - } - - public void testPacketizedOfficialFramesDuplicatePrerollAndReplay() throws Exception { - long handle = create(); - try { - for (String base : new String[]{"blur+t", "357-k-and-kf-desynced"}) { - PacketScript script = packetize(base + ".ass"); - loadPackets(handle, script); - AssPacketInput.Packet first = script.packets().get(0); - assertTrue("Duplicate ReadOrder follows libass semantics", - AssNative.chunk(handle, first.data(), first.startMs(), first.durationMs())); - boolean blur = base.equals("blur+t"); - int width = blur ? 800 : 1920, height = blur ? 600 : 1080; - assertTrue(AssNative.testSurface(handle, width, height)); - int[] times = blur ? new int[]{1900, 1000, 1500} : new int[]{8170, 6798, 7170}; - for (int time : times) compare(handle, base, time, width, height); - // The same retained packets must reconstruct a fresh track after font changes. - loadPackets(handle, script); - compare(handle, base, times[1], width, height); - } - } finally { AssNative.destroy(handle); } - } - - public void testPacketNativeBoundsAndScriptModeIsolation() throws Exception { - long handle = create(); - try { - PacketScript script = packetize("blur+t.ass"); - AssPacketInput.Packet first = script.packets().get(0); - assertFalse(AssNative.chunk(handle, first.data(), first.startMs(), first.durationMs())); - assertTrue(AssNative.load(handle, AssInput.normalize(asset("blur+t.ass")))); - assertFalse("Chunk input must never mutate a full-script track", - AssNative.chunk(handle, first.data(), first.startMs(), first.durationMs())); - assertTrue(AssNative.loadHeader(handle, script.header())); - assertFalse(AssNative.chunk(handle, new byte[AssInput.MAX_INPUT_BYTES + 1], 0, 1)); - assertFalse(AssNative.chunk(handle, first.data(), Long.MAX_VALUE, 1)); - assertFalse(AssNative.chunk(handle, first.data(), 0, -1)); - assertFalse(AssNative.loadHeader(handle, new byte[AssInput.MAX_UTF8_BYTES + 1])); - assertFalse(AssNative.loadHeader(handle, AssInput.normalize(asset("blur+t.ass")))); - } finally { AssNative.destroy(handle); } - } - - public void testUnchangedContextRecreationAndEmptyFrameAreDistinct() throws Exception { - long handle = create(); - long[] stats = new long[6]; - try { - assertTrue(AssNative.load(handle, AssInput.normalize(asset("blur+t.ass")))); - assertFalse(AssNative.testSurface(handle, Integer.MAX_VALUE, 100)); - assertTrue(AssNative.testSurface(handle, 800, 600)); - assertTrue(AssNative.render(handle, 1000, 800, 600, 800, 600, 1, 2, 2, false, stats) > 0); - assertEquals(0, AssNative.render(handle, 1000, 800, 600, 800, 600, 1, 2, 2, false, stats)); - assertTrue(AssNative.setSurface(handle, null)); - assertTrue(AssNative.testSurface(handle, 800, 600)); - assertTrue(AssNative.render(handle, 1000, 800, 600, 800, 600, 1, 2, 2, false, stats) > 0); - assertEquals(2, AssNative.render(handle, 3000, 800, 600, 800, 600, 1, 2, 2, false, stats)); - byte[] pixels = AssNative.readPixels(handle); - for (byte pixel : pixels) assertEquals(0, pixel); - } finally { AssNative.destroy(handle); } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssPlaybackTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssPlaybackTest.java deleted file mode 100644 index 4a76d6ce05..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssPlaybackTest.java +++ /dev/null @@ -1,181 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.app.Instrumentation; -import android.content.Intent; -import android.os.SystemClock; -import android.os.ParcelFileDescriptor; -import android.view.View; -import androidx.test.platform.app.InstrumentationRegistry; -import junit.framework.TestCase; -import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicBoolean; -import java.io.FileInputStream; - -/** Real Exo engine, existing source/renderers factory, physical SurfaceView and async native worker. */ -public class AssPlaybackTest extends TestCase { - private Instrumentation instrumentation; - private AssPrototypeActivity activity; - - private void main(Runnable action) { instrumentation.runOnMainSync(action); } - - private void await(Callable condition, String description) throws Exception { - long deadline = SystemClock.elapsedRealtime() + 12000; - AtomicBoolean ready = new AtomicBoolean(); - while (SystemClock.elapsedRealtime() < deadline) { - main(() -> { - try { ready.set(condition.call()); } - catch (Exception error) { throw new IllegalStateException(error); } - }); - if (ready.get()) return; - SystemClock.sleep(40); - } - main(() -> android.util.Log.e("ExoAssTest", "timeout " + description + " " + activity.metrics())); - fail(description); - } - - @Override protected void setUp() throws Exception { - super.setUp(); - instrumentation = InstrumentationRegistry.getInstrumentation(); - } - - private void start(boolean enabled, boolean noSubtitle) throws Exception { - start(enabled, noSubtitle, false); - } - - private void start(boolean enabled, boolean noSubtitle, boolean embedded) throws Exception { - // Shell launch is the same path as the device harness, avoiding the - // runner's idle-queue wait while an animated player is being created. - Instrumentation.ActivityMonitor monitor = instrumentation.addMonitor( - AssPrototypeActivity.class.getName(), null, false); - try (ParcelFileDescriptor fd = instrumentation.getUiAutomation().executeShellCommand( - "am start -W -f 0x10008000 -n com.fongmi.android.tv/.player.exo.ass.AssPrototypeActivity" - + " --ez enabled " + enabled + " --ez no_subtitle " + noSubtitle - + " --ez embedded " + embedded); - FileInputStream input = new FileInputStream(fd.getFileDescriptor())) { - byte[] buffer = new byte[1024]; - while (input.read(buffer) != -1) { } - activity = (AssPrototypeActivity) monitor.waitForActivityWithTimeout(10000); - assertNotNull("Prototype Activity should launch", activity); - } finally { - instrumentation.removeMonitor(monitor); - } - } - - @Override protected void tearDown() throws Exception { - if (activity != null) main(activity::finish); - instrumentation.waitForIdleSync(); - super.tearDown(); - } - - public void testPauseDelaySurfaceFallbackSeekTracksAndRelease() throws Exception { - exerciseLifecycle(false); - } - - public void testContainerPacketsPauseSeekLateFontsTracksAndRelease() throws Exception { - exerciseLifecycle(true); - } - - private void exerciseLifecycle(boolean embedded) throws Exception { - start(true, false, embedded); - await(() -> activity.session != null && activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, - "ASS should become visible after a valid native submission"); - await(() -> activity.player.getCurrentPosition() >= 1200, "position after positive delay boundary"); - main(() -> { - assertEquals(View.INVISIBLE, activity.view.getSubtitleView().getVisibility()); - assertEquals("Current MKV's font must reach the selected ASS", 1, - activity.session.diagnostics().fontCount()); - if (embedded) { - assertEquals(0, activity.session.diagnostics().scripts()); - assertTrue(activity.session.diagnostics().packetCount() > 0); - assertTrue(activity.player.getCurrentMediaItem().localConfiguration.subtitleConfigurations.isEmpty()); - } - assertTrue(activity.player.getCurrentCues().cues.size() > 0); - activity.player.pause(); - }); - await(() -> !activity.player.isPlaying(), "pause"); - SystemClock.sleep(200); - long frozen = activity.session.diagnostics().timeMs(); - SystemClock.sleep(200); - assertEquals(frozen, activity.session.diagnostics().timeMs()); - main(() -> activity.player.setTextOffsetMs(500)); - await(() -> Math.abs(activity.session.diagnostics().timeMs() - (frozen - 500)) <= 40, "paused positive delay"); - if (embedded) { - java.lang.reflect.Field field = ExoAssSession.class.getDeclaredField("fonts"); - field.setAccessible(true); - AssFontSet fonts = (AssFontSet) field.get(activity.session); - byte[] font; - try (java.io.InputStream input = instrumentation.getContext().getAssets().open("exo-ass/official/Arimo-Regular.ttf")) { - font = input.readAllBytes(); - } - long frames = activity.session.diagnostics().frames(); - int packets = activity.session.diagnostics().packetCount(); - fonts.add("late-Arimo-Regular.ttf", font); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE - && activity.session.diagnostics().fontCount() == 2 - && activity.session.diagnostics().frames() > frames, "late font rebuild replays packets while paused"); - assertEquals(packets, activity.session.diagnostics().packetCount()); - } - long epoch = activity.session.diagnostics().surfaceEpoch(); - main(() -> activity.command("reattach", null)); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE - && activity.session.diagnostics().surfaceEpoch() > epoch, "paused Surface recreation"); - main(() -> activity.session.injectFailure("test")); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.FALLBACK - && activity.view.getSubtitleView().getVisibility() == View.VISIBLE, "recoverable fallback"); - main(() -> { - assertTrue("Current compatible Cue must survive fallback", !activity.player.getCurrentCues().cues.isEmpty()); - activity.session.setEnabled(false); - activity.session.setEnabled(true); - activity.player.play(); - }); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "runtime re-enable"); - long scripts = activity.session.diagnostics().scripts(); - int packets = activity.session.diagnostics().packetCount(); - for (int i = 0; i < 4; i++) { - long previous = activity.session.diagnostics().generation(); - final long position = i % 2 == 0 ? 6000 : 2000; - main(() -> activity.player.seekTo(position)); - await(() -> activity.session.diagnostics().generation() > previous - && activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "seek redraw"); - } - assertEquals("Seek reuses the complete script", scripts, activity.session.diagnostics().scripts()); - if (embedded) assertEquals("Duplicate preroll must not accumulate events", packets, - activity.session.diagnostics().packetCount()); - main(() -> activity.command("subtitle_off", null)); - await(() -> !activity.session.diagnostics().nativeAlive() - && activity.view.getSubtitleView().getVisibility() == View.VISIBLE, "track off releases native state"); - main(() -> activity.command("subtitle_on", null)); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "track reselect"); - main(() -> activity.command("texture", null)); - await(() -> activity.view.getSubtitleView().getVisibility() == View.VISIBLE, "TextureView remains compatible"); - main(() -> activity.command("surface", null)); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "SurfaceView resumes effect mode"); - ExoAssSession previous = activity.session; - main(() -> activity.command("rebuild", null)); - await(() -> previous.diagnostics().releaseComplete() - && !previous.diagnostics().workerAlive() - && activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "engine rebuild releases old native owner"); - ExoAssSession last = activity.session; - main(activity::finish); - await(() -> last.diagnostics().releaseComplete() && !last.diagnostics().nativeAlive() - && !last.diagnostics().workerAlive(), "final native and worker release"); - } - - public void testDisabledPrototypeDoesNotCreateNativeOrOverlay() throws Exception { - start(false, false); - await(() -> activity.player.isPlaying() && activity.player.getCurrentPosition() > 800, "compatible playback"); - main(() -> { - assertFalse(activity.session.diagnostics().nativeAlive()); - assertEquals(0, activity.session.diagnostics().frames()); - assertEquals(View.VISIBLE, activity.view.getSubtitleView().getVisibility()); - assertFalse(activity.player.getCurrentCues().cues.isEmpty()); - }); - } - - public void testNoSubtitleDoesNotCreateNativeOrWorkerFrames() throws Exception { - start(true, true); - await(() -> activity.player.isPlaying() && activity.player.getCurrentPosition() > 800, "playback without subtitles"); - assertFalse(activity.session.diagnostics().nativeAlive()); - assertEquals(0, activity.session.diagnostics().frames()); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssVideoPolicyTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssVideoPolicyTest.java deleted file mode 100644 index 511229f69c..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/AssVideoPolicyTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import androidx.media3.common.C; -import androidx.media3.common.ColorInfo; -import androidx.media3.common.DrmInitData; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; - -import junit.framework.TestCase; - -public class AssVideoPolicyTest extends TestCase { - private Format video(String mime, ColorInfo color) { - return new Format.Builder().setSampleMimeType(mime).setWidth(3840).setHeight(2160) - .setColorInfo(color).build(); - } - - private ColorInfo color(int space, int transfer, int range) { - return new ColorInfo.Builder().setColorSpace(space).setColorTransfer(transfer) - .setColorRange(range).build(); - } - - public void testHdrAndWideGamutKeepSubtitleRgb() { - for (int transfer : new int[]{C.COLOR_TRANSFER_ST2084, C.COLOR_TRANSFER_HLG, - C.COLOR_TRANSFER_SDR, Format.NO_VALUE}) { - Format format = video(MimeTypes.VIDEO_H265, - color(C.COLOR_SPACE_BT2020, transfer, C.COLOR_RANGE_LIMITED)); - assertTrue(AssVideoPolicy.supports(format)); - assertEquals(AssNative.COLOR_SPACE_SDR_RGB, AssVideoPolicy.colorSpace(format)); - assertEquals(C.COLOR_RANGE_FULL, AssVideoPolicy.colorRange(format)); - } - Format pq709 = video(MimeTypes.VIDEO_H265, - color(C.COLOR_SPACE_BT709, C.COLOR_TRANSFER_ST2084, C.COLOR_RANGE_LIMITED)); - assertTrue(AssVideoPolicy.supports(pq709)); - assertEquals(AssNative.COLOR_SPACE_SDR_RGB, AssVideoPolicy.colorSpace(pq709)); - } - - public void testDolbyVisionWithoutColorMetadataIsStillSdrSubtitleRgb() { - Format format = video(MimeTypes.VIDEO_DOLBY_VISION, null); - assertTrue(AssVideoPolicy.supports(format)); - assertEquals(AssNative.COLOR_SPACE_SDR_RGB, AssVideoPolicy.colorSpace(format)); - assertEquals(C.COLOR_RANGE_FULL, AssVideoPolicy.colorRange(format)); - } - - public void testSdrVideoRetainsDeclaredAndFallbackMatrices() { - Format sdr = video(MimeTypes.VIDEO_H264, - color(C.COLOR_SPACE_BT709, C.COLOR_TRANSFER_SDR, C.COLOR_RANGE_FULL)); - assertTrue(AssVideoPolicy.supports(sdr)); - assertEquals(C.COLOR_SPACE_BT709, AssVideoPolicy.colorSpace(sdr)); - assertEquals(C.COLOR_RANGE_FULL, AssVideoPolicy.colorRange(sdr)); - Format unspecified = video(MimeTypes.VIDEO_H264, null); - assertEquals(C.COLOR_SPACE_BT709, AssVideoPolicy.colorSpace(unspecified)); - assertEquals(C.COLOR_RANGE_LIMITED, AssVideoPolicy.colorRange(unspecified)); - assertEquals(C.COLOR_SPACE_BT601, - AssVideoPolicy.colorSpace(unspecified.buildUpon().setHeight(576).build())); - Format declared601 = video(MimeTypes.VIDEO_H264, - color(C.COLOR_SPACE_BT601, C.COLOR_TRANSFER_SDR, C.COLOR_RANGE_LIMITED)); - assertEquals(C.COLOR_SPACE_BT601, AssVideoPolicy.colorSpace(declared601)); - } - - public void testSecureRotatedAndUnknownTransferStillUseCompatiblePath() { - Format hdr = video(MimeTypes.VIDEO_H265, - color(C.COLOR_SPACE_BT2020, C.COLOR_TRANSFER_ST2084, C.COLOR_RANGE_LIMITED)); - assertFalse(AssVideoPolicy.supports(hdr.buildUpon().setWidth(0).build())); - assertFalse(AssVideoPolicy.supports(hdr.buildUpon().setRotationDegrees(90).build())); - assertFalse(AssVideoPolicy.supports(hdr.buildUpon().setCryptoType(C.CRYPTO_TYPE_FRAMEWORK).build())); - assertFalse(AssVideoPolicy.supports(hdr.buildUpon() - .setDrmInitData(new DrmInitData(new DrmInitData.SchemeData[0])).build())); - assertFalse(AssVideoPolicy.supports(hdr.buildUpon() - .setColorInfo(color(C.COLOR_SPACE_BT2020, C.COLOR_TRANSFER_LINEAR, C.COLOR_RANGE_FULL)).build())); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/TextRendererObserverTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/TextRendererObserverTest.java deleted file mode 100644 index c75a452531..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/ass/TextRendererObserverTest.java +++ /dev/null @@ -1,193 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.os.SystemClock; -import junit.framework.TestCase; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.text.CueGroup; -import androidx.media3.common.util.Clock; -import androidx.media3.decoder.DecoderInputBuffer; -import androidx.media3.exoplayer.FormatHolder; -import androidx.media3.exoplayer.RendererConfiguration; -import androidx.media3.exoplayer.analytics.PlayerId; -import androidx.media3.exoplayer.source.MediaSource; -import androidx.media3.exoplayer.source.SampleStream; -import androidx.media3.exoplayer.text.TextOutput; -import androidx.media3.exoplayer.text.TextRenderer; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; - -/** Exercises the real shipped TextRenderer, decoder, Cue output and final-stream handling. */ -@SuppressWarnings("deprecation") -public class TextRendererObserverTest extends TestCase { - private static final long OFFSET = 1_000_000_000L; - private static final byte[] SCRIPT = ("[Script Info]\nPlayResX: 640\nPlayResY: 360\n" - + "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" - + "Style: Default,Arial,30,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1\n" - + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,kept,comma\n").getBytes(StandardCharsets.UTF_8); - private static final Format FORMAT = new Format.Builder().setSampleMimeType(MimeTypes.TEXT_SSA) - .setId(AssInput.EXTERNAL_ID_PREFIX + "test").build(); - - private static final class Output implements TextOutput { - CueGroup cues = CueGroup.EMPTY_TIME_ZERO; - @Override public void onCues(CueGroup cues) { this.cues = cues; } - } - - private static class Observer implements TextRenderer.Observer { - long generation, resets, sampleTime, position, textOffset; - int samples, disabled; - boolean ended, throwOnSample; - byte[] bytes; - TextRenderer.Stream reading, displaying; - @Override public void onStream(TextRenderer.Stream stream, long generation) { reading = stream; this.generation = generation; } - @Override public void onReset(TextRenderer.Stream stream, long generation, long position) { resets++; this.generation = generation; } - @Override public void onSample(TextRenderer.Stream stream, long generation, Format format, ByteBuffer data, long timeUs) { - samples++; bytes = AssInput.copy(data); sampleTime = timeUs; - if (throwOnSample) throw new IllegalStateException("fixture"); - } - @Override public void onClock(TextRenderer.Stream reading, TextRenderer.Stream displaying, long generation, - long position, long offset, boolean started, boolean ended) { - this.reading = reading; this.displaying = displaying; this.position = position; - textOffset = offset; this.ended = ended; - } - @Override public void onCues(TextRenderer.Stream stream, long generation, CueGroup group) { } - @Override public void onDisabled(long generation) { disabled++; } - } - - private static final class Input implements SampleStream { - int phase, actualReads, peeks; - @Override public boolean isReady() { return true; } - @Override public void maybeThrowError() { } - @Override public int skipData(long positionUs) { return 0; } - @Override public int readData(FormatHolder holder, DecoderInputBuffer buffer, int flags) { - if ((flags & FLAG_PEEK) != 0) peeks++; - if (phase == 0 || (flags & FLAG_REQUIRE_FORMAT) != 0) { - holder.format = FORMAT; phase = 1; return C.RESULT_FORMAT_READ; - } - buffer.clear(); - if (phase >= 2) { - buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); return C.RESULT_BUFFER_READ; - } - buffer.timeUs = 0; - buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME); - if ((flags & FLAG_OMIT_SAMPLE_DATA) == 0) { - buffer.ensureSpaceForWrite(SCRIPT.length); buffer.data.put(SCRIPT); - } - if ((flags & FLAG_PEEK) == 0) { phase = 2; actualReads++; } - return C.RESULT_BUFFER_READ; - } - } - - private TextRenderer renderer(Output output, Input input, Observer observer) throws Exception { - TextRenderer renderer = new TextRenderer(output, null); - if (observer != null) renderer.setObserver(observer); - renderer.init(0, PlayerId.UNSET, Clock.DEFAULT); - renderer.enable(RendererConfiguration.DEFAULT, new Format[]{FORMAT}, input, - OFFSET, false, true, OFFSET, OFFSET, new MediaSource.MediaPeriodId("period-a")); - renderer.start(); - return renderer; - } - - private void awaitCue(TextRenderer renderer, Output output, long position) throws Exception { - long deadline = SystemClock.elapsedRealtime() + 2000; - do { - renderer.render(position, SystemClock.elapsedRealtime() * 1000); - if (!output.cues.cues.isEmpty()) return; - SystemClock.sleep(5); - } while (SystemClock.elapsedRealtime() < deadline); - fail("Legacy Cue output did not arrive"); - } - - private void release(TextRenderer renderer) { - renderer.stop(); renderer.disable(); renderer.reset(); renderer.release(); - } - - public void testDefaultObserverOffPreservesCueAndSingleConsumption() throws Exception { - Output output = new Output(); Input input = new Input(); - TextRenderer renderer = renderer(output, input, null); - try { - awaitCue(renderer, output, OFFSET + 1_500_000); - assertEquals("kept,comma", output.cues.cues.get(0).text.toString()); - assertEquals(1, input.actualReads); - assertTrue(input.peeks > 0); - assertEquals(TextRenderer.class, renderer.getClass()); - } finally { release(renderer); } - } - - public void testPausedObserverDelayRedeliversCompatibleCue() throws Exception { - Output output = new Output(); Input input = new Input(); - TextRenderer renderer = renderer(output, input, new Observer()); - try { - awaitCue(renderer, output, OFFSET + 2_000_000); - renderer.stop(); - renderer.setTextOffsetMs(500); - assertEquals("kept,comma", output.cues.cues.get(0).text.toString()); - renderer.setTextOffsetMs(2500); - assertTrue("Moving before the cue's start must clear it", output.cues.cues.isEmpty()); - renderer.setTextOffsetMs(0); - assertEquals("Moving time backward must also reset the event index", "kept,comma", - output.cues.cues.get(0).text.toString()); - } finally { - if (renderer.getState() != androidx.media3.exoplayer.Renderer.STATE_STARTED) renderer.start(); - release(renderer); - } - } - - public void testObserverUsesSelectedBytesOffsetDelaySeekAndFinalEnd() throws Exception { - Output output = new Output(); Input input = new Input(); Observer observer = new Observer(); - TextRenderer renderer = renderer(output, input, observer); - try { - awaitCue(renderer, output, OFFSET + 1_500_000); - assertEquals(1, observer.samples); - assertTrue(Arrays.equals(SCRIPT, observer.bytes)); - assertEquals(OFFSET, observer.sampleTime); - assertEquals(OFFSET, observer.reading.offsetUs); - assertEquals("period-a", observer.reading.mediaPeriodId.periodUid); - renderer.setTextOffsetMs(500); - renderer.render(OFFSET + 2_000_000, 0); - assertEquals(1500, AssInput.timeMs(observer.position, observer.reading.offsetUs, observer.textOffset)); - assertEquals("kept,comma", output.cues.cues.get(0).text.toString()); - long generation = observer.generation; - input.phase = 1; - renderer.resetPosition(OFFSET + 1_500_000, false); - awaitCue(renderer, output, OFFSET + 1_500_000); - assertTrue(observer.generation > generation); - assertEquals(2, observer.samples); - renderer.setCurrentStreamFinal(); - renderer.setFinalStreamEndPositionUs(OFFSET + 4_000_000); - renderer.render(OFFSET + 4_000_000, 0); - assertTrue(renderer.isEnded()); - assertTrue(observer.ended); - } finally { release(renderer); } - assertEquals(1, observer.disabled); - } - - public void testThrowingObserverCannotBreakCompatibleDecoder() throws Exception { - Output output = new Output(); Input input = new Input(); Observer observer = new Observer(); - observer.throwOnSample = true; - TextRenderer renderer = renderer(output, input, observer); - try { - awaitCue(renderer, output, OFFSET + 1_500_000); - assertEquals("kept,comma", output.cues.cues.get(0).text.toString()); - assertEquals(1, observer.disabled); - } finally { release(renderer); } - } - - public void testReadAheadPeriodDoesNotRelabelDisplayedSubtitle() throws Exception { - Output output = new Output(); Input input = new Input(); Observer observer = new Observer(); - TextRenderer renderer = renderer(output, input, observer); - try { - awaitCue(renderer, output, OFFSET + 1_500_000); - long displaying = observer.displaying.sequence; - renderer.replaceStream(new Format[]{FORMAT}, new Input(), OFFSET + 5_000_000, - OFFSET + 5_000_000, new MediaSource.MediaPeriodId("period-b")); - renderer.render(OFFSET + 1_600_000, 0); - assertEquals("period-b", observer.reading.mediaPeriodId.periodUid); - assertEquals(displaying, observer.displaying.sequence); - assertEquals("kept,comma", output.cues.cues.get(0).text.toString()); - } finally { release(renderer); } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlaybackTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlaybackTest.java deleted file mode 100644 index 9184b6ed2c..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlaybackTest.java +++ /dev/null @@ -1,268 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.app.Instrumentation; -import android.graphics.Bitmap; -import android.os.ParcelFileDescriptor; -import android.os.SystemClock; -import android.text.Spanned; -import android.view.View; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MediaMetadata; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.Tracks; -import androidx.media3.common.text.Cue; -import androidx.media3.exoplayer.DecoderCounters; -import androidx.media3.exoplayer.Renderer; -import androidx.media3.ui.SubtitleView; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.bean.Sub; -import com.fongmi.android.tv.bean.Track; -import com.fongmi.android.tv.player.PlayerHelper; -import com.fongmi.android.tv.player.engine.PlaySpec; -import com.fongmi.android.tv.player.exo.ass.AssPrototypeActivity; -import com.fongmi.android.tv.player.exo.ass.ExoAssSession; - -import junit.framework.TestCase; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicBoolean; - -/** Existing real Exo engine/source factory, two selected streams and the physical ASS Surface. */ -public class DualSubtitlePlaybackTest extends TestCase { - private Instrumentation instrumentation; - private AssPrototypeActivity activity; - private ExoSubtitleSession subtitles; - - private void main(Runnable action) { - instrumentation.runOnMainSync(action); - } - - private void await(Callable condition, String message) throws Exception { - long deadline = SystemClock.elapsedRealtime() + 15000; - AtomicBoolean ready = new AtomicBoolean(); - while (SystemClock.elapsedRealtime() < deadline) { - main(() -> { - try { - assertNull("Playback error: " + activity.metrics(), activity.player.getPlayerError()); - ready.set(condition.call()); - } catch (Exception e) { throw new IllegalStateException(e); } - }); - if (ready.get()) return; - SystemClock.sleep(30); - } - main(() -> android.util.Log.e("ExoDualSubtitleTest", message + " " + activity.metrics())); - fail(message); - } - - @Override - protected void setUp() throws Exception { - super.setUp(); - instrumentation = InstrumentationRegistry.getInstrumentation(); - Instrumentation.ActivityMonitor monitor = instrumentation.addMonitor(AssPrototypeActivity.class.getName(), null, false); - try (ParcelFileDescriptor fd = instrumentation.getUiAutomation().executeShellCommand( - "am start -W -f 0x10008000 -n com.fongmi.android.tv/.player.exo.ass.AssPrototypeActivity --ez no_subtitle true"); - FileInputStream input = new FileInputStream(fd.getFileDescriptor())) { - byte[] buffer = new byte[1024]; - while (input.read(buffer) != -1) { } - activity = (AssPrototypeActivity) monitor.waitForActivityWithTimeout(10000); - assertNotNull(activity); - } finally { - instrumentation.removeMonitor(monitor); - } - } - - @Override - protected void tearDown() throws Exception { - if (activity != null) main(activity::finish); - instrumentation.waitForIdleSync(); - super.tearDown(); - } - - public void testExternalAssAndSecondaryTextLifecycle() throws Exception { - exercise(false); - } - - public void testEmbeddedAssAndExternalSecondaryLifecycle() throws Exception { - exercise(true); - } - - private void exercise(boolean embedded) throws Exception { - File directory = new File(instrumentation.getTargetContext().getCacheDir(), "dual-subtitle-fixture"); - assertTrue(directory.isDirectory() || directory.mkdirs()); - File srt = new File(directory, "secondary.srt"); - Files.write(srt.toPath(), ("1\n00:00:00,000 --> 00:00:04,000\nSECONDARY SRT ONE\n\n" - + "2\n00:00:04,000 --> 00:01:00,000\nSECONDARY SRT TWO\n").getBytes(StandardCharsets.UTF_8)); - File ass = new File(directory, "secondary.ass"); - Files.write(ass.toPath(), ("[Script Info]\nScriptType: v4.00+\nPlayResX: 960\nPlayResY: 540\n" - + "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" - + "Style: Default,Aileron,70,&H000000FF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,2,10,10,10,1\n" - + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - + "Dialogue: 0,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\\pos(480,510)\\frz20}SECONDARY ASS PLAIN\n").getBytes(StandardCharsets.UTF_8)); - PlaySpec spec = PlaySpec.from("E4-LIBASS-dual", "asset:///exo-ass/" - + (embedded ? "sample-embedded.mkv" : "sample-fonts.mkv"), Collections.emptyMap(), MediaMetadata.EMPTY); - spec.setSub(Sub.create("Secondary ASS", ass.toURI().toString(), "fr", MimeTypes.TEXT_SSA)); - spec.setSub(Sub.create("Secondary SRT", srt.toURI().toString(), "en", MimeTypes.APPLICATION_SUBRIP)); - if (!embedded) spec.setSub(Sub.create("Primary ASS", "asset:///exo-ass/animated.ass", "zh", MimeTypes.TEXT_SSA)); - main(() -> { - subtitles = activity.engine.getSubtitleSession(); - subtitles.attach(activity.view); - assertTrue(activity.engine.supportsSecondarySubtitle()); - assertNull("Disabled secondary must not allocate a view", secondaryView()); - activity.engine.start(spec, 1000, true); - }); - await(() -> find("Secondary SRT") != null && findPrimary() != null, "subtitle tracks extracted"); - main(() -> activity.engine.setTrack(List.of(track(findPrimary())))); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE - && activity.player.getCurrentPosition() >= 1200, "primary ASS rendered"); - DecoderCounters[] videoCounters = new DecoderCounters[1]; - main(() -> { - videoCounters[0] = activity.player.getVideoDecoderCounters(); - assertEquals(Renderer.STATE_DISABLED, secondaryRenderer().getState()); - activity.player.seekTo(1500); - activity.engine.setSecondarySubtitleTrack(track(find("Secondary SRT"))); - }); - await(() -> secondaryText().equals("SECONDARY SRT ONE"), "secondary SRT rendered beside ASS"); - main(() -> { - activity.player.pause(); - assertTrue(activity.engine.isPrimarySubtitleSelected(findPrimary())); - assertTrue(activity.engine.isSecondarySubtitleSelected(find("Secondary SRT"))); - assertFalse(activity.engine.isPrimarySubtitleSelected(find("Secondary SRT"))); - assertEquals(View.INVISIBLE, activity.view.getSubtitleView().getVisibility()); - assertEquals(View.VISIBLE, secondaryView().getVisibility()); - assertSame(activity.view.getSubtitleView().getParent(), secondaryView().getParent()); - assertEquals(1, activity.session.diagnostics().fontCount()); - assertFalse(secondaryCues().get(0).text instanceof Spanned); - }); - Bitmap screenshot = instrumentation.getUiAutomation().takeScreenshot(); - assertNotNull(screenshot); - try (FileOutputStream output = new FileOutputStream(new File(activity.getExternalFilesDir(null), - "exo-dual-" + (embedded ? "embedded" : "external") + ".png"))) { - screenshot.compress(Bitmap.CompressFormat.PNG, 100, output); - } finally { screenshot.recycle(); } - - main(() -> activity.player.setTextOffsetMs(5000)); - await(() -> secondaryCues().isEmpty(), "paused positive delay clears early secondary cue"); - main(() -> activity.player.setTextOffsetMs(0)); - await(() -> secondaryText().equals("SECONDARY SRT ONE"), "paused delay restoration redraws secondary"); - main(() -> { activity.player.seekTo(5500); activity.player.play(); }); - await(() -> secondaryText().equals("SECONDARY SRT TWO"), "forward seek subtitle time"); - main(() -> { activity.player.seekTo(1500); activity.player.play(); }); - await(() -> secondaryText().equals("SECONDARY SRT ONE"), "backward seek subtitle time"); - - main(() -> { - activity.player.pause(); - activity.engine.setTrack(List.of(Track.disabled(C.TRACK_TYPE_TEXT, "off"))); - }); - await(() -> !activity.session.diagnostics().nativeAlive(), "primary off releases only its ASS owner"); - main(() -> { - assertEquals("SECONDARY SRT ONE", secondaryText()); - assertTrue(activity.engine.isSecondarySubtitleSelected(find("Secondary SRT"))); - activity.engine.setTrack(List.of(track(findPrimary()))); - activity.player.play(); - }); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "primary ASS restored"); - main(() -> activity.engine.setSecondarySubtitleTrack(Track.disabled(C.TRACK_TYPE_TEXT, "off"))); - await(() -> secondaryRenderer().getState() == Renderer.STATE_DISABLED && secondaryCues().isEmpty(), "secondary independently disabled"); - main(() -> { - assertEquals(ExoAssSession.State.ACTIVE, activity.session.diagnostics().state()); - assertSame("Subtitle switching must not reinitialize video", videoCounters[0], activity.player.getVideoDecoderCounters()); - activity.engine.setSecondarySubtitleTrack(track(find("Secondary ASS"))); - }); - await(() -> secondaryText().equals("SECONDARY ASS PLAIN"), "secondary ASS follows MPV strip default"); - main(() -> { - activity.player.pause(); - assertFalse(secondaryCues().get(0).text instanceof Spanned); - assertEquals(Cue.ANCHOR_TYPE_START, secondaryCues().get(0).lineAnchor); - assertTrue(secondaryCues().get(0).line < 0.1f); - SubtitleView previous = secondaryView(); - subtitles.detach(); - assertNull(previous.getParent()); - subtitles.attach(activity.view); - assertNotSame(previous, secondaryView()); - assertEquals("SECONDARY ASS PLAIN", secondaryText()); - activity.command("reattach", null); - }); - await(() -> activity.session.diagnostics().state() == ExoAssSession.State.ACTIVE, "ASS and secondary survive host reattach"); - main(() -> { - android.util.Log.i("ExoDualSubtitleTest", "passed embedded=" + embedded + " " + activity.metrics()); - activity.engine.start(PlaySpec.from("different-source", "asset:///exo-ass/sample-fonts.mkv", - Collections.emptyMap(), MediaMetadata.EMPTY)); - assertTrue(secondaryCues().isEmpty()); - }); - await(() -> activity.player.getCurrentTracks().getGroups().stream().noneMatch(group -> group.getType() == C.TRACK_TYPE_TEXT), - "source change drops old subtitle selections"); - main(() -> { - ExoSubtitleSession old = subtitles; - SubtitleView oldView = secondaryView(); - activity.command("rebuild", null); - subtitles = activity.engine.getSubtitleSession(); - assertNotSame(old, subtitles); - assertNull(oldView.getParent()); - subtitles.attach(activity.view); - assertNull("New player starts without a secondary overlay", secondaryView()); - }); - } - - private Format find(String label) { - for (Tracks.Group group : activity.player.getCurrentTracks().getGroups()) { - if (group.getType() != C.TRACK_TYPE_TEXT) continue; - for (int i = 0; i < group.length; i++) if (label.equals(group.getTrackFormat(i).label)) return group.getTrackFormat(i); - } - return null; - } - - private Format findPrimary() { - for (Tracks.Group group : activity.player.getCurrentTracks().getGroups()) { - if (group.getType() != C.TRACK_TYPE_TEXT) continue; - for (int i = 0; i < group.length; i++) { - Format format = group.getTrackFormat(i); - if (MimeTypes.TEXT_SSA.equals(format.sampleMimeType) - && (format.label == null || !format.label.startsWith("Secondary"))) return format; - } - } - return null; - } - - private static Track track(Format format) { - if (format == null) throw new AssertionError("Missing fixture track"); - Track track = new Track(C.TRACK_TYPE_TEXT, format.label, PlayerHelper.describeFormat(format)).playerId(format.id); - track.setSelected(true); - return track; - } - - private Renderer secondaryRenderer() { - return activity.player.getRenderer(activity.player.getRendererCount() - 1); - } - - @SuppressWarnings("unchecked") - private List secondaryCues() { - return (List) field("cues"); - } - - private String secondaryText() { - List cues = secondaryCues(); - return cues.isEmpty() || cues.get(0).text == null ? "" : cues.get(0).text.toString(); - } - - private SubtitleView secondaryView() { - return (SubtitleView) field("view"); - } - - private Object field(String name) { - try { - java.lang.reflect.Field field = ExoSubtitleSession.class.getDeclaredField(name); - field.setAccessible(true); - return field.get(subtitles); - } catch (ReflectiveOperationException e) { throw new AssertionError(e); } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlayerTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlayerTest.java deleted file mode 100644 index cce4b9cd47..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitlePlayerTest.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.content.Context; -import android.view.View; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MediaItem; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.Tracks; -import androidx.media3.common.text.Cue; -import androidx.media3.common.text.CueGroup; -import androidx.media3.datasource.ByteArrayDataSource; -import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.exoplayer.Renderer; -import androidx.media3.exoplayer.source.MediaSource; -import androidx.media3.exoplayer.source.MergingMediaSource; -import androidx.media3.exoplayer.source.SingleSampleMediaSource; -import androidx.media3.exoplayer.text.TextRenderer; -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; -import androidx.media3.ui.PlayerView; -import androidx.media3.ui.SubtitleView; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.bean.Track; - -import junit.framework.TestCase; - -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicBoolean; - -/** Real ExoPlayer/MediaPeriod integration, without relying on a device audio/video codec. */ -@SuppressWarnings("deprecation") -public class DualSubtitlePlayerTest extends TestCase { - private ExoPlayer player; - private ExoSubtitleSession session; - private PlayerView view; - private CueGroup primaryCues = CueGroup.EMPTY_TIME_ZERO; - - private static void main(Runnable action) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(action); - } - - protected void pumpMainLoop() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private void await(Callable condition, String message) throws Exception { - long deadline = System.nanoTime() + 5_000_000_000L; - AtomicBoolean ready = new AtomicBoolean(); - do { - pumpMainLoop(); - main(() -> { - assertNull("Player error", player.getPlayerError()); - try { ready.set(condition.call()); } - catch (Exception e) { throw new AssertionError(e); } - }); - if (ready.get()) return; - Thread.sleep(5); - } while (System.nanoTime() < deadline); - main(() -> fail(message + "; state=" + player.getPlaybackState() + " position=" + player.getCurrentPosition() - + " tracks=" + player.getCurrentTracks().getGroups().size() + " primary=" + text(primaryCues.cues) - + " secondary=" + text(secondaryCues()))); - } - - private MediaSource source(String id, String language, String text) { - byte[] bytes = ("1\n00:00:00,000 --> 00:00:04,000\n" + text + " ONE\n\n" - + "2\n00:00:04,000 --> 00:00:10,000\n" + text + " TWO\n").getBytes(StandardCharsets.UTF_8); - return new SingleSampleMediaSource.Factory(() -> new ByteArrayDataSource(bytes)) - .setTreatLoadErrorsAsEndOfStream(false).createMediaSource( - new MediaItem.SubtitleConfiguration.Builder(android.net.Uri.parse("memory:///" + id + ".srt")) - .setId(id).setLabel(id).setLanguage(language).setMimeType(MimeTypes.APPLICATION_SUBRIP) - .build(), 10_000_000); - } - - @Override protected void tearDown() throws Exception { - main(() -> { - if (view != null) view.setPlayer(null); - if (session != null) session.release(); - if (player != null) player.release(); - }); - super.tearDown(); - } - - public void testTwoRealMediaPeriodsSelectionsAndSiblingOverlayLifecycle() throws Exception { - main(() -> { - Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - session = new ExoSubtitleSession(); - DefaultTrackSelector original = new DefaultTrackSelector(context); - original.setParameters(original.buildUponParameters().setPreferredTextLanguage("en")); - player = new ExoPlayer.Builder(context) - .setTrackSelector(session.wrapTrackSelector(original)) - .setRenderersFactory(session.wrapRenderersFactory((handler, video, audio, output, metadata) -> - new Renderer[]{new TextRenderer(group -> { - primaryCues = group; - output.onCues(group); - }, handler.getLooper())})).build(); - view = new PlayerView(context); - view.setPlayer(player); - session.attach(view); - assertNull(secondaryView()); - player.setMediaSource(new MergingMediaSource(source("main", "en", "MAIN"), - source("second", "zh", "SECOND")), 1500); - player.prepare(); - player.play(); - }); - await(() -> text(primaryCues.cues).equals("MAIN ONE") && find("second") != null, "primary playback started"); - main(() -> { - assertEquals(Renderer.STATE_DISABLED, player.getRenderer(1).getState()); - assertTrue(session.isPrimarySelected(find("main"))); - Track track = new Track(C.TRACK_TYPE_TEXT, "second", null).playerId(find("second").id); - assertTrue(session.selectSecondary(player, track)); - }); - await(() -> text(secondaryCues()).equals("SECOND ONE"), "secondary selected through real merged media periods"); - main(() -> { - player.pause(); - assertTrue(session.isPrimarySelected(find("main"))); - assertTrue(session.isSecondarySelected(find("second"))); - assertEquals("MAIN ONE", text(primaryCues.cues)); - // This is the exact visibility operation performed when the existing ASS surface takes over. - view.getSubtitleView().setVisibility(View.INVISIBLE); - assertEquals(View.VISIBLE, secondaryView().getVisibility()); - assertSame(view.getSubtitleView().getParent(), secondaryView().getParent()); - assertFalse(secondaryView().isFocusable()); - SubtitleView old = secondaryView(); - session.detach(); - assertNull(old.getParent()); - session.attach(view); - assertNotSame(old, secondaryView()); - assertEquals("SECOND ONE", text(secondaryCues())); - player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build()); - }); - await(() -> player.getRenderer(0).getState() == Renderer.STATE_DISABLED, "primary independently disabled"); - main(() -> { - assertEquals("SECOND ONE", text(secondaryCues())); - assertTrue(session.isSecondarySelected(find("second"))); - player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false).build()); - player.seekTo(5500); - player.play(); - }); - await(() -> text(primaryCues.cues).equals("MAIN TWO") && text(secondaryCues()).equals("SECOND TWO"), - "both real renderers follow the same forward seek"); - main(() -> { player.seekTo(1500); player.play(); }); - await(() -> text(primaryCues.cues).equals("MAIN ONE") && text(secondaryCues()).equals("SECOND ONE"), - "both real renderers follow the same backward seek"); - main(() -> assertTrue(session.selectSecondary(player, Track.disabled(C.TRACK_TYPE_TEXT, "off")))); - await(() -> player.getRenderer(1).getState() == Renderer.STATE_DISABLED && secondaryCues().isEmpty(), - "secondary independently disabled"); - main(() -> { - assertEquals("MAIN ONE", text(primaryCues.cues)); - session.reset(); - player.stop(); - assertTrue(secondaryCues().isEmpty()); - }); - } - - private Format find(String id) { - for (Tracks.Group group : player.getCurrentTracks().getGroups()) { - // MergingMediaPeriod prefixes runtime ids with the child source index. - for (int i = 0; i < group.length; i++) if (id.equals(group.getTrackFormat(i).label)) return group.getTrackFormat(i); - } - return null; - } - - private Object field(String name) { - try { - java.lang.reflect.Field field = ExoSubtitleSession.class.getDeclaredField(name); - field.setAccessible(true); - return field.get(session); - } catch (ReflectiveOperationException e) { throw new AssertionError(e); } - } - - @SuppressWarnings("unchecked") private List secondaryCues() { return (List) field("cues"); } - private SubtitleView secondaryView() { return (SubtitleView) field("view"); } - private static String text(List cues) { return cues.isEmpty() ? "" : cues.get(0).text.toString(); } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleRendererTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleRendererTest.java deleted file mode 100644 index d12561f341..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleRendererTest.java +++ /dev/null @@ -1,239 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.os.Handler; -import android.os.Looper; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.TrackGroup; -import androidx.media3.common.TrackSelectionOverride; -import androidx.media3.common.text.Cue; -import androidx.media3.common.text.CueGroup; -import androidx.media3.common.util.Clock; -import androidx.media3.decoder.DecoderInputBuffer; -import androidx.media3.exoplayer.FormatHolder; -import androidx.media3.exoplayer.Renderer; -import androidx.media3.exoplayer.RendererConfiguration; -import androidx.media3.exoplayer.analytics.PlayerId; -import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; -import androidx.media3.exoplayer.source.SampleStream; -import androidx.media3.exoplayer.text.TextOutput; -import androidx.media3.exoplayer.text.TextRenderer; -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; -import androidx.test.platform.app.InstrumentationRegistry; - -import junit.framework.TestCase; - -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; - -/** Real shipped renderers/decoders with independent streams, one renderer clock and queued UI output. */ -@SuppressWarnings("deprecation") -public class DualSubtitleRendererTest extends TestCase { - private static final long OFFSET = 1_000_000_000L; - private static final Format PRIMARY = new Format.Builder().setId("primary") - .setSampleMimeType(MimeTypes.TEXT_SSA).build(); - private static final Format SECONDARY = new Format.Builder().setId("secondary") - .setSampleMimeType(MimeTypes.APPLICATION_SUBRIP).build(); - private static final TrackGroup SECONDARY_GROUP = new TrackGroup("secondary", SECONDARY); - private static final String ASS = "[Script Info]\nPlayResX: 640\nPlayResY: 360\n" - + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,PRIMARY ONE\n" - + "Dialogue: 0,0:00:05.00,0:00:10.00,Default,,0,0,0,,PRIMARY TWO\n"; - private static final String SRT = "1\n00:00:01,000 --> 00:00:03,000\nSECONDARY ONE\n\n" - + "2\n00:00:05,000 --> 00:00:10,000\nSECONDARY TWO\n"; - - private static final class Input implements SampleStream { - final Format format; - final byte[] data; - int phase, reads; - - Input(Format format, String text) { - this.format = format; - data = text.getBytes(StandardCharsets.UTF_8); - } - - @Override public boolean isReady() { return true; } - @Override public void maybeThrowError() { } - @Override public int skipData(long positionUs) { return 0; } - @Override - public int readData(FormatHolder holder, DecoderInputBuffer buffer, int flags) { - if (phase == 0 || (flags & FLAG_REQUIRE_FORMAT) != 0) { - holder.format = format; - phase = 1; - return C.RESULT_FORMAT_READ; - } - buffer.clear(); - if (phase > 1) { - buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); - return C.RESULT_BUFFER_READ; - } - buffer.timeUs = 0; - buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME); - if ((flags & FLAG_OMIT_SAMPLE_DATA) == 0) { - buffer.ensureSpaceForWrite(data.length); - buffer.data.put(data); - } - if ((flags & FLAG_PEEK) == 0) { phase = 2; reads++; } - return C.RESULT_BUFFER_READ; - } - } - - private static final class Output implements TextOutput, TextRenderer.Observer { - CueGroup cues = CueGroup.EMPTY_TIME_ZERO; - int samples; - @Override public void onCues(CueGroup cues) { this.cues = cues; } - @Override public void onStream(TextRenderer.Stream stream, long generation) { } - @Override public void onReset(TextRenderer.Stream stream, long generation, long position) { } - @Override public void onSample(TextRenderer.Stream stream, long generation, Format format, ByteBuffer data, long timeUs) { - assertEquals(PRIMARY, format); - samples++; - } - @Override public void onClock(TextRenderer.Stream reading, TextRenderer.Stream displaying, long generation, - long position, long offset, boolean started, boolean ended) { } - @Override public void onCues(TextRenderer.Stream stream, long generation, CueGroup cues) { } - @Override public void onDisabled(long generation) { } - } - - private final class Rig implements AutoCloseable { - final ExoSubtitleSession session = new ExoSubtitleSession(); - final DualSubtitleTrackSelector selector = (DualSubtitleTrackSelector) session.wrapTrackSelector( - new DefaultTrackSelector(InstrumentationRegistry.getInstrumentation().getTargetContext())); - final Output output = new Output(); - final TextRenderer primary = new TextRenderer(output, null); - final TextRenderer secondary; - final Input primaryInput = new Input(PRIMARY, ASS); - final Input secondaryInput = new Input(SECONDARY, SRT); - - Rig() { - primary.setObserver(output); - Renderer[] renderers = session.wrapRenderersFactory((handler, video, audio, text, metadata) -> - new Renderer[]{primary}).createRenderers(new Handler(Looper.getMainLooper()), null, null, output, null); - assertSame(primary, renderers[0]); - secondary = (TextRenderer) renderers[1]; - primary.init(0, PlayerId.UNSET, Clock.DEFAULT); - secondary.init(1, PlayerId.UNSET, Clock.DEFAULT); - } - - void start() throws Exception { - selector.setSecondaryOverride(new TrackSelectionOverride(SECONDARY_GROUP, 0)); - enable(primary, primaryInput, "a"); - enable(secondary, secondaryInput, "a"); - } - - void seek(long positionUs) throws Exception { - primaryInput.phase = secondaryInput.phase = 1; - primary.resetPosition(OFFSET + positionUs, false); - secondary.resetPosition(OFFSET + positionUs, false); - } - - @Override public void close() { - release(primary); - release(secondary); - main(session::release); - selector.release(); - } - } - - private static void enable(TextRenderer renderer, Input input, String period) throws Exception { - renderer.enable(RendererConfiguration.DEFAULT, new Format[]{input.format}, input, - OFFSET, false, true, OFFSET, OFFSET, new MediaPeriodId(period)); - renderer.start(); - } - - private static void release(TextRenderer renderer) { - if (renderer.getState() == Renderer.STATE_STARTED) renderer.stop(); - if (renderer.getState() == Renderer.STATE_ENABLED) renderer.disable(); - renderer.reset(); - renderer.release(); - } - - private static void main(Runnable action) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(action); - } - - @SuppressWarnings("unchecked") - private static List cues(ExoSubtitleSession session) { - AtomicReference> result = new AtomicReference<>(); - main(() -> { - try { - java.lang.reflect.Field field = ExoSubtitleSession.class.getDeclaredField("cues"); - field.setAccessible(true); - result.set((List) field.get(session)); - } catch (ReflectiveOperationException e) { throw new AssertionError(e); } - }); - return result.get(); - } - - private static String text(List cues) { - return cues.isEmpty() ? "" : cues.get(0).text.toString(); - } - - private static void await(Rig rig, long positionUs, String expectedPrimary, String expectedSecondary) throws Exception { - long deadline = System.nanoTime() + 2_000_000_000L; - do { - rig.primary.render(OFFSET + positionUs, 0); - rig.secondary.render(OFFSET + positionUs, 0); - if (expectedPrimary.equals(text(rig.output.cues.cues)) - && expectedSecondary.equals(text(cues(rig.session)))) return; - Thread.sleep(5); - } while (System.nanoTime() < deadline); - fail("Expected " + expectedPrimary + " / " + expectedSecondary + ", got " - + text(rig.output.cues.cues) + " / " + text(cues(rig.session))); - } - - public void testRealRenderersKeepOutputsIndependentAcrossPauseOffsetAndSeek() throws Exception { - try (Rig rig = new Rig()) { - assertEquals(Renderer.STATE_DISABLED, rig.secondary.getState()); - assertEquals(0, rig.secondaryInput.reads); - rig.start(); - await(rig, 1_500_000, "PRIMARY ONE", "SECONDARY ONE"); - assertEquals(1, rig.output.samples); - assertEquals(1, rig.secondaryInput.reads); - assertEquals(Cue.ANCHOR_TYPE_START, cues(rig.session).get(0).lineAnchor); - rig.primary.stop(); - rig.secondary.stop(); - rig.primary.setTextOffsetMs(2000); - rig.secondary.setTextOffsetMs(2000); - assertTrue(rig.output.cues.cues.isEmpty()); - assertTrue(cues(rig.session).isEmpty()); - rig.primary.setTextOffsetMs(0); - rig.secondary.setTextOffsetMs(0); - assertEquals("PRIMARY ONE", text(rig.output.cues.cues)); - assertEquals("SECONDARY ONE", text(cues(rig.session))); - rig.primary.start(); - rig.secondary.start(); - rig.seek(5_500_000); - await(rig, 5_500_000, "PRIMARY TWO", "SECONDARY TWO"); - rig.seek(1_500_000); - await(rig, 1_500_000, "PRIMARY ONE", "SECONDARY ONE"); - rig.secondary.stop(); - rig.secondary.disable(); - assertTrue(cues(rig.session).isEmpty()); - assertEquals("PRIMARY ONE", text(rig.output.cues.cues)); - } - } - - public void testSourceResetRejectsOldStreamEvenWhenTheNewFormatIsIdentical() throws Exception { - try (Rig rig = new Rig()) { - rig.start(); - await(rig, 1_500_000, "PRIMARY ONE", "SECONDARY ONE"); - main(() -> { - rig.session.reset(); - rig.selector.setSecondaryOverride(new TrackSelectionOverride(SECONDARY_GROUP, 0)); - }); - rig.secondary.setTextOffsetMs(100); - assertTrue("An old source must not repopulate the new session", cues(rig.session).isEmpty()); - rig.secondary.stop(); - rig.secondary.disable(); - enable(rig.secondary, new Input(SECONDARY, SRT), "b"); - await(rig, 1_500_000, "PRIMARY ONE", "SECONDARY ONE"); - main(rig.session::release); - rig.secondary.setTextOffsetMs(200); - assertTrue("Released sessions must ignore renderer output", cues(rig.session).isEmpty()); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleTrackSelectorTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleTrackSelectorTest.java deleted file mode 100644 index b73cce7760..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/DualSubtitleTrackSelectorTest.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.content.Context; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.Timeline; -import androidx.media3.common.TrackGroup; -import androidx.media3.common.TrackSelectionOverride; -import androidx.media3.exoplayer.RendererCapabilities; -import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; -import androidx.media3.exoplayer.source.TrackGroupArray; -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; -import androidx.media3.exoplayer.trackselection.TrackSelectorResult; -import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter; -import androidx.test.platform.app.InstrumentationRegistry; - -import junit.framework.TestCase; - -import java.util.Arrays; - -/** Runs the shipped Media3 selector, including its parameter overrides and tunneling decisions. */ -public class DualSubtitleTrackSelectorTest extends TestCase { - private DefaultTrackSelector delegate; - private DualSubtitleTrackSelector selector; - private TrackGroup video, audio, primary, secondary; - private TrackGroupArray groups; - private RendererCapabilities[] capabilities; - private int invalidations; - - @Override - protected void setUp() throws Exception { - super.setUp(); - Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - video = new TrackGroup("video", new Format.Builder().setId("v").setSampleMimeType(MimeTypes.VIDEO_H264) - .setWidth(1280).setHeight(720).setFrameRate(24).build()); - audio = new TrackGroup("audio", new Format.Builder().setId("a").setSampleMimeType(MimeTypes.AUDIO_AAC) - .setChannelCount(2).setSampleRate(48000).build()); - primary = new TrackGroup("english", text("en", "en")); - secondary = new TrackGroup("chinese", text("zh", "zh")); - groups = new TrackGroupArray(video, audio, primary, secondary); - capabilities = new RendererCapabilities[]{new Capabilities(C.TRACK_TYPE_VIDEO), - new Capabilities(C.TRACK_TYPE_AUDIO), new Capabilities(C.TRACK_TYPE_TEXT), - new Capabilities(C.TRACK_TYPE_TEXT)}; - delegate = new DefaultTrackSelector(context); - delegate.setParameters(delegate.buildUponParameters() - .setConstrainAudioChannelCountToDeviceCapabilities(false).setTunnelingEnabled(true) - .setOverrideForType(new TrackSelectionOverride(primary, 0)).build()); - selector = new DualSubtitleTrackSelector(delegate); - selector.setSecondaryRendererIndex(3); - selector.init(() -> invalidations++, DefaultBandwidthMeter.getSingletonInstance(context)); - } - - @Override - protected void tearDown() throws Exception { - if (selector != null) selector.release(); - super.tearDown(); - } - - private static Format text(String id, String language) { - return new Format.Builder().setId(id).setLanguage(language).setSampleMimeType(MimeTypes.TEXT_VTT).build(); - } - - private TrackSelectorResult select() throws Exception { - return selector.selectTracks(capabilities, groups, new MediaPeriodId("period"), Timeline.EMPTY); - } - - private TrackSelectorResult activate() throws Exception { - TrackSelectorResult result = select(); - selector.onSelectionActivated(result.info); - return result; - } - - public void testDefaultOffPreservesOriginalSelectionsAndTunneling() throws Exception { - TrackSelectorResult original = delegate.selectTracks(Arrays.copyOf(capabilities, 3), groups, - new MediaPeriodId("period"), Timeline.EMPTY); - TrackSelectorResult result = activate(); - for (int i = 0; i < 3; i++) assertTrue("Original renderer " + i, result.isEquivalent(original, i)); - assertNull(result.selections[3]); - assertNull(result.rendererConfigurations[3]); - assertEquals(4, result.tracks.getGroups().size()); - assertTrue(selector.isPrimarySelected(primary.getFormat(0))); - assertFalse(selector.isSecondarySelected(secondary.getFormat(0))); - assertSame(delegate.getParameters(), selector.getParameters()); - assertSame(delegate.getRendererCapabilitiesListener(), selector.getRendererCapabilitiesListener()); - } - - public void testSecondaryHasIndependentSelectionAndOnlyPublishesWhenActivated() throws Exception { - TrackSelectorResult before = activate(); - assertTrue(selector.setSecondaryOverride(new TrackSelectionOverride(secondary, 0))); - assertEquals(1, invalidations); - TrackSelectorResult result = select(); - assertFalse("Read-ahead is not active playback", selector.isSecondarySelected(secondary.getFormat(0))); - for (int i = 0; i < 3; i++) assertTrue(result.isEquivalent(before, i)); - assertEquals(secondary, result.selections[3].getTrackGroup()); - assertEquals(1, result.selections[3].length()); - assertEquals(2, result.tracks.getGroups().stream() - .filter(group -> group.getType() == C.TRACK_TYPE_TEXT && group.isSelected()).count()); - selector.onSelectionActivated(result.info); - assertTrue(selector.isPrimarySelected(primary.getFormat(0))); - assertFalse(selector.isPrimarySelected(secondary.getFormat(0))); - assertTrue(selector.isSecondarySelected(secondary.getFormat(0))); - } - - public void testPrimaryAndSecondaryDisableIndependently() throws Exception { - TrackSelectorResult before = activate(); - selector.setSecondaryOverride(new TrackSelectionOverride(secondary, 0)); - activate(); - selector.setParameters(delegate.buildUponParameters().setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build()); - TrackSelectorResult primaryOff = activate(); - assertNull(primaryOff.selections[2]); - assertEquals(secondary, primaryOff.selections[3].getTrackGroup()); - assertFalse(selector.isPrimarySelected(primary.getFormat(0))); - assertTrue(selector.isSecondarySelected(secondary.getFormat(0))); - assertTrue(primaryOff.isEquivalent(before, 0)); - assertTrue(primaryOff.isEquivalent(before, 1)); - selector.setParameters(delegate.buildUponParameters().setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false).build()); - selector.setSecondaryOverride(null); - TrackSelectorResult secondaryOff = activate(); - assertEquals(primary, secondaryOff.selections[2].getTrackGroup()); - assertNull(secondaryOff.selections[3]); - assertTrue(selector.isPrimarySelected(primary.getFormat(0))); - } - - public void testDuplicateRoleRequestDoesNotDisturbEitherSelection() throws Exception { - activate(); - selector.setSecondaryOverride(new TrackSelectionOverride(secondary, 0)); - TrackSelectorResult before = activate(); - assertFalse(selector.setSecondaryOverride(new TrackSelectionOverride(primary, 0))); - assertTrue(activate().isEquivalent(before)); - } - - public void testTwoVariantsOfOneGroupCannotBindTheSameSampleQueueTwice() throws Exception { - TrackGroup variants = new TrackGroup("variants", text("en-1", "en"), text("en-2", "en")); - groups = new TrackGroupArray(video, audio, variants, secondary); - delegate.setParameters(delegate.buildUponParameters().setOverrideForType(new TrackSelectionOverride(variants, 0)).build()); - activate(); - assertFalse(selector.setSecondaryOverride(new TrackSelectionOverride(variants, 1))); - assertNull(activate().selections[3]); - } - - public void testResetRejectsReadAheadActivationAndOldOutputTokens() throws Exception { - activate(); - selector.setSecondaryOverride(new TrackSelectionOverride(secondary, 0)); - TrackSelectorResult old = select(); - DualSubtitleTrackSelector.Request token = selector.request(); - assertTrue(token.accepts(secondary.getFormat(0))); - selector.reset(); - selector.onSelectionActivated(old.info); - assertNotSame(token, selector.request()); - assertFalse(selector.isPrimarySelected(primary.getFormat(0))); - assertFalse(selector.isSecondarySelected(secondary.getFormat(0))); - assertNull(select().selections[3]); - } - - public void testUnavailableOrDrmSubtitleDoesNotAffectAudioVideo() throws Exception { - TrackSelectorResult before = activate(); - TrackGroup missing = new TrackGroup("missing", text("missing", "fr")); - selector.setSecondaryOverride(new TrackSelectionOverride(missing, 0)); - assertNull(select().selections[3]); - TrackGroup encrypted = new TrackGroup("encrypted", text("drm", "fr").buildUpon() - .setCryptoType(C.CRYPTO_TYPE_FRAMEWORK).build()); - groups = new TrackGroupArray(video, audio, primary, encrypted); - selector.setSecondaryOverride(new TrackSelectionOverride(encrypted, 0)); - TrackSelectorResult result = activate(); - assertNull(result.selections[3]); - assertTrue(result.isEquivalent(before, 0)); - assertTrue(result.isEquivalent(before, 1)); - } - - private static final class Capabilities implements RendererCapabilities { - private final int type; - Capabilities(int type) { this.type = type; } - @Override public String getName() { return "fixture-" + type; } - @Override public int getTrackType() { return type; } - @Override public int supportsMixedMimeTypeAdaptation() { return ADAPTIVE_NOT_SUPPORTED; } - @Override public int supportsFormat(Format format) { - if (MimeTypes.getTrackType(format.sampleMimeType) != type) return RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE); - return RendererCapabilities.create(format.cryptoType == C.CRYPTO_TYPE_NONE ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_DRM, - ADAPTIVE_NOT_SUPPORTED, type == C.TRACK_TYPE_TEXT ? TUNNELING_NOT_SUPPORTED : TUNNELING_SUPPORTED); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/SecondarySubtitleCuesTest.java b/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/SecondarySubtitleCuesTest.java deleted file mode 100644 index 17132169ee..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/player/exo/subtitle/SecondarySubtitleCuesTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.fongmi.android.tv.player.exo.subtitle; - -import android.graphics.Bitmap; -import android.graphics.Color; -import android.graphics.Typeface; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; - -import androidx.media3.common.text.Cue; - -import junit.framework.TestCase; - -import java.util.Collections; -import java.util.List; - -public class SecondarySubtitleCuesTest extends TestCase { - public void testTextIsStackedAtTopWithoutChangingPrimaryStylesOrBidiText() { - SpannableString text = new SpannableString("中文 English\nالعربية"); - text.setSpan(new ForegroundColorSpan(Color.RED), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - text.setSpan(new StyleSpan(Typeface.BOLD), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - Cue original = new Cue.Builder().setText(text).setLine(0.9f, Cue.LINE_TYPE_FRACTION) - .setLineAnchor(Cue.ANCHOR_TYPE_END).setTextSize(0.2f, Cue.TEXT_SIZE_TYPE_FRACTIONAL).build(); - List result = SecondarySubtitleCues.atTop(List.of(original, new Cue.Builder().setText("second line").build())); - assertEquals(1, result.size()); - Cue secondary = result.get(0); - assertEquals("中文 English\nالعربية\nsecond line", secondary.text.toString()); - assertFalse(secondary.text instanceof Spanned); - assertEquals(Cue.ANCHOR_TYPE_START, secondary.lineAnchor); - assertTrue(secondary.line < 0.1f); - assertEquals(Cue.DIMEN_UNSET, secondary.textSize, 0f); - assertTrue(original.text instanceof Spanned); - assertEquals(2, ((Spanned) original.text).getSpans(0, 2, Object.class).length); - assertEquals(0.9f, original.line, 0.0001f); - } - - public void testBitmapRegionsKeepPixelsAndRelativeLayoutWhenMovedToTop() { - Bitmap bitmap = Bitmap.createBitmap(8, 2, Bitmap.Config.ARGB_8888); - try { - Cue lower = new Cue.Builder().setBitmap(bitmap).setPosition(0.2f).setSize(0.25f).setBitmapHeight(0.1f) - .setLine(0.9f, Cue.LINE_TYPE_FRACTION).setLineAnchor(Cue.ANCHOR_TYPE_END).build(); - Cue upper = lower.buildUpon().setPosition(0.6f).setBitmapHeight(0.04f) - .setLine(0.85f, Cue.LINE_TYPE_FRACTION).setLineAnchor(Cue.ANCHOR_TYPE_START).build(); - List result = SecondarySubtitleCues.atTop(List.of(lower, upper)); - assertEquals(2, result.size()); - assertSame(bitmap, result.get(0).bitmap); - assertSame(bitmap, result.get(1).bitmap); - assertEquals(0.08f, result.get(0).line, 0.0001f); - assertEquals(0.13f, result.get(1).line, 0.0001f); - assertEquals(0.2f, result.get(0).position, 0.0001f); - assertEquals(0.6f, result.get(1).position, 0.0001f); - assertEquals(lower.size, result.get(0).size, 0f); - assertEquals(lower.bitmapHeight, result.get(0).bitmapHeight, 0f); - } finally { - bitmap.recycle(); - } - } - - public void testEmptyOutputClearsRatherThanCreatingAnEmptyTextBox() { - assertTrue(SecondarySubtitleCues.atTop(Collections.emptyList()).isEmpty()); - assertTrue(SecondarySubtitleCues.atTop(List.of(new Cue.Builder().setText("").build())).isEmpty()); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginEndToEndDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginEndToEndDeviceTest.java deleted file mode 100644 index 54930268de..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginEndToEndDeviceTest.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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 androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.github.catvod.utils.Path; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.File; - -/** - * 插件系统**端到端真机链路**测试:真实目录 → 真实导入器 → 真实配置落盘 → 真实装载 - * → 真实 QuickJS 执行 → 真实动作返回值 → 关闭开关后真实卸载。 - * - *

存在理由:P2f 之前所有证据都是**分段**的——导入器有单测(但用临时根目录接缝)、 - * 配置有单测(但用假 Store)、QuickJS 有仪器化测试(但直插 factory/host,绕过了导入与 - * 配置)。分段全绿并不能证明"用户选一个目录、装上来、脚本真跑起来"这条链条通。本测试 - * 把四段串成一条,用真实 {@link Path#files()} 与真实 SharedPreferences 跑一遍。 - * - *

它同时覆盖一个真实缺陷的回归:{@code PluginManifest} 曾用同一个 JSON 键 - * {@code "version"} 既判协议版本又当插件版本,导致 {@code getVersion()} 恒为 "1"。 - */ -@RunWith(AndroidJUnit4.class) -public class PluginEndToEndDeviceTest { - - private static final String ID = "e2e.probe"; - private static final String PLUGIN_VERSION = "e2e-1"; - - private File sourceDir; - - @Before - public void setUp() { - PluginController.reset(); - PluginSourceConfig.get().remove(ID); - PluginSourceConfig.get().save(); - deleteQuietly(new File(PluginPackageImporter.pluginsRoot(), ID)); - sourceDir = new File(Path.cache(), "e2e-plugin-src"); - deleteQuietly(sourceDir); - assertTrue(sourceDir.mkdirs()); - write(new File(sourceDir, "manifest.json"), String.format(java.util.Locale.US, """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "%s", - "name": "E2E Probe", - "pluginVersion": "%s", - "sdk": 1, - "runtime": { "api": "js", "entry": "main.js", "timeout": 15 }, - "events": ["ui.contentShown"], - "permissions": ["plugin.events"] - } - """, ID, PLUGIN_VERSION)); - write(new File(sourceDir, "main.js"), """ - webhtv.on('ui.contentShown', function (payload) { - webhtv.log('e2e content shown', payload); - return 'seen'; - }); - webhtv.action('e2e.probe', function (payload) { - return 'pong:' + payload; - }); - """); - } - - @After - public void tearDown() { - PluginSourceConfig.get().remove(ID); - PluginSourceConfig.get().save(); - deleteQuietly(new File(PluginPackageImporter.pluginsRoot(), ID)); - PluginBootstrap.reloadAll(); - PluginController.reset(); - deleteQuietly(sourceDir); - } - - @Test - public void importedPluginRunsRealScriptEndToEnd() { - // 1. 整包导入:源目录 → 私有目录,entry 改写成复制后的绝对路径 - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(sourceDir); - assertTrue("import failed: " + result.error(), result.isSuccess()); - assertNotNull(result.entry()); - assertEquals(ID, result.entry().id()); - // 插件版本必须来自 pluginVersion,而不是协议版本 "1" - assertEquals(PLUGIN_VERSION, result.entry().version()); - assertTrue("imported plugins are enabled by default", result.entry().enabled()); - assertTrue(PluginPackageImporter.isOwnedDirectory(result.entry().directory())); - assertTrue(new File(result.entry().directory(), "main.js").isFile()); - - // 2. 配置真的落盘了(不是只活在内存里) - assertNotNull(PluginSourceConfig.get().find(ID)); - - // 3. 装载:注册运行时 + 按配置读清单 + QuickJS 求值 - assertTrue("reload should load the imported plugin", PluginBootstrap.reloadAll()); - PluginHost host = PluginController.get().host(); - assertTrue(host.isLoaded(ID)); - - // 4. 脚本真被执行:动作经宿主路由进 QuickJS 并带回返回值 - assertEquals("pong:{\"x\":1}", host.action(ID, "e2e.probe", "{\"x\":1}")); - } - - @Test - public void disablingPluginDropsItFromTheNextReload() { - assertTrue(PluginPackageImporter.importFrom(sourceDir).isSuccess()); - assertTrue(PluginBootstrap.reloadAll()); - assertTrue(PluginController.get().host().isLoaded(ID)); - - PluginSourceConfig.get().setEnabled(ID, false); - PluginSourceConfig.get().save(); - PluginBootstrap.reloadAll(); - - assertFalse(PluginController.get().host().isLoaded(ID)); - assertNull(PluginController.get().host().action(ID, "e2e.probe", "{}")); - } - - private static void write(File file, String text) { - try { - java.io.FileOutputStream out = new java.io.FileOutputStream(file); - try { - out.write(text.getBytes(java.nio.charset.Charset.forName("UTF-8"))); - } finally { - out.close(); - } - } catch (Exception e) { - throw new AssertionError("cannot write fixture " + file, e); - } - } - - private static void deleteQuietly(File file) { - if (file == null || !file.exists()) return; - File[] children = file.listFiles(); - if (children != null) for (File child : children) deleteQuietly(child); - //noinspection ResultOfMethodCallIgnored - file.delete(); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginPanelAndContextDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginPanelAndContextDeviceTest.java deleted file mode 100644 index e48f7ff1d2..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginPanelAndContextDeviceTest.java +++ /dev/null @@ -1,467 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import android.app.Instrumentation; -import android.os.SystemClock; -import android.view.View; -import android.webkit.WebView; -import android.widget.TextView; - -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.Vod; -import com.fongmi.android.tv.ui.activity.AppBrandingActivity; -import com.github.catvod.utils.Path; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.File; -import java.util.Locale; -import java.util.concurrent.Callable; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -/** - * 面板槽位与剧集上下文注入的**真机**测试:面板真的弹出来、WebView 真的渲染出插件返回的 html、 - * 动作 payload 里真的带上了 {@code context},以及缺 {@code plugin.data.episode} 权限时真的不带。 - * - *

为什么必须在设备上跑。面板链路后半段全是 Android 运行时: - * {@code PluginController.showPanel} 要投递到主线程 Handler、{@code PluginPanelDialog} 要经 - * {@code FragmentManager} 提交事务、{@code SideSheetDialog} 要真正的窗口、WebView 要有渲染进程。 - * JVM 单测只能覆盖到"payload 里有没有 context"这一层,覆盖不到"用户是否真的看见评论"。 - * - *

断言为什么读 WebView 的 DOM 而不是读返回值。插件动作的返回值在 - * {@code PluginController} 内部就被路由掉了,测试拿不到。而 DOM 里的文本正是用户看到的文本—— - * 只要 {@code document.getElementById(...).textContent} 等于期望值,就证明 - * 「payload → 脚本 → html → WebView」整条链路都通,而不是仅仅"某一环没抛异常"。 - * - *

宿主复用已注册的 {@link AppBrandingActivity}:新增 Manifest 条目会扩大测试对生产配置的 - * 侵入面,而面板与上下文注入对宿主页面类型并无要求(只要它是 {@code FragmentActivity})。 - */ -@RunWith(AndroidJUnit4.class) -public class PluginPanelAndContextDeviceTest { - - private static final String ID = "panel.probe"; - private static final String PLUGIN_VERSION = "panel-1"; - private static final String ACTION = "probe.panel"; - private static final String SLOT_LABEL = "ProbePanel"; - private static final String PANEL_TAG = "plugin-panel"; - private static final String TITLE = "PROBE42"; - private static final String YEAR = "2019"; - private static final String NO_CONTEXT = "NO_CONTEXT"; - private static final long WAIT_MS = 3_000L; - /** WebView 首次创建要起渲染进程,冷启动那一次比后续几次慢得多,单给 DOM 一个更宽的窗口。 */ - private static final long DOM_WAIT_MS = 20_000L; - /** 等 {@code document.readyState} 变成 complete 的上限:加载本身可能比读一次 DOM 慢得多。 */ - private static final long READY_WAIT_MS = 20_000L; - /** - * 读 DOM 的轮询间隔,**只给 {@code awaitWebText} 用**。 - * - *

不能沿用 {@link #sleep()} 的 50ms:那个间隔是查「视图出现没有」,代价近乎零; - * 而这里每一轮都要往 WebView 里发一次 {@code evaluateJavascript},50ms 一轮意味着 - * 20 秒窗口能堆四百次 eval,页面还没就绪时足以把渲染进程压住。 - */ - private static final long TEXT_POLL_MS = 250L; - - private static final String MANIFEST = """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "%s", - "name": "Panel Probe", - "pluginVersion": "%s", - "sdk": 1, - "runtime": { "api": "js", "entry": "main.js", "timeout": 15 }, - "events": ["ui.contentShown"], - "permissions": %s, - "ui": [ { "slot": "panel", "label": "%s", "action": "%s" } ] - } - """; - - /** 只证明"面板里显示的就是插件给的那段 html"。 */ - private static final String SCRIPT_MARK = """ - webhtv.action('probe.panel', function (payload) { - return '

PANELMARK
'; - }); - """; - - /** - * 把 payload 里的 activity 与 context 字段**逐字搬进 DOM**,让断言变成精确值比对。 - * - *

{@code payload} 是 JSON **字符串**而不是对象:{@code QuickJsPluginRuntime.dispatch} 直接把 - * 宿主拼好的串交给 JS,解析由插件自己做(README §5.1 的同一条约定,example/hello.js 也这么写)。 - * 这里必须自己 {@code JSON.parse},否则 {@code payload.context} 恒为 undefined。 - */ - private static final String SCRIPT_CONTEXT = """ - webhtv.action('probe.panel', function (raw) { - var payload = {}; - try { payload = JSON.parse(raw || '{}'); } catch (e) {} - var ctx = (payload && payload.context) || {}; - var act = (payload && payload.activity) || 'NO_ACTIVITY'; - var t = ctx.title || 'NO_CONTEXT'; - var y = ctx.year || 'NO_CONTEXT'; - return '' - + '

' + t + '
' - + '
' + y + '
' - + '
' + act + '
' - + ''; - }); - """; - - private ActivityScenario scenario; - private AppBrandingActivity activity; - private String activityName; - private File sourceDir; - - @Before - public void setUp() { - PluginController.reset(); - PluginSourceConfig.get().remove(ID); - PluginSourceConfig.get().save(); - deleteQuietly(new File(PluginPackageImporter.pluginsRoot(), ID)); - sourceDir = new File(Path.cache(), "panel-plugin-src"); - deleteQuietly(sourceDir); - assertTrue(sourceDir.mkdirs()); - - scenario = ActivityScenario.launch(AppBrandingActivity.class); - scenario.onActivity(host -> { - activity = host; - activityName = host.getClass().getSimpleName(); - }); - // 宿主 Activity 简名是上下文的键:插件在别的页面上拿到的必须是 null,所以这条先钉死。 - assertEquals("AppBrandingActivity", activityName); - } - - @After - public void tearDown() { - dismissPanelQuietly(); - PluginEpisodeContext.clear(activityName); - PluginSourceConfig.get().remove(ID); - PluginSourceConfig.get().save(); - deleteQuietly(new File(PluginPackageImporter.pluginsRoot(), ID)); - reloadOnMain(); - PluginController.reset(); - if (scenario != null) scenario.close(); - deleteQuietly(sourceDir); - } - - /** 面板渲染:插件返回的 html 真的进了 WebView,且标题来自槽位声明而不是宿主兜底文案。 */ - @Test - public void panelSlotOpensSheetWithPluginHtml() { - install("[\"plugin.events\",\"ui.panel\"]", SCRIPT_MARK); - resumeHost(); - - PluginController.get().dispatchSlotAction(ID, ACTION); - - Fragment panel = awaitPanel(); - assertNotNull("panel slot must open a PluginPanelDialog", panel); - assertEquals(SLOT_LABEL, awaitViewText(R.id.pluginPanelTitle)); - - WebView web = awaitWebView(); - assertNotNull("panel must host a WebView", web); - assertEquals("plugin html must be rendered by the panel WebView", "PANELMARK", awaitWebText(web, "probe-title")); - } - - /** 有 plugin.data.episode 权限:剧集字段真的进了 payload,并被脚本读出来渲染到面板上。 */ - @Test - public void actionPayloadCarriesEpisodeContextWhenPermissionGranted() { - install("[\"plugin.events\",\"ui.panel\",\"plugin.data.episode\"]", SCRIPT_CONTEXT); - resumeHost(); - publishContext(); - - PluginController.get().dispatchSlotAction(ID, ACTION); - - WebView web = awaitWebView(); - assertNotNull("panel must host a WebView", web); - assertEquals("context.title must reach the script", TITLE, awaitWebText(web, "probe-title")); - assertEquals("context.year must reach the script", YEAR, awaitWebText(web, "probe-year")); - // 这一条同时证明 payload 本身存在:没有 payload 就取不到 activity,只会渲染 NO_ACTIVITY。 - assertEquals("payload.activity must name the host page", activityName, awaitWebText(web, "probe-activity")); - } - - /** 未授予 plugin.data.episode:payload 照旧送达(activity 在),但 context 必须缺席而不是错位数据。 */ - @Test - public void actionPayloadOmitsEpisodeContextWithoutPermission() { - install("[\"plugin.events\",\"ui.panel\"]", SCRIPT_CONTEXT); - resumeHost(); - publishContext(); - - PluginController.get().dispatchSlotAction(ID, ACTION); - - WebView web = awaitWebView(); - assertNotNull("panel must host a WebView", web); - // 锚点先立:payload 确实到达了脚本,否则下面的"没有 context"毫无诊断力。 - assertEquals("payload.activity must still be delivered", activityName, awaitWebText(web, "probe-activity")); - assertEquals("context must be absent without plugin.data.episode", NO_CONTEXT, awaitWebText(web, "probe-title")); - assertEquals("context must be absent without plugin.data.episode", NO_CONTEXT, awaitWebText(web, "probe-year")); - } - - private void install(String permissions, String script) { - write(new File(sourceDir, "manifest.json"), - String.format(Locale.US, MANIFEST, ID, PLUGIN_VERSION, permissions, SLOT_LABEL, ACTION)); - write(new File(sourceDir, "main.js"), script); - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(sourceDir); - assertTrue("import failed: " + result.error(), result.isSuccess()); - PluginBootstrap.install(); - assertTrue("reload should load the probe plugin", reloadOnMain()); - PluginHost host = PluginController.get().host(); - assertTrue("plugin must be loaded", host.isLoaded(ID)); - assertNotNull("panel slot must survive manifest parsing", host.slotOfAction(ID, ACTION)); - } - - /** - * 装载必须在主线程发起:{@code PluginController.reload} 会顺手把槽位控件挂到当前页的 decorView 上, - * 而 View 只能在建树线程上改动。在测试线程直接调用会被 reload 内部的 catch 吞掉异常, - * 表现为"插件已 ready 但 reload 返回空列表"——这正是本测试首版失败的真正原因。 - */ - private static boolean reloadOnMain() { - AtomicBoolean loaded = new AtomicBoolean(false); - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> loaded.set(PluginBootstrap.reloadAll())); - return loaded.get(); - } - - /** 宿主页面必须是控制器眼里的"当前页",否则 showPanel 会按防串台规则直接放弃。 */ - private void resumeHost() { - scenario.onActivity(host -> PluginController.safeOnResumed(host)); - } - - private void publishContext() { - TmdbItem item = new TmdbItem(4242, "tv", TITLE, "", "", "", ""); - Vod vod = new Vod(); - vod.setId("probe-vod"); - vod.setName(TITLE); - vod.setYear(YEAR); - PluginEpisodeContext.publish(activityName, PluginEpisodePayload.of(item, vod, null)); - } - - private Fragment awaitPanel() { - return poll(() -> { - AppBrandingActivity host = activity; - if (host == null || host.isFinishing()) return null; - return host.getSupportFragmentManager().findFragmentByTag(PANEL_TAG); - }, WAIT_MS); - } - - private WebView awaitWebView() { - return poll(() -> { - Fragment panel = awaitPanelNow(); - if (panel == null) return null; - View root = panel.getView(); - if (root == null) return null; - View found = root.findViewById(R.id.pluginPanelWeb); - return found instanceof WebView ? (WebView) found : null; - }, WAIT_MS); - } - - private String awaitViewText(int viewId) { - String text = poll(() -> { - Fragment panel = awaitPanelNow(); - if (panel == null) return null; - View root = panel.getView(); - if (root == null) return null; - View found = root.findViewById(viewId); - return found instanceof TextView ? ((TextView) found).getText().toString() : null; - }, WAIT_MS); - assertNotNull("view " + viewId + " must exist in the panel", text); - return text; - } - - private Fragment awaitPanelNow() { - AppBrandingActivity host = activity; - if (host == null || host.isFinishing()) return null; - return host.getSupportFragmentManager().findFragmentByTag(PANEL_TAG); - } - - /** - * 读 DOM 文本;一次读不到就重开面板再读一次,仍读不到才返回诊断串。 - * - *

为什么要重开一次。这条链路偶发过一次性失败:{@code evaluateJavascript} 的回调 - * 再也不回来({@code latch.await} 直接超时,诊断串里 html=null),随后单独重跑同一批用例又全过。 - * 那是 WebView 渲染进程被压住,而不是插件或 payload 出错——此时断言失败会把「渲染进程卡了一次」 - * 说成「context 没送到脚本」,指向完全错误的方向。用户遇到同样情形会关掉面板再点一次, - * 重开一次正是更高保真的复现路径。 - */ - private String awaitWebText(WebView web, String elementId) { - String value = readWebText(liveWeb(web), elementId); - if (value != null) return value; - WebView reopened = retryDispatch(); - if (reopened != null) { - web = reopened; - value = readWebText(web, elementId); - if (value != null) return value; - } - // 超时不能只报 null:那既可能是"页面没加载",也可能是"元素 id 不对", - // 两种原因的修法完全不同,所以把页面真实内容一起带进断言消息。 - return "TIMEOUT html=" + evaluate(web, "document.documentElement.outerHTML") - + " audit=" + PluginAuditLog.get().entriesFor(ID) - + " url=" + evaluate(web, "document.URL"); - } - - /** - * 取当前面板里**活着的**那个 WebView。 - * - *

重开面板后调用方手里那个 {@code web} 已经随旧面板销毁,再往上发 - * {@code evaluateJavascript} 只会一直等到超时——那正是本次要消除的慢路径。 - * 所以每一轮都先按面板当前实例重新取一次,取不到才退回入参。 - */ - private WebView liveWeb(WebView fallback) { - WebView current = awaitWebView(); - return current != null ? current : fallback; - } - - /** 等到页面 {@code readyState=complete} 之后再逐次读文本;没就绪就返回 null,交给上层重开。 */ - private String readWebText(WebView web, String elementId) { - if (!awaitDocumentReady(web)) return null; - String expression = "document.getElementById('" + elementId + "').textContent"; - long deadline = SystemClock.uptimeMillis() + DOM_WAIT_MS; - while (SystemClock.uptimeMillis() < deadline) { - String value = evaluate(web, expression); - if (value != null) return value; - sleepTextPoll(); - } - return null; - } - - /** - * 等 {@code document.readyState} 变成 complete。 - * - *

加载中就往 WebView 里灌 {@code evaluateJavascript} 会挤住渲染进程——这一条是本次加固的 - * 核心:先确认页面就绪,再把轮询压到 {@link #TEXT_POLL_MS} 一次。 - */ - private static boolean awaitDocumentReady(WebView web) { - long deadline = SystemClock.uptimeMillis() + READY_WAIT_MS; - while (SystemClock.uptimeMillis() < deadline) { - if ("complete".equals(evaluate(web, "document.readyState"))) return true; - sleepTextPoll(); - } - return false; - } - - /** - * 关掉卡住的面板再重新触发一次点击,返回新面板的 WebView。 - * - *

必须先 dismiss 再重开:{@code PluginPanelDialog.show} 用固定 TAG 提交事务,旧实例还在时 - * 再 add 同一个 tag 会撞在 FragmentManager 上。等旧 fragment 真的消失,新面板才拿得到 tag。 - */ - private WebView retryDispatch() { - dismissPanelQuietly(); - poll(() -> awaitPanelNow() == null ? Boolean.TRUE : null, WAIT_MS); - PluginController.get().dispatchSlotAction(ID, ACTION); - return awaitWebView(); - } - - private static String evaluate(WebView web, String expression) { - final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); - final CountDownLatch latch = new CountDownLatch(1); - final AtomicReference box = new AtomicReference<>(); - instrumentation.runOnMainSync(() -> { - try { - web.evaluateJavascript(expression, value -> { - box.set(value); - latch.countDown(); - }); - } catch (Throwable error) { - latch.countDown(); - } - }); - try { - latch.await(WAIT_MS, TimeUnit.MILLISECONDS); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } - return decode(box.get()); - } - - /** evaluateJavascript 回传的是 JSON:字符串带引号,元素不存在时是字面量 null。 */ - private static String decode(String raw) { - if (raw == null || "null".equals(raw)) return null; - if (raw.length() >= 2 && raw.charAt(0) == '"' && raw.charAt(raw.length() - 1) == '"') { - return raw.substring(1, raw.length() - 1); - } - return raw; - } - - private static T poll(Callable probe, long timeoutMs) { - final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); - final AtomicReference box = new AtomicReference<>(); - long deadline = SystemClock.uptimeMillis() + timeoutMs; - while (SystemClock.uptimeMillis() < deadline) { - box.set(null); - instrumentation.runOnMainSync(() -> { - try { - box.set(probe.call()); - } catch (Throwable ignored) { - } - }); - T value = box.get(); - if (value != null) return value; - sleep(); - } - return null; - } - - /** 查视图是否出现的轮询间隔:不碰 WebView,50ms 就够。 */ - private static void sleep() { - try { - Thread.sleep(50L); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } - } - - /** 读 DOM 的轮询间隔:每一轮都要发一次 JS eval,必须比 {@link #sleep()} 更宽。 */ - private static void sleepTextPoll() { - try { - Thread.sleep(TEXT_POLL_MS); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } - } - - private void dismissPanelQuietly() { - AppBrandingActivity host = activity; - if (host == null) return; - try { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - Fragment panel = host.getSupportFragmentManager().findFragmentByTag(PANEL_TAG); - if (panel instanceof DialogFragment dialog) dialog.dismissAllowingStateLoss(); - }); - } catch (Throwable ignored) { - } - } - - private static void write(File file, String text) { - try { - java.io.FileOutputStream out = new java.io.FileOutputStream(file); - try { - out.write(text.getBytes(java.nio.charset.Charset.forName("UTF-8"))); - } finally { - out.close(); - } - } catch (Exception error) { - throw new AssertionError("cannot write fixture " + file, error); - } - } - - private static void deleteQuietly(File file) { - if (file == null || !file.exists()) return; - File[] children = file.listFiles(); - if (children != null) for (File child : children) deleteQuietly(child); - //noinspection ResultOfMethodCallIgnored - file.delete(); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginPanelLeanbackFocusDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginPanelLeanbackFocusDeviceTest.java deleted file mode 100644 index 159a1cd82b..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginPanelLeanbackFocusDeviceTest.java +++ /dev/null @@ -1,423 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import android.app.Instrumentation; -import android.os.SystemClock; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import android.widget.LinearLayout; - -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.activity.AppBrandingActivity; -import com.fongmi.android.tv.utils.Util; -import com.github.catvod.utils.Path; - -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.File; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -/** - * 插件 {@code panel} 槽位在**电视端**的三件事:入口能被遥控器选中、入口进得了宿主控制条、 - * 面板关闭后焦点回到入口。 - * - *

为什么单开一个测试类。{@link PluginPanelAndContextDeviceTest} 用 - * {@code PluginController.dispatchSlotAction} 直接注入点击,绕过了 chip 本身——于是「chip 是否可聚焦」 - * 从来没有被任何测试覆盖过。而电视端没有触摸,焦点环就是用户唯一的"我在哪"的表达: - * 一颗不可聚焦的 chip 等于插件根本不存在,可这条链路在手机端永远是对的,靠手机回归发现不了。 - * - *

为什么整个类都 {@code Assume} 掉非电视端。可聚焦性与"进控制条"都是电视专属语义, - * 手机端刻意保持 floating 的触摸行为({@code PluginUiPresenter.addPanelEntry} 里 - * {@code if (!leanback) addFloating(..., false)})。同一份 androidTest 源集会被 mobile 变体编译并执行, - * 所以这里用 {@code Assume.assumeTrue(Util.isLeanback())} 跳过而不是断言"手机端也一样"。 - */ -@RunWith(AndroidJUnit4.class) -public class PluginPanelLeanbackFocusDeviceTest { - - private static final String ID = "panel.focus"; - private static final String PLUGIN_VERSION = "focus-1"; - private static final String ACTION = "probe.focus"; - private static final String FLOAT_ACTION = "probe.float"; - private static final String SLOT_LABEL = "FocusPanel"; - private static final String PANEL_TAG = "plugin-panel"; - private static final long WAIT_MS = 3_000L; - - private static final String MANIFEST = """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "%s", - "name": "Focus Probe", - "pluginVersion": "%s", - "sdk": 1, - "runtime": { "api": "js", "entry": "main.js", "timeout": 15 }, - "events": ["ui.contentShown"], - "permissions": ["plugin.events", "ui.panel"], - "ui": [ { "slot": "panel", "label": "%s", "action": "%s" } ] - } - """; - - private static final String SCRIPT = """ - webhtv.action('probe.focus', function (payload) { - return '

FOCUSMARK
'; - }); - """; - - private ActivityScenario scenario; - private AppBrandingActivity activity; - private PluginUiPresenter presenter; - private File sourceDir; - /** 用例 2 临时搭出来的控制条根节点;必须在用例结束前摘掉,否则会污染后续用例的方向键路径。 */ - private View tempControlBar; - private LinearLayout tempChipContainer; - - @Before - public void setUp() { - PluginController.reset(); - PluginSourceConfig.get().remove(ID); - PluginSourceConfig.get().save(); - deleteQuietly(new File(PluginPackageImporter.pluginsRoot(), ID)); - sourceDir = new File(Path.cache(), "panel-focus-src"); - deleteQuietly(sourceDir); - assertTrue("cannot create fixture dir", sourceDir.mkdirs()); - - scenario = ActivityScenario.launch(AppBrandingActivity.class); - scenario.onActivity(host -> activity = host); - assertNotNull("host activity must be launched", activity); - presenter = new PluginUiPresenter(); - } - - @After - public void tearDown() { - dismissPanelQuietly(); - removeTempControlBar(); - // detach() 里是 parent.removeView(...),必须在建树线程上做。 - if (presenter != null) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(presenter::detach); - } - PluginSourceConfig.get().remove(ID); - PluginSourceConfig.get().save(); - deleteQuietly(new File(PluginPackageImporter.pluginsRoot(), ID)); - // 卸载同样要回主线程:它在 host.size()==0 时会 presenter.detach(),走的是 removeView。 - reloadOnMain(); - PluginController.reset(); - if (scenario != null) scenario.close(); - deleteQuietly(sourceDir); - } - - /** - * 电视端入口必须可聚焦:这是遥控器"能停在它上面"的唯一前提。 - * - *

同时留一条对照断言——{@code floating} 槽位在电视端**仍然**不可聚焦(既有行为, - * 见 {@code PluginUiPresenterDeviceTest})。没有这条对照,日后把 {@code addFloating} 的 - * {@code setFocusable(!leanback)} 一并改掉也不会有人发现。 - */ - @Test - public void panelEntryIsFocusableOnLeanback() { - Assume.assumeTrue("面板入口的焦点语义只在电视端成立", Util.isLeanback()); - - PluginHost.PluginSlot panel = slot("panel", ACTION); - attachOnMain(List.of(panel), true); - View chip = findChip(panel); - assertNotNull("panel entry must be attached to the decor view on leanback", chip); - assertTrue("panel entry must be focusable on leanback, actual focusable=" + isFocusableOnMain(chip), - isFocusableOnMain(chip)); - assertTrue("requestFocus must succeed on the panel entry", requestFocusOnMain(chip)); - assertTrue("panel entry must hold focus after requestFocus", isFocusedOnMain(chip)); - - PluginHost.PluginSlot floating = slot("floating", FLOAT_ACTION); - attachOnMain(List.of(floating), true); - View floatChip = findChip(floating); - assertNotNull("floating entry must still be attached on leanback", floatChip); - assertFalse("floating slots must stay non-focusable on leanback (existing behaviour)", - isFocusableOnMain(floatChip)); - } - - /** - * 有控制条的页面里,panel 入口要进控制条而不是当浮标。 - * - *

控制条里已有的 {@code @style/Control} 自带焦点环与方向键通路,挂进去即可被遥控到, - * 且不会在原有导航路径上凭空多一个停靠点——这正是"进控制条"与"自己管焦点环的浮标"的差别, - * 所以断言落在**父节点身份**上,而不是"它可聚焦"(两者都不可取)。 - * - *

{@link AppBrandingActivity} 本身没有控制条,因此这里按宿主真实的三层 id 结构 - * ({@code R.id.control} → {@code R.id.action} → {@code R.id.container})临时搭一份。 - */ - @Test - public void panelSlotGoesIntoControlBarOnLeanback() { - Assume.assumeTrue("控制条归属只在电视端成立", Util.isLeanback()); - - buildTempControlBar(); - assertNotNull("temp control bar must be built", tempChipContainer); - - PluginHost.PluginSlot panel = slot("panel", ACTION); - attachOnMain(List.of(panel), true); - View chip = findChip(panel); - assertNotNull("panel entry must be attached when a control bar exists", chip); - ViewGroup parent = (ViewGroup) chip.getParent(); - assertTrue("panel entry must live inside R.id.container, actual parentId=" - + (parent == null ? "null" : String.valueOf(parent.getId())), - parent == tempChipContainer); - assertTrue("chip inside the control bar must be focusable", isFocusableOnMain(chip)); - } - - /** - * 面板关闭后焦点必须回到入口。 - * - *

电视端最关键的一条:面板是独立窗口,关闭时系统把焦点交还给宿主自己挑的落点。 - * 若不还,用户关掉评论面板就"丢了自己刚才站的位置",只能从头按方向键找回控制条。 - * - *

本用例刻意走 {@code chip.performClick()} 而不是 {@code dispatchSlotAction}: - * 只有真实点击才会让 {@code PluginUiPresenter.dispatch} 记下 {@code lastClicked}, - * 而那正是关闭后归还焦点的落点。 - */ - @Test - public void closingPanelReturnsFocusToEntryOnLeanback() { - Assume.assumeTrue("焦点归还只在电视端成立", Util.isLeanback()); - - install(); - resumeHost(); - - PluginHost.PluginSlot panel = slot("panel", ACTION); - View chip = awaitChip(panel); - assertNotNull("panel entry must appear after the plugin is loaded", chip); - - performClickOnMain(chip); - Fragment sheet = awaitPanel(); - assertNotNull("clicking the panel entry must open the panel", sheet); - // 面板是独立窗口,宿主窗口失去焦点就说明按键已经进了面板。 - // 不断言 chip.isFocused()==false:窗口失去焦点时焦点视图内部的 focused 标志并不被清除, - // 那条断言会去证明一件不成立的事,只会制造随机红灯。 - // 用轮询而非立即断言:提交对话框事务与窗口真正拿到焦点是两步,中间有极短的空窗。 - assertTrue("host window must lose focus while the panel is up", awaitWindowFocusLost(WAIT_MS)); - - dismissPanelOnMain(); - boolean focusedAgain = awaitFocused(chip, WAIT_MS); - assertTrue("closing the panel must return focus to the entry, actual focused=" - + isFocusedOnMain(chip) + " windowFocus=" + hasWindowFocusOnMain(), focusedAgain); - } - - private PluginHost.PluginSlot slot(String type, String action) { - return new PluginHost.PluginSlot(ID, new PluginManifest.UiSlot(type, SLOT_LABEL, action, null, null)); - } - - private void install() { - write(new File(sourceDir, "manifest.json"), - String.format(Locale.US, MANIFEST, ID, PLUGIN_VERSION, SLOT_LABEL, ACTION)); - write(new File(sourceDir, "main.js"), SCRIPT); - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(sourceDir); - assertTrue("import failed: " + result.error(), result.isSuccess()); - PluginBootstrap.install(); - assertTrue("reload should load the probe plugin", reloadOnMain()); - PluginHost host = PluginController.get().host(); - assertTrue("plugin must be loaded", host.isLoaded(ID)); - assertNotNull("panel slot must survive manifest parsing", host.slotOfAction(ID, ACTION)); - } - - /** - * 装载必须回主线程:{@code PluginController.reload → renderNow → presenter.attach} 会往 - * decorView 上 addView,而 View 只能在建树线程上改。测试线程直接调会被 reload 的 - * {@code catch (Throwable)} 吞成"插件已 ready 但 reload 返回空"。 - */ - private static boolean reloadOnMain() { - AtomicBoolean loaded = new AtomicBoolean(false); - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> loaded.set(PluginBootstrap.reloadAll())); - return loaded.get(); - } - - /** 宿主页面要是控制器眼里的"当前页",否则 showPanel 会按防串台规则直接放弃。 */ - private void resumeHost() { - scenario.onActivity(host -> PluginController.safeOnResumed(host)); - } - - private void attachOnMain(List slots, boolean leanback) { - InstrumentationRegistry.getInstrumentation() - .runOnMainSync(() -> presenter.attach(activity, slots, leanback)); - } - - private View findChip(PluginHost.PluginSlot slot) { - return poll(() -> activity.getWindow().getDecorView().findViewWithTag(PluginUiPresenter.tagOf(slot)), 1_000L); - } - - private View awaitChip(PluginHost.PluginSlot slot) { - return poll(() -> activity.getWindow().getDecorView().findViewWithTag(PluginUiPresenter.tagOf(slot)), WAIT_MS); - } - - private boolean isFocusableOnMain(View view) { - return onMain(view::isFocusable, false); - } - - private boolean isFocusedOnMain(View view) { - return onMain(view::isFocused, false); - } - - private boolean requestFocusOnMain(View view) { - return onMain(view::requestFocus, false); - } - - private boolean hasWindowFocusOnMain() { - return onMain(() -> activity == null || activity.hasWindowFocus(), false); - } - - private void performClickOnMain(View view) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(view::performClick); - } - - /** 搭一份与宿主同构的控制条:control → action → container,找不到 R.id.container 就不进控制条。 */ - private void buildTempControlBar() { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - LinearLayout control = new LinearLayout(activity); - control.setId(R.id.control); - control.setOrientation(LinearLayout.VERTICAL); - FrameLayout actionGroup = new FrameLayout(activity); - actionGroup.setId(R.id.action); - LinearLayout container = new LinearLayout(activity); - container.setId(R.id.container); - container.setOrientation(LinearLayout.HORIZONTAL); - actionGroup.addView(container); - control.addView(actionGroup); - // getDecorView() 的静态类型是 View,挂子视图前要换成 ViewGroup。 - View decor = activity.getWindow().getDecorView(); - if (decor instanceof ViewGroup group) group.addView(control); - tempControlBar = control; - tempChipContainer = container; - }); - } - - private void removeTempControlBar() { - View bar = tempControlBar; - tempControlBar = null; - tempChipContainer = null; - if (bar == null) return; - try { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - if (bar.getParent() instanceof ViewGroup parent) parent.removeView(bar); - }); - } catch (Throwable ignored) { - } - } - - private Fragment awaitPanel() { - return poll(this::panelNow, WAIT_MS); - } - - private Fragment panelNow() { - AppBrandingActivity host = activity; - if (host == null || host.isFinishing()) return null; - return host.getSupportFragmentManager().findFragmentByTag(PANEL_TAG); - } - - private void dismissPanelOnMain() { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - Fragment panel = panelNow(); - if (panel instanceof DialogFragment dialog) dialog.dismissAllowingStateLoss(); - }); - } - - private void dismissPanelQuietly() { - try { - dismissPanelOnMain(); - } catch (Throwable ignored) { - } - } - - private boolean awaitWindowFocusLost(long timeoutMs) { - long deadline = SystemClock.uptimeMillis() + timeoutMs; - while (SystemClock.uptimeMillis() < deadline) { - if (!hasWindowFocusOnMain()) return true; - sleep(); - } - return false; - } - - private boolean awaitFocused(View view, long timeoutMs) { - long deadline = SystemClock.uptimeMillis() + timeoutMs; - while (SystemClock.uptimeMillis() < deadline) { - if (isFocusedOnMain(view)) return true; - sleep(); - } - return false; - } - - private static T onMain(Callable probe, T fallback) { - AtomicReference box = new AtomicReference<>(); - try { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - try { - box.set(probe.call()); - } catch (Throwable ignored) { - } - }); - } catch (Throwable ignored) { - return fallback; - } - T value = box.get(); - return value == null ? fallback : value; - } - - private static T poll(Callable probe, long timeoutMs) { - final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); - final AtomicReference box = new AtomicReference<>(); - long deadline = SystemClock.uptimeMillis() + timeoutMs; - while (SystemClock.uptimeMillis() < deadline) { - box.set(null); - instrumentation.runOnMainSync(() -> { - try { - box.set(probe.call()); - } catch (Throwable ignored) { - } - }); - T value = box.get(); - if (value != null) return value; - sleep(); - } - return null; - } - - private static void sleep() { - try { - Thread.sleep(50L); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } - } - - private static void write(File file, String text) { - try { - java.io.FileOutputStream out = new java.io.FileOutputStream(file); - try { - out.write(text.getBytes(java.nio.charset.Charset.forName("UTF-8"))); - } finally { - out.close(); - } - } catch (Exception error) { - throw new AssertionError("cannot write fixture " + file, error); - } - } - - private static void deleteQuietly(File file) { - if (file == null || !file.exists()) return; - File[] children = file.listFiles(); - if (children != null) for (File child : children) deleteQuietly(child); - //noinspection ResultOfMethodCallIgnored - file.delete(); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginParamsNetDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginParamsNetDeviceTest.java deleted file mode 100644 index 8f39fd1183..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginParamsNetDeviceTest.java +++ /dev/null @@ -1,215 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.github.catvod.utils.Path; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.File; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.Socket; - -/** - * 参数配置与 {@code net.request} 的**真机链路**测试。 - * - *

JVM 单测能证明 {@link PluginEnvironment} 替换得对、{@link PluginNetBridge} 会记账, - * 但证不了这两样东西**真的接进了 QuickJS 的全局对象**:宿主注入用的是 - * {@code JSObject.setProperty(...) + JSCallFunction},那条路只在真机上有原生实现。 - * 因此这里跑的是完整链路:清单 params → 用户填的环境 → 占位符替换 → 信封 → - * {@code runtime.init} → 注入 {@code webhtv.config} → 脚本读它 → 脚本调 {@code webhtv.request} - * → 宿主 OkHttp 打到本地 socket 服务器 → 结果回传脚本。 - */ -@RunWith(AndroidJUnit4.class) -public class PluginParamsNetDeviceTest { - - private static final String ID = "e2e.params"; - - private File sourceDir; - private ServerSocket server; - private Thread serverThread; - - @Before - public void setUp() throws Exception { - PluginController.reset(); - PluginSourceConfig.get().remove(ID); - PluginSourceConfig.get().save(); - PluginEnvironmentStore.get().remove(ID); - deleteQuietly(new File(PluginPackageImporter.pluginsRoot(), ID)); - sourceDir = new File(Path.cache(), "e2e-params-src"); - deleteQuietly(sourceDir); - assertTrue(sourceDir.mkdirs()); - PluginAuditLog.get().clear(); - } - - @After - public void tearDown() throws Exception { - stopServer(); - PluginSourceConfig.get().remove(ID); - PluginSourceConfig.get().save(); - PluginEnvironmentStore.get().remove(ID); - deleteQuietly(new File(PluginPackageImporter.pluginsRoot(), ID)); - PluginBootstrap.reloadAll(); - PluginController.reset(); - deleteQuietly(sourceDir); - PluginAuditLog.get().clear(); - } - - @Test - public void paramsReachTheScriptWithPlaceholdersResolved() { - install("[\"plugin.events\"]", """ - webhtv.action('probe.config', function () { - return JSON.stringify(webhtv.config); - }); - """); - assertTrue(PluginEnvironmentStore.get().put(ID, "{\"TOKEN\":\"abc123\"}")); - - assertTrue(PluginBootstrap.reloadAll()); - String config = PluginController.get().host().action(ID, "probe.config", "{}"); - - assertNotNull(config); - // 逐字段断言而不是 contains:contains 在 config 意外为空串时也会漏判。 - com.google.gson.JsonObject parsed = com.google.gson.JsonParser.parseString(config).getAsJsonObject(); - assertEquals("placeholder must be replaced by the user value", "abc123", parsed.get("token").getAsString()); - assertEquals("non-placeholder params must survive", 20, parsed.get("limit").getAsInt()); - } - - @Test - public void missingEnvironmentValueBecomesEmptyStringNotLiteral() { - install("[\"plugin.events\"]", """ - webhtv.action('probe.config', function () { - return JSON.stringify(webhtv.config); - }); - """); - - assertTrue(PluginBootstrap.reloadAll()); - String config = PluginController.get().host().action(ID, "probe.config", "{}"); - - assertNotNull(config); - com.google.gson.JsonObject parsed = com.google.gson.JsonParser.parseString(config).getAsJsonObject(); - assertTrue("the param key must still exist", parsed.has("token")); - assertEquals("unfilled placeholder must collapse to empty string", "", parsed.get("token").getAsString()); - } - - @Test - public void netRequestReachesARealServer() throws Exception { - serveOnce("hello-from-server"); - install("[\"plugin.events\",\"net.request\"]", String.format(java.util.Locale.US, """ - webhtv.action('probe.net', function () { - var res = webhtv.request('GET', 'http://127.0.0.1:%d/probe?token=secret'); - return res.status + ':' + res.body; - }); - """, server.getLocalPort())); - - assertTrue(PluginBootstrap.reloadAll()); - String result = PluginController.get().host().action(ID, "probe.net", "{}"); - - assertEquals("200:hello-from-server", result); - boolean audited = false; - for (PluginAuditLog.Entry entry : PluginAuditLog.get().entriesFor(ID)) { - if ("request".equals(entry.kind()) && "GET".equals(entry.method()) && entry.status() == 200) audited = true; - } - assertTrue("a real request must be audited under the plugin id", audited); - } - - @Test - public void netRequestWithoutPermissionIsBlockedNotCrashed() throws Exception { - serveOnce("should-not-be-read"); - install("[\"plugin.events\"]", String.format(java.util.Locale.US, """ - webhtv.action('probe.net', function () { - var res = webhtv.request('GET', 'http://127.0.0.1:%d/probe'); - return String(res.status) + ':' + res.error; - }); - """, server.getLocalPort())); - - assertTrue(PluginBootstrap.reloadAll()); - String result = PluginController.get().host().action(ID, "probe.net", "{}"); - - assertNotNull("a blocked call must still return a result, not throw", result); - assertTrue("blocked call must report status 0: " + result, result.startsWith("0:")); - assertTrue("blocked call must name the missing permission: " + result, result.contains("net.request")); - assertTrue("plugin must stay loaded", PluginController.get().host().isLoaded(ID)); - assertTrue(PluginController.get().host().failedPlugins().isEmpty()); - } - - // ---- 装置 ---- - - private void install(String permissionsJson, String script) { - write(new File(sourceDir, "manifest.json"), String.format(java.util.Locale.US, """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "%s", - "name": "Params Probe", - "sdk": 1, - "runtime": { "api": "js", "entry": "main.js", "timeout": 15 }, - "params": { "token": "${TOKEN}", "limit": 20 }, - "events": ["ui.contentShown"], - "permissions": %s - } - """, ID, permissionsJson)); - write(new File(sourceDir, "main.js"), script); - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(sourceDir); - assertTrue("import failed: " + result.error(), result.isSuccess()); - } - - /** 起一个只应答一次的裸 socket 服务器:够验证"真发出去、真读回来",且不引入新依赖。 */ - private void serveOnce(String body) throws Exception { - server = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")); - serverThread = new Thread(() -> { - try (Socket socket = server.accept()) { - InputStream in = socket.getInputStream(); - byte[] buffer = new byte[4096]; - //noinspection ResultOfMethodCallIgnored - in.read(buffer); - byte[] payload = body.getBytes("UTF-8"); - OutputStream out = socket.getOutputStream(); - out.write(("HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: " - + payload.length + "\r\nConnection: close\r\n\r\n").getBytes("UTF-8")); - out.write(payload); - out.flush(); - } catch (Exception ignored) { - } - }, "probe-server"); - serverThread.setDaemon(true); - serverThread.start(); - } - - private void stopServer() throws Exception { - if (server != null) { - server.close(); - server = null; - } - } - - private static void write(File file, String text) { - try { - java.io.FileOutputStream out = new java.io.FileOutputStream(file); - try { - out.write(text.getBytes(java.nio.charset.Charset.forName("UTF-8"))); - } finally { - out.close(); - } - } catch (Exception e) { - throw new AssertionError("cannot write fixture " + file, e); - } - } - - private static void deleteQuietly(File file) { - if (file == null || !file.exists()) return; - File[] children = file.listFiles(); - if (children != null) for (File child : children) deleteQuietly(child); - //noinspection ResultOfMethodCallIgnored - file.delete(); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginUiPresenterDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginUiPresenterDeviceTest.java deleted file mode 100644 index 26cdd9106c..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginUiPresenterDeviceTest.java +++ /dev/null @@ -1,194 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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 android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.ui.activity.AppBrandingActivity; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; - -/** - * 槽位渲染的真机测试:把插件声明的控件真正加到 Activity 上,验证可查、可移除、可点击路由。 - * - *

存在理由:{@code attach/detach} 依赖 {@code Window.decorView}、{@code Resources.density} - * 与 {@code ThemeController.focusRingColor},都只有 Android 运行时能给;JVM 单测只能锁住计数逻辑。 - * - *

宿主复用已注册的 {@link AppBrandingActivity}:新增 Manifest 条目会扩大测试对生产配置的 - * 侵入面,而渲染逻辑对宿主类型并无要求。 - * - *

为什么不用 FollowingActivity。它缺少必需 Intent 参数时会在 onCreate 里直接 - * {@code finish()}({@code FollowingActivity.java:114}),Activity 随即销毁, - * {@code onActivity} 抛 "Activity has been destroyed already"。 - */ -@RunWith(AndroidJUnit4.class) -public class PluginUiPresenterDeviceTest { - - private static final String PROBE_PLUGIN_ID = "probe.plugin"; - - private ActivityScenario scenario; - private PluginUiPresenter presenter; - - @Before - public void setUp() { - scenario = ActivityScenario.launch(AppBrandingActivity.class); - presenter = new PluginUiPresenter(); - } - - /** - * 摘控件必须回到主线程。 - * - *

{@code detach} 走的是 {@code ViewGroup.removeView},错误线程调用时 {@code ViewRootImpl} - * 的 {@code checkThread} 会抛 {@code CalledFromWrongThreadException};而此前这一行能过只是 - * 因为摘除发生在 {@code ViewRootImpl} 已挂上窗口的时机之外,是竞态而非安全。本类每个用例都 - * 会 attach 一个真实控件,不能靠时机赌。 - */ - @After - public void tearDown() { - if (presenter != null) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> presenter.detach()); - } - if (scenario != null) scenario.close(); - } - - private static PluginHost.PluginSlot slot(String type, String action) { - return new PluginHost.PluginSlot(PROBE_PLUGIN_ID, - new PluginManifest.UiSlot(type, "探测", action, null, null)); - } - - @Test - public void floatingSlotIsReallyAttachedAndDetached() { - scenario.onActivity(activity -> { - presenter.attach(activity, List.of(slot("floating", "probe.action")), false); - - assertEquals("floating slot must produce exactly one view", 1, presenter.attachedCount()); - ViewGroup root = (ViewGroup) activity.getWindow().getDecorView(); - View found = findTagged(root); - assertNotNull("attached view must be present in the decor view", found); - assertTrue(found instanceof TextView); - assertEquals("探测", ((TextView) found).getText().toString()); - assertTrue("phone floating control must be clickable", found.isClickable()); - assertTrue("phone floating control must be focusable", found.isFocusable()); - - presenter.detach(); - assertEquals(0, presenter.attachedCount()); - assertNull("view must be removed from the tree", findTagged(root)); - }); - } - - @Test - public void leanbackFloatingIsNotFocusable() { - scenario.onActivity(activity -> { - presenter.attach(activity, List.of(slot("floating", "probe.action")), true); - - View found = findTagged((ViewGroup) activity.getWindow().getDecorView()); - assertNotNull(found); - assertTrue("leanback floating must stay out of the focus chain", !found.isFocusable()); - assertTrue("but still tappable", found.isClickable()); - }); - } - - /** - * AppBrandingActivity 没有播放控制条,因此 {@code toolbar} 槽位必须降级为 floating, - * 而不是被静默丢弃——作者声明了入口就该看到入口,且降级必须可查。 - */ - @Test - public void toolbarWithoutControlBarFallsBackToFloating() { - scenario.onActivity(activity -> { - presenter.attach(activity, List.of(slot("toolbar", "probe.action")), false); - - assertEquals("toolbar must still render something", 1, presenter.attachedCount()); - assertEquals("fallback must be recorded", 1, presenter.lastFallbacks().size()); - assertEquals("probe.plugin", presenter.lastFallbacks().get(0).pluginId()); - assertNotNull(findTagged((ViewGroup) activity.getWindow().getDecorView())); - }); - } - - /** 点击必须把 pluginId 与 action 一起交回装配层,否则无法定位到插件运行时。 */ - @Test - public void clickRoutesPluginIdAndActionBack() { - scenario.onActivity(activity -> { - AtomicReference gotPlugin = new AtomicReference<>(); - AtomicReference gotAction = new AtomicReference<>(); - presenter.setOnSlotAction((pluginId, action) -> { - gotPlugin.set(pluginId); - gotAction.set(action); - }); - - presenter.attach(activity, List.of(slot("floating", "comments.show")), false); - View found = findTagged((ViewGroup) activity.getWindow().getDecorView()); - assertNotNull(found); - - assertTrue("performClick must be accepted", found.performClick()); - assertEquals("probe.plugin", gotPlugin.get()); - assertEquals("comments.show", gotAction.get()); - }); - } - - /** 未被点击时回调不能自己触发。 */ - @Test - public void noClickMeansNoDispatch() { - scenario.onActivity(activity -> { - AtomicReference called = new AtomicReference<>(); - presenter.setOnSlotAction((pluginId, action) -> called.set(pluginId)); - - presenter.attach(activity, List.of(slot("floating", "probe.action")), false); - assertNull(called.get()); - }); - } - - @Test - public void attachWithNullActivityIsSafe() { - presenter.attach(null, List.of(slot("floating", "probe.action")), false); - assertEquals(0, presenter.attachedCount()); - } - - @Test - public void attachWithEmptySlotsRemovesPreviousViews() { - scenario.onActivity(activity -> { - presenter.attach(activity, List.of(slot("floating", "probe.action")), false); - assertEquals(1, presenter.attachedCount()); - - presenter.attach(activity, List.of(), false); - assertEquals(0, presenter.attachedCount()); - assertNull(findTagged((ViewGroup) activity.getWindow().getDecorView())); - }); - } - - /** - * 只找本用例自己挂的那一个控件。 - * - *

不能按 {@code TAG_PREFIX} 前缀匹配:宿主 resume 时 {@code PluginController} 会用它 - * 自己的 {@link PluginUiPresenter} 把已装载插件的槽位也挂到同一个 decorView 上(用户自己 - * 导入的插件就是这样),前缀匹配会先命中别人的控件,让断言 - * {@code expected:<探测> but was:<别的标签>} 变成一条与本节逻辑无关的红。 - */ - private static View findTagged(ViewGroup root) { - String prefix = PluginUiPresenter.TAG_PREFIX + PROBE_PLUGIN_ID + ":"; - for (int i = 0; i < root.getChildCount(); i++) { - View child = root.getChildAt(i); - Object tag = child.getTag(); - if (tag instanceof String text && text.startsWith(prefix)) return child; - if (child instanceof ViewGroup group) { - View nested = findTagged(group); - if (nested != null) return nested; - } - } - return null; - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginUiPresenterDragAndSurfaceDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginUiPresenterDragAndSurfaceDeviceTest.java deleted file mode 100644 index 6e1067bc1e..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/plugin/PluginUiPresenterDragAndSurfaceDeviceTest.java +++ /dev/null @@ -1,400 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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 android.app.Instrumentation; -import android.os.SystemClock; -import android.view.MotionEvent; -import android.view.View; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.ui.activity.AppBrandingActivity; -import com.fongmi.android.tv.utils.Util; -import com.github.catvod.utils.Prefers; - -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; -import java.util.Set; -import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicReference; - -/** - * 两件只有真机才能证的事:页面过滤在 {@code attach} 里真的生效、悬浮 chip 真的能用手指拖动并记住落点。 - * - *

为什么单开一个测试类。{@code PluginUiPresenterTest}(纯 JVM)只能锁住 - * {@link PluginSurface#matches} 这条纯函数判定,拿不到真实 Activity,也就证不了 - * "{@code attach} 在碰视图之前就把它筛掉了";拖动更彻底——它依赖 {@code MotionEvent.getRawX}、 - * 真实 layout 与 {@code SharedPreferences},JVM 里连事件都造不出来。 - * - *

为什么宿主仍然复用 {@link AppBrandingActivity}。与 {@code PluginUiPresenterDeviceTest} - * 同理:新增 Manifest 条目会扩大测试对生产配置的侵入面,而渲染与拖动对宿主类型没有要求。 - * 它不属于 {@code detail}({@code AppBrandingActivity} 直接继承 {@code BaseActivity}), - * 所以正好能当"不该显示 detail 槽位的页面";本类第 2 条用例会先把这一点断言死。 - */ -@RunWith(AndroidJUnit4.class) -public class PluginUiPresenterDragAndSurfaceDeviceTest { - - /** 自己的插件 id:{@code PluginController} 会把用户自己导入的插件的入口也挂到同一个 decorView 上, - * 用独立 id 才能保证 {@link PluginUiPresenter#tagOf} 只命中本类自己挂的控件。 */ - private static final String PROBE_PLUGIN_ID = "probe.drag"; - private static final String LABEL = "拖动探测"; - - private static final String ACTION_EVERY_PAGE = "probe.drag.every"; - private static final String ACTION_DETAIL_ONLY = "probe.drag.detail"; - private static final String ACTION_OTHER_ONLY = "probe.drag.other"; - private static final String ACTION_DRAG = "probe.drag.action"; - - /** 与 {@code PluginUiPresenter.POSITION_PREFIX}(私有常量)同值,测试只能照字面量写。 */ - private static final String POSITION_KEY_PREFIX = "plugin_chip_pos:"; - /** 键的实际形状由 {@code PluginUiPresenter.positionKey} 决定:前缀 + pluginId + ":" + action。 */ - private static final String DRAG_KEY = POSITION_KEY_PREFIX + PROBE_PLUGIN_ID + ":" + ACTION_DRAG; - /** 与 {@code PluginUiPresenter.EDGE_INSET_DP}(私有常量)同值:吸附后贴边的水平留白。 */ - private static final int EDGE_INSET_DP = 16; - - private static final int DRAG_STEPS = 6; - private static final int DRAG_UP_PX = 220; - private static final int RESTORE_TOLERANCE_PX = 2; - /** {@link #currentSurface()} 探测失败时的哨兵,绝不能与 {@code PluginSurface} 的任何真值相同。 */ - private static final String SURFACE_PROBE_FAILED = "probe-failed"; - private static final long LAYOUT_WAIT_MS = 5_000L; - private static final long FOCUS_WAIT_MS = 5_000L; - private static final long SETTLE_WAIT_MS = 3_000L; - private static final long PRESENCE_WAIT_MS = 1_000L; - - private ActivityScenario scenario; - private AppBrandingActivity activity; - private PluginUiPresenter presenter; - - @Before - public void setUp() { - // 上一次运行若在任何一步崩掉,残留的落点会让本次 attach 就把 chip 挪走, - // 于是"拖动前后位置必须不同"会莫名其妙地红——清掉它比事后解释便宜。 - removeStoredPosition(); - scenario = ActivityScenario.launch(AppBrandingActivity.class); - scenario.onActivity(host -> activity = host); - assertNotNull("host activity must be launched", activity); - presenter = new PluginUiPresenter(); - } - - /** - * 收尾:摘掉自己挂的控件、删掉落点记忆。 - * - *

{@code detach} 走的是 {@code ViewGroup.removeView},必须在建树线程上做, - * 否则 {@code ViewRootImpl.checkThread} 会抛 {@code CalledFromWrongThreadException}。 - */ - @After - public void tearDown() { - if (presenter != null) { - try { - detachOnMain(); - } catch (Throwable ignored) { - } - } - removeStoredPosition(); - if (scenario != null) scenario.close(); - } - - /** - * 未声明 {@code surfaces} 的槽位必须在任何页面上都挂出来。 - * - *

钉住的是向后兼容命门:历史清单没有 {@code surfaces} 字段,若把"没声明"当成 - * "哪都不显示",升级一次宿主就会让所有既有插件集体消失。 - */ - @Test - public void undeclaredSurfaceSlotIsAttachedOnEveryPage() { - PluginHost.PluginSlot slot = slot("floating", ACTION_EVERY_PAGE); - assertTrue("夹具前提:5 参构造出来的槽位 surfaces 必须是空集", slot.slot().surfaces().isEmpty()); - - attachOnMain(List.of(slot), false); - - assertNotNull("未声明 surfaces 的槽位必须出现在任意页面上(缺省=全页显示)", findTagged(slot)); - assertEquals("一个可渲染槽位对应一个控件", 1, attachedCountOnMain()); - } - - /** - * 只在 {@code detail} 生效的槽位不得出现在当前页面。 - * - *

钉住的是"过滤真的发生在 {@code attach} 里":控件数量必须是 0,也就是根本没建过控件, - * 而不是建完再摘掉(后者会让用户看到一次不该出现的入口,也会让排查变成猜)。 - */ - @Test - public void declaredSurfaceSlotIsSkippedOnTheCurrentPage() { - assertEquals("夹具前提:AppBrandingActivity 必须被判成 other 页,否则本条断言没有意义", - PluginSurface.OTHER, currentSurface()); - - PluginHost.PluginSlot slot = slot("floating", ACTION_DETAIL_ONLY, Set.of(PluginSurface.DETAIL)); - attachOnMain(List.of(slot), false); - - assertNull("只在 detail 生效的槽位不得出现在 other 页面上", findTagged(slot)); - assertEquals("被过滤的槽位连控件都不该建", 0, attachedCountOnMain()); - } - - /** - * 声明了当前页面的槽位必须照常挂出来。 - * - *

与上一条合起来才证明过滤是"按声明判":如果实现写成一律不挂,上一条仍然绿, - * 而这一条会红——只测"不该出现的没出现"是测不出过滤器坏掉的。 - */ - @Test - public void declaredSurfaceSlotIsAttachedWhenThePageMatches() { - assertEquals("夹具前提:AppBrandingActivity 必须被判成 other 页", - PluginSurface.OTHER, currentSurface()); - - PluginHost.PluginSlot slot = slot("floating", ACTION_OTHER_ONLY, Set.of(PluginSurface.OTHER)); - attachOnMain(List.of(slot), false); - - assertNotNull("声明了 other 的槽位必须出现在 other 页面上", findTagged(slot)); - assertEquals("一个可渲染槽位对应一个控件", 1, attachedCountOnMain()); - } - - /** - * 手指拖动必须真的改落点、松手贴边、并把落点记住供下次 {@code attach} 复原。 - * - *

为什么必须真注入触摸。拖动状态机在 {@code PluginUiPresenter.onChipTouch} 里, - * 判据是 {@code event.getRawX()/getRawY()} 的位移是否超过 touch slop;{@code performClick()} - * 只能造出点击,造不出"拖完再抬手"这条路径。 - * - *

为什么坐标必须用 {@code getLocationOnScreen}。注入的事件坐标是屏幕坐标, - * 而 {@code getLeft()/getTop()} 是父容器坐标;拿后者当事件坐标喂进去,手指会落在 chip 之外, - * 表现为"怎么拖都不动"。 - */ - @Test - public void dragMovesTheChipSnapsToASideAndIsRestoredOnNextAttach() { - Assume.assumeFalse("拖动只在手机端生效:电视端刻意不装触摸监听", Util.isLeanback()); - - PluginHost.PluginSlot slot = slot("floating", ACTION_DRAG); - attachOnMain(List.of(slot), false); - View chip = awaitLaidOutChip(slot, LAYOUT_WAIT_MS); - assertNotNull("拖动用例前提:chip 必须先完成首次布局,否则 getLeft/getTop 恒为 0", chip); - assertTrue("注入触摸前宿主窗口必须先拿到焦点,否则系统会直接丢弃这些事件", - awaitWindowFocus(FOCUS_WAIT_MS)); - - int[] before = locationOnMain(chip); - int[] size = sizeOnMain(chip); - assertNotNull("拖动前必须能读到 chip 的屏幕坐标", before); - assertNotNull("拖动前必须能读到 chip 的尺寸", size); - - // 把 chip 的中心拖到"距屏幕左边 EDGE_INSET_DP"处:落点中心必然落在屏幕左半边, - // 松手时 snap 只能选 start 侧,不会"拖了但还贴回原来那一边"。纵向同时上移, - // 保证位移在两个轴上都远超 touch slop,不会被判成一次点击。 - // 落点按屏幕坐标算:手指出到屏幕外之后,注入的 MOVE 有可能被输入系统直接丢弃, - // 于是整段手势只剩一个 DOWN,什么都不会发生。 - int densityDpi = Math.round(activity.getResources().getDisplayMetrics().density); - int targetCenterX = EDGE_INSET_DP * densityDpi + size[0] / 2; - int dragDx = targetCenterX - (before[0] + size[0] / 2); - int dragDy = -DRAG_UP_PX; - dragChip(chip, dragDx, dragDy); - - int[] after = awaitMoved(chip, before, SETTLE_WAIT_MS); - assertNotNull("松手吸附后 chip 必须换到新落点;before=[" + before[0] + "," + before[1] - + "],落点没变说明 chip 没收到这串触摸事件", after); - - String stored = Prefers.getString(DRAG_KEY, ""); - assertTrue("落点必须被写进 Prefers 的 " + DRAG_KEY + ",actual=\"" + stored + "\"", - stored.matches("(start|end):\\d\\.\\d{4}")); - assertTrue("拖到屏幕左半边后必须贴左侧,actual=\"" + stored + "\"", stored.startsWith("start:")); - - // 贴边留白只能拿"相对父容器的左偏移"来验:{@code getLeft()} 就是 decor 坐标系里的位置, - // 等于我们写进 {@code marginStart} 的那份留白;而屏幕坐标还混着窗口 inset, - // 拿它去比 dp(16) 会在有状态栏/刘海/分屏的设备上假红。 - int expectedLeft = Math.round(EDGE_INSET_DP * activity.getResources().getDisplayMetrics().density); - int leftInParent = leftOnMain(chip); - assertTrue("吸附必须贴到左侧留白处:expectedLeft=" + expectedLeft + " actualLeft=" + leftInParent, - Math.abs(leftInParent - expectedLeft) <= 1); - - // 复原:换一个全新的控件再看落点,才能证明位置来自记忆而不是上一次的 LayoutParams。 - detachOnMain(); - assertNull("detach 之后 chip 必须离开视图树", findTagged(slot)); - attachOnMain(List.of(slot), false); - View restored = awaitLaidOutChip(slot, LAYOUT_WAIT_MS); - assertNotNull("再次 attach 必须重新挂出 chip", restored); - int[] restoredLoc = locationOnMain(restored); - assertNotNull("复原后必须能读到屏幕坐标", restoredLoc); - assertTrue("再次 attach 必须复原到拖动后的落点(±" + RESTORE_TOLERANCE_PX + "px 舍入),dragged=[" - + after[0] + "," + after[1] + "] restored=[" + restoredLoc[0] + "," + restoredLoc[1] + "]", - Math.abs(restoredLoc[0] - after[0]) <= RESTORE_TOLERANCE_PX - && Math.abs(restoredLoc[1] - after[1]) <= RESTORE_TOLERANCE_PX); - } - - private static PluginHost.PluginSlot slot(String type, String action) { - return new PluginHost.PluginSlot(PROBE_PLUGIN_ID, - new PluginManifest.UiSlot(type, LABEL, action, null, null)); - } - - private static PluginHost.PluginSlot slot(String type, String action, Set surfaces) { - return new PluginHost.PluginSlot(PROBE_PLUGIN_ID, - new PluginManifest.UiSlot(type, LABEL, action, null, null, surfaces)); - } - - /** - * 当前页面归属。规则与实现都在 {@code PluginSurface.of(Activity)}。 - * - *

探测失败时返回哨兵而不是 {@code OTHER}:{@code OTHER} 正是我们要断言的值, - * 拿它当兜底会让"主线程探测没跑起来"伪装成"页面判定正确"。 - */ - private String currentSurface() { - return onMain(() -> PluginSurface.of(activity), SURFACE_PROBE_FAILED); - } - - private void attachOnMain(List slots, boolean leanback) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> presenter.attach(activity, slots, leanback)); - } - - private void detachOnMain() { - InstrumentationRegistry.getInstrumentation().runOnMainSync(presenter::detach); - } - - private int attachedCountOnMain() { - return onMain(presenter::attachedCount, -1); - } - - /** 按 tag 精确找自己挂的控件;{@code decorView} 上还有用户自己导入的插件的入口,不能按前缀扫。 */ - private View findTagged(PluginHost.PluginSlot slot) { - return poll(() -> activity.getWindow().getDecorView().findViewWithTag(PluginUiPresenter.tagOf(slot)), - PRESENCE_WAIT_MS); - } - - /** 等到 chip 真有尺寸再动手:未布局时 {@code getLeft()/getTop()} 都是 0,拖动会从错误的起点开始。 */ - private View awaitLaidOutChip(PluginHost.PluginSlot slot, long timeoutMs) { - return poll(() -> { - View view = activity.getWindow().getDecorView().findViewWithTag(PluginUiPresenter.tagOf(slot)); - if (view == null || !view.isShown() || view.getWidth() <= 0 || view.getHeight() <= 0) return null; - return view; - }, timeoutMs); - } - - private boolean awaitWindowFocus(long timeoutMs) { - long deadline = SystemClock.uptimeMillis() + timeoutMs; - while (SystemClock.uptimeMillis() < deadline) { - if (onMain(() -> activity != null && activity.hasWindowFocus(), false)) return true; - sleep(); - } - return false; - } - - /** 等吸附后的位置真的落到屏幕上({@code setLayoutParams} 只排一次布局,读太早还是旧值)。 */ - private int[] awaitMoved(View chip, int[] before, long timeoutMs) { - long deadline = SystemClock.uptimeMillis() + timeoutMs; - while (SystemClock.uptimeMillis() < deadline) { - int[] now = locationOnMain(chip); - if (now != null && (now[0] != before[0] || now[1] != before[1])) return now; - sleep(); - } - return null; - } - - private int[] locationOnMain(View view) { - return onMain(() -> { - int[] point = new int[2]; - view.getLocationOnScreen(point); - return point; - }, null); - } - - private int[] sizeOnMain(View view) { - return onMain(() -> new int[]{view.getWidth(), view.getHeight()}, null); - } - - private int leftOnMain(View view) { - return onMain(view::getLeft, 0); - } - - /** - * 注入一串指针事件:DOWN → 若干 MOVE → UP。 - * - *

起点与终点都按 {@code getLocationOnScreen} 换算成屏幕坐标;所有事件共用同一个 - * {@code downTime},系统才会把它们识别为同一次手势。 - */ - private void dragChip(View chip, int dx, int dy) { - Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); - int[] point = locationOnMain(chip); - int[] size = sizeOnMain(chip); - assertNotNull("注入前必须能读到 chip 的屏幕坐标", point); - assertNotNull("注入前必须能读到 chip 的尺寸", size); - - float fromX = point[0] + size[0] / 2f; - float fromY = point[1] + size[1] / 2f; - long downTime = SystemClock.uptimeMillis(); - sendPointer(instrumentation, downTime, downTime, MotionEvent.ACTION_DOWN, fromX, fromY); - for (int step = 1; step <= DRAG_STEPS; step++) { - // 事件之间留一点时间差:同一毫秒内的一串事件在部分设备上会被合并成一次移动。 - sleep(); - sendPointer(instrumentation, downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_MOVE, - fromX + dx * step / (float) DRAG_STEPS, fromY + dy * step / (float) DRAG_STEPS); - } - sleep(); - sendPointer(instrumentation, downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, - fromX + dx, fromY + dy); - } - - private static void sendPointer(Instrumentation instrumentation, long downTime, long eventTime, - int action, float x, float y) { - MotionEvent event = MotionEvent.obtain(downTime, eventTime, action, x, y, 0); - try { - instrumentation.sendPointerSync(event); - } finally { - event.recycle(); - } - } - - private static void removeStoredPosition() { - try { - Prefers.remove(DRAG_KEY); - } catch (Throwable ignored) { - } - } - - private static T onMain(Callable probe, T fallback) { - AtomicReference box = new AtomicReference<>(); - try { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - try { - box.set(probe.call()); - } catch (Throwable ignored) { - } - }); - } catch (Throwable ignored) { - return fallback; - } - T value = box.get(); - return value == null ? fallback : value; - } - - private static T poll(Callable probe, long timeoutMs) { - final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); - final AtomicReference box = new AtomicReference<>(); - long deadline = SystemClock.uptimeMillis() + timeoutMs; - while (SystemClock.uptimeMillis() < deadline) { - box.set(null); - instrumentation.runOnMainSync(() -> { - try { - box.set(probe.call()); - } catch (Throwable ignored) { - } - }); - T value = box.get(); - if (value != null) return value; - sleep(); - } - return null; - } - - private static void sleep() { - try { - Thread.sleep(50L); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/plugin/QuickJsRuntimeDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/plugin/QuickJsRuntimeDeviceTest.java deleted file mode 100644 index 8bf9175825..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/plugin/QuickJsRuntimeDeviceTest.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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 static org.junit.Assert.fail; - -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.Set; - -/** - * QuickJS 适配器的**真机链路测试**。 - * - *

存在理由:P2c 交付时该适配器只通过了编译与 JVM 单测,但 \`QuickJSLoader.init()\` - * 加载 .so、\`QuickJSContext.create()\` 建上下文、真实脚本求值这条链路**从未真跑过**。 - * JVM 单测无法覆盖它(依赖原生库与 Android 运行时),所以必须有仪器化测试兜底。 - * - *

本测试直插真实链路,不 mock:装载 → 事件派发 → 动作调用 → 故障隔离 → 卸载。 - */ -@RunWith(AndroidJUnit4.class) -public class QuickJsRuntimeDeviceTest { - - private PluginEventBus bus; - private PluginAuditLog audit; - private PluginRuntimeFactory factory; - private PluginHost host; - - @Before - public void setUp() { - bus = PluginEventBus.get(); - bus.clear(); - audit = PluginAuditLog.get(); - audit.clear(); - factory = new PluginRuntimeFactory().register("js", new QuickJsPluginRuntime()); - host = new PluginHost(factory, bus, audit); - } - - @After - public void tearDown() { - host.unloadAll(); - bus.clear(); - audit.clear(); - } - - private static String manifest(String id, String entry) { - // 用 String.format 而非 text block + formatted():String.formatted 是 API 30+ 的 - // 方法,而本项目 minSdk 更低、模拟器是 SDK 28,直呼会 NoSuchMethodError。 - return String.format(java.util.Locale.US, """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "%s", - "name": "%s", - "sdk": 1, - "runtime": { "api": "js", "entry": "%s" }, - "events": ["ui.contentShown"], - "permissions": ["plugin.events"] - } - """, id, id, entry); - } - - @Test - public void loadsAndDispatchesFromAssets() { - PluginManifest m = PluginManifest.parse(manifest("example.hello", "assets://plugin/example/hello.js")); - assertNotNull("example manifest must parse", m); - - host.load(java.util.List.of(m), null); - - assertTrue("plugin must load from assets:// on a real device", host.isLoaded("example.hello")); - assertTrue("no audit failures expected", audit.entriesFor("example.hello").isEmpty()); - - // 真实事件派发:走 QuickJS 求值,脚本返回 'ok' - bus.emitActivity(PluginEventBus.EVENT_UI_CONTENT_SHOWN, "TmdbDetailActivity"); - - assertTrue("plugin must not be quarantined after a normal event", host.failedPlugins().isEmpty()); - } - - @Test - public void actionRoundTripsThroughQuickJs() { - PluginManifest m = PluginManifest.parse(manifest("example.hello", "assets://plugin/example/hello.js")); - host.load(java.util.List.of(m), null); - - String result = host.action("example.hello", "hello.show", "{\"x\":1}"); - - assertEquals("script return value must round-trip", "ok", result); - } - - @Test - public void missingHandlerReturnsNullNotError() { - PluginManifest m = PluginManifest.parse(manifest("example.hello", "assets://plugin/example/hello.js")); - host.load(java.util.List.of(m), null); - - // 脚本没有注册这个动作:应返回 null,且不隔离 - assertNull(host.action("example.hello", "never.registered", "{}")); - assertTrue(host.failedPlugins().isEmpty()); - } - - @Test - public void brokenScriptIsQuarantinedNotCrashed() { - // 故意写一个语法错误的脚本,验证"坏脚本不崩应用" - PluginManifest m = PluginManifest.parse(manifest("broken", "file:///android_asset/plugin/broken.js")); - assertNotNull(m); - - host.load(java.util.List.of(m), null); - - // 参考实现:资产里没有 broken.js,init 失败 → 不装载 + 审计 - org.junit.Assert.assertFalse("broken plugin must not load", host.isLoaded("broken")); - assertTrue("failure must be audited", !audit.entriesFor("broken").isEmpty()); - } - - @Test - public void directRuntimeApiWorks() throws Exception { - // 绕开 Host,直接验证运行时三方法 —— 缩小故障定位范围 - QuickJsPluginRuntime runtime = new QuickJsPluginRuntime(); - try { - String init = runtime.init("direct", "assets://plugin/example/hello.js", "{}"); - assertEquals("ok", init); - assertEquals(1, runtime.activeSessions()); - - String event = runtime.onEvent("direct", "ui.contentShown", "{\"activity\":\"HomeActivity\"}"); - assertEquals("ok", event); - - String action = runtime.action("direct", "hello.show", "{}"); - assertEquals("ok", action); - } finally { - runtime.destroyAll(); - } - assertEquals("all contexts must be released", 0, runtime.activeSessions()); - } - - @Test - public void unknownPluginIdIsRejected() { - QuickJsPluginRuntime runtime = new QuickJsPluginRuntime(); - try { - runtime.onEvent("nobody", "e", "{}"); - fail("dispatching to an uninitialized plugin must throw"); - } catch (Exception expected) { - assertTrue(expected.getMessage().contains("not initialized")); - } finally { - runtime.destroyAll(); - } - } - - @Test - public void destroyIsIdempotent() { - QuickJsPluginRuntime runtime = new QuickJsPluginRuntime(); - runtime.destroy("nobody"); - runtime.destroy("nobody"); - runtime.destroy(null); - assertEquals(0, runtime.activeSessions()); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/theme/TmdbPersonScrimPixelDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/theme/TmdbPersonScrimPixelDeviceTest.java deleted file mode 100644 index 5a51426704..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/theme/TmdbPersonScrimPixelDeviceTest.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.fongmi.android.tv.theme; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import android.graphics.Bitmap; -import android.graphics.Rect; -import android.os.SystemClock; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.bean.TmdbPerson; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.ui.dialog.TmdbPersonDialog; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -/** Device-only proof that scrimOpacity changes the shipped dialog scrim, not the opaque panel. */ -@RunWith(AndroidJUnit4.class) -public class TmdbPersonScrimPixelDeviceTest { - - private ActivityScenario scenario; - private ThemeProfile originalProfile; - - @Before - public void setUp() { - originalProfile = ThemeProfileStore.load(); - scenario = ActivityScenario.launch(TmdbScrimHostActivity.class); - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - @After - public void tearDown() { - if (originalProfile != null) ThemeProfileStore.apply(originalProfile); - if (scenario != null) scenario.close(); - } - - @Test - public void scrimOpacityMovesOnlyTheDialogScrimPixels() { - Bitmap unset = captureWithOpacity(null); - Bitmap opaque = captureWithOpacity(ThemeProfileValidator.MAX_SCRIM_OPACITY); - assertEquals("probe screenshots must share the device resolution", unset.getWidth(), opaque.getWidth()); - assertEquals("probe screenshots must share the device resolution", unset.getHeight(), opaque.getHeight()); - - Rect scrim = new Rect(0, 0, unset.getWidth(), unset.getHeight() / 3); - dumpSample("unset", unset, scrim); - dumpSample("opaque", opaque, scrim); - int changedInPanel = changedPixelsAroundPanelCenter(unset, opaque); - int changedInScrim = changedPixels(unset, opaque, scrim); - - assertTrue("scrimOpacity=max must change visible pixels in the scrim area, changed=" + changedInScrim, - changedInScrim > 0); - assertEquals("opaque dialog panel centre must not change with scrimOpacity, changed=" + changedInPanel, - 0, changedInPanel); - } - - private Bitmap captureWithOpacity(Float opacity) { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.scrimOpacity = opacity; - profile.dark.scrimOpacity = opacity; - ThemeProfileStore.apply(profile); - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> scenario.onActivity(activity -> { - Setting.putTmdbDetailTheme(Setting.DETAIL_STYLE_NATIVE); - ThemeController.applyFromPreferences(activity); - TmdbPerson person = new TmdbPerson(1, "Pixel Probe", "Actor", "", "Acting", "biography"); - TmdbPersonDialog.show(activity, person, null); - })); - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - SystemClock.sleep(500); - Bitmap bitmap = InstrumentationRegistry.getInstrumentation().getUiAutomation().takeScreenshot(); - assertNotNull("screenshot unavailable", bitmap); - scenario.recreate(); - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - return bitmap; - } - - private static int changedPixels(Bitmap before, Bitmap after, Rect area) { - int changed = 0; - for (int y = area.top; y < area.bottom; y += 2) { - for (int x = area.left; x < area.right; x += 2) { - if (before.getPixel(x, y) != after.getPixel(x, y)) changed++; - } - } - return changed; - } - - private static int changedPixelsAroundPanelCenter(Bitmap before, Bitmap after) { - int changed = 0; - int cx = before.getWidth() / 2; - int cy = before.getHeight() / 2; - for (int y = cy - 20; y <= cy + 20; y += 2) { - for (int x = cx - 20; x <= cx + 20; x += 2) { - if (before.getPixel(x, y) != after.getPixel(x, y)) changed++; - } - } - return changed; - } - - private static void dumpSample(String tag, Bitmap bitmap, Rect area) { - int first = bitmap.getPixel(area.left + 4, area.top + 4); - int mid = bitmap.getPixel(area.centerX(), area.centerY()); - android.util.Log.i("TmdbScrimPixel", tag + " first=#" + Integer.toHexString(first) - + " mid=#" + Integer.toHexString(mid) + " size=" + bitmap.getWidth() + "x" + bitmap.getHeight()); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/theme/TvFocusRingResolutionDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/theme/TvFocusRingResolutionDeviceTest.java deleted file mode 100644 index 3c4b60a101..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/theme/TvFocusRingResolutionDeviceTest.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.fongmi.android.tv.theme; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.content.res.Resources; -import android.util.TypedValue; -import android.view.View; -import android.widget.FrameLayout; - -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.R; -import com.google.android.material.card.MaterialCardView; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -/** - * 设备侧证明:TV 焦点环只有一个取值源,且两条绘制路径在任何主题下都取到同一个颜色。 - * - *

背景:{@code tv_item_focus_ring} 由写死的 {@code #FFD166} 接线到主题 FOCUS 用户槽之后, - * TV 详情页仍在 Java 里写死 {@code 0xFFFFD166},于是同一个 TV 应用里出现了两种焦点环色, - * 且浅色详情页(底板 {@code #EBE3DA})的黄色环对比度只有 1.14:1,几乎看不见。 - * - *

本测试在真实设备上断言: - *

    - *
  1. {@code ?attr/tvFocusRing} 解析到的就是 {@code tv_item_focus_ring}, - * 且该 token 直连主题 FOCUS 用户槽;
  2. - *
  3. 没有 FOCUS 覆写时 {@code ThemeController.focusRingColor(...)} 与 - * {@code ?attr/tvFocusRing} 字节一致——默认档两条路径共用同一取值;
  4. - *
  5. 用户显式设置 FOCUS 槽后,代码路径必须跟随该槽:卡片的焦点描边由代码路径绘制, - * 它不跟随就等于编辑器的「焦点色」对这些视图完全无效(用户实机报告)。
  6. - *
- */ -@RunWith(AndroidJUnit4.class) -public class TvFocusRingResolutionDeviceTest { - - private ThemeProfile originalProfile; - - @Before - public void setUp() { - originalProfile = ThemeProfileStore.load(); - // 每条用例都从「无覆写」开始:设备上残留的主题档案会让静态属性断言失真。 - ThemeProfileStore.apply(ThemeProfile.defaultProfile()); - ThemeController.applyFromPreferences(null); - } - - @After - public void tearDown() { - if (originalProfile != null) ThemeProfileStore.apply(originalProfile); - ThemeController.applyFromPreferences(null); - } - - @Test - public void focusRingAttributeResolvesToTheThemeFocusSlot() { - Context context = themedContext(); - int attribute = resolveAttrColor(context); - assertEquals("?attr/tvFocusRing 必须解析到 tv_item_focus_ring", - context.getColor(R.color.tv_item_focus_ring), attribute); - assertEquals("代码路径与 XML 选择器路径必须共用同一取值", - attribute, ThemeController.focusRingColor(context)); - assertEquals("焦点环必须来自主题 FOCUS 用户槽,而不是别的角色", - context.getColor(R.color.webhtv_color_focus), attribute); - } - - /** - * 主题可控性:FOCUS 用户槽被覆写后,解析值必须变化。 - * 这是主题编辑器"焦点色"可改的底层保证;环色是槽的编译取值,槽位本身必须可覆写。 - */ - @Test - public void focusRingFollowsTheThemeFocusSlot() { - ThemeController.applyFromPreferences(null); - int before = ThemeController.resolveFromPreferences().colorFocus(); - - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.focus = "#00B0FF"; - profile.dark.focus = "#00B0FF"; - ThemeProfileStore.ApplyResult applied = ThemeProfileStore.apply(profile); - assertTrue("测试用的主题配置必须能保存: " + applied.error(), applied.success()); - - ThemeController.applyFromPreferences(null); - int slot = ThemeController.resolveFromPreferences().colorFocus(); - assertNotEquals("改 FOCUS 槽后解析值必须变化,否则主题设置对焦点环无效", before, slot); - assertEquals("active 快照的 FOCUS 槽必须等于解析结果", slot, ThemeController.current().colorFocus()); - } - - /** - * 回归门:用户显式设置 FOCUS 槽后,代码路径必须跟随该槽。 - * - *

默认档继续要求代码路径与 {@code ?attr/tvFocusRing} 字节一致(见上一条测试); - * 一旦用户改写 FOCUS 槽,编译期静态属性无法跟随,此时让代码路径继续返回静态值等于 - * 让「焦点色」对卡片描边永久失效——这正是用户实机反馈的现象。 - */ - @Test - public void codeRingPathFollowsAnExplicitFocusOverride() { - int compiledRing = themedContext().getColor(R.color.tv_item_focus_ring); - assertEquals("未覆写时代码路径必须保持编译期环色", - compiledRing, ThemeController.focusRingColor(themedContext())); - - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.focus = "#00B0FF"; - profile.dark.focus = "#00B0FF"; - assertTrue("主题覆写必须能保存", ThemeProfileStore.apply(profile).success()); - ThemeController.applyFromPreferences(null); - - Context context = themedContext(); - int codePath = ThemeController.focusRingColor(context); - assertEquals("代码路径必须跟随用户设置的 FOCUS 槽", - ThemeController.current().colorFocus(), codePath); - assertNotEquals("覆写后代码路径不得仍等于编译期静态环色", compiledRing, codePath); - - final int[] bound = new int[1]; - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - FrameLayout root = new FrameLayout(context); - MaterialCardView card = new MaterialCardView(context); - card.setStrokeColor(codePath); - root.addView(card); - ThemeController.bindTheme(root); - bound[0] = card.getStrokeColor(); - }); - assertTrue("描边经 binder 后必须仍等于代码路径取值", - bound[0] == codePath || bound[0] == ThemeController.current().colorFocus()); - } - - /** 应用真实主题(Theme.App -> Theme.Base 声明了 tvFocusRing)的 Context。 */ - private static Context themedContext() { - Context base = InstrumentationRegistry.getInstrumentation().getTargetContext(); - return new ContextThemeWrapper(base, R.style.Theme_App); - } - - private static int resolveAttrColor(Context context) { - TypedValue value = new TypedValue(); - Resources.Theme theme = context.getTheme(); - assertTrue("主题必须声明 tvFocusRing", theme.resolveAttribute(R.attr.tvFocusRing, value, true)); - if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT - && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { - return value.data; - } - return context.getColor(value.resourceId); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/tts/ReaderTtsDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/tts/ReaderTtsDeviceTest.java deleted file mode 100644 index fb8dab836f..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/tts/ReaderTtsDeviceTest.java +++ /dev/null @@ -1,431 +0,0 @@ -package com.fongmi.android.tv.tts; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.Intent; -import android.media.AudioManager; -import android.os.SystemClock; -import android.webkit.WebView; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.bean.Episode; -import com.fongmi.android.tv.service.ReaderTtsService; -import com.fongmi.android.tv.ui.web.WebReaderActivity; - -import org.json.JSONObject; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.File; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -/** - * 小说朗读的真机(联网)验收测试。 - * - * 为什么必须有这一层:朗读链路的每一段都是「运行时字符串/线程/网络」耦合, - * 单测只覆盖切句与规则渲染。这里在真实设备上验证: - * 1) 在线引擎真的合成出音频文件并真的在播放(用「片段推进」与 AudioManager 判活跃); - * 2) 系统引擎在没有 TTS 引擎的设备上给出可操作错误而不是静默失败; - * 3) reader.html + AndroidReader 桥 + 前台服务 + 控制器串起来后, - * 点朗读能进入 playing、跟随高亮落在正确段落、暂停/继续/下一段/停止按状态机工作; - * 4) 章末事件能驱动阅读页续读下一章。 - * - * 依赖:设备联网(百度/微软 Edge 音源)。运行方式: - * {@code bash scripts/build_arm64_debug_install.sh} 之后 - * {@code ./gradlew :app:connectedMobileArm64_v8aDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.fongmi.android.tv.tts.ReaderTtsDeviceTest} - */ -@RunWith(AndroidJUnit4.class) -public class ReaderTtsDeviceTest { - - private static final long LONG_WAIT_MS = 120_000L; - - private static final List PARAGRAPHS = Arrays.asList( - "山风从谷底吹上来,带着松针的气味。", - "他站了很久,直到天边泛起第一线亮色。", - "然后他转身,走下了山。"); - - /** - * 阅读器端到端用例的正文:比引擎用例长,保证「进后台继续读」「暂停/继续」 - * 这些步骤发生时本章还没读完(否则读到的就是章末停止,而不是被测行为)。 - */ - private static final List READER_PARAGRAPHS = Arrays.asList( - "夜色还没有完全退去,山坳里的雾气沿着石阶缓慢地往上爬。", - "他背着一只旧帆布包站在观景台上,听见远处传来第一声鸟叫。", - "风把松针的气味送过来,也送来了山下村庄里隐约的犬吠声。", - "很多年前他也是这样站在这里,只是那时候他还不知道自己要往哪里走。", - "太阳终于越过东边的山脊,光线像水一样漫过整片山谷,把雾气染成了淡金色。", - "他深深地吸了一口气,转身沿着来时的路一步一步走下去。"); - - /** 朗读事件探针:把控制器回调汇总成可断言的快照。 */ - private static final class Probe implements ReaderTtsController.Listener { - final List stateHistory = new CopyOnWriteArrayList<>(); - final AtomicInteger paragraph = new AtomicInteger(-1); - final AtomicInteger paragraphCount = new AtomicInteger(0); - final AtomicInteger chapterEnds = new AtomicInteger(); - final AtomicReference message = new AtomicReference<>(""); - volatile String state = "stopped"; - - @Override - public void onTtsState(String state, int paragraph, int total, String message, String title) { - this.state = state; - this.paragraph.set(paragraph); - this.paragraphCount.set(total); - if (message != null && !message.isEmpty()) this.message.set(message); - if (stateHistory.isEmpty() || !stateHistory.get(stateHistory.size() - 1).equals(state)) { - stateHistory.add(state); - } - } - - @Override - public void onTtsParagraph(int paragraph, int total, String text) { - this.paragraph.set(paragraph); - this.paragraphCount.set(total); - } - - @Override - public void onTtsChapterEnd() { - chapterEnds.incrementAndGet(); - } - - @Override - public void onTtsVoices(List voices) { - } - } - - private static Context context() { - return InstrumentationRegistry.getInstrumentation().getTargetContext(); - } - - private static Probe startController(ReaderTtsController controller, String source, - TtsEngineConfig rule, int timer) { - Probe probe = new Probe(); - controller.setListener(probe); - controller.start(source, new TtsOptions(2f, 1f, ""), rule, PARAGRAPHS, 0, timer, "朗读验收"); - return probe; - } - - private static boolean waitUntil(long timeoutMs, java.util.function.BooleanSupplier condition) { - long deadline = SystemClock.elapsedRealtime() + timeoutMs; - while (SystemClock.elapsedRealtime() < deadline) { - if (condition.getAsBoolean()) return true; - SystemClock.sleep(200); - } - return condition.getAsBoolean(); - } - - private static boolean isAudioActive() { - AudioManager manager = (AudioManager) context().getSystemService(Context.AUDIO_SERVICE); - return manager != null && manager.isMusicActive(); - } - - /** 在线引擎必须真的产出音频文件(合成成功)并且真的播放(片段下标推进)。 */ - private static void assertOnlineEngineReadsAloud(String source) { - ReaderTtsController controller = new ReaderTtsController(context()); - try { - Probe probe = startController(controller, source, null, 0); - assertTrue(source + " 未进入 playing:states=" + probe.stateHistory + " message=" + probe.message.get(), - waitUntil(LONG_WAIT_MS, () -> "playing".equals(probe.state))); - assertTrue(source + " 未上报首个朗读段落", probe.paragraph.get() >= 0); - - // 片段推进只能由「一段音频播完」触发,因此这是真实播放的证据 - boolean advanced = waitUntil(LONG_WAIT_MS, () -> probe.paragraph.get() >= 1); - assertTrue(source + " 朗读未推进(音频没有真正播放):paragraph=" + probe.paragraph.get() - + " state=" + probe.state + " message=" + probe.message.get() - + " states=" + probe.stateHistory, advanced); - - File dir = new File(TtsEngines.cacheRoot(), source); - long bytes = 0; - File[] files = dir.listFiles(); - if (files != null) { - for (File file : files) bytes += file.length(); - } - assertTrue(source + " 没有生成音频缓存(message=" + probe.message.get() - + " states=" + probe.stateHistory + " paragraph=" + probe.paragraph.get() + ")", bytes > 1000); - - // 暂停 / 继续 - controller.pause(); - assertTrue(source + " 暂停未生效", waitUntil(10_000L, () -> "paused".equals(probe.state))); - // 暂停必须真的没声音(后面用它作为「暂停中改语速不能出声」的基线) - assertTrue(source + " 暂停后仍有音频在播放", waitUntil(5_000L, () -> !isAudioActive())); - // 暂停中改语速:平台文档明确「已 prepared 的播放器用非零速度调 setPlaybackParams - // 等价于 start()」,因此这里绝不能把暂停中的朗读拖响。 - controller.setRate(1.5f); - SystemClock.sleep(2_000L); - assertTrue(source + " 暂停中改语速把音频拖响了(setPlaybackParams 非零速度等价 start())", - !isAudioActive()); - assertEquals(source + " 暂停中改语速改变了朗读状态", "paused", probe.state); - controller.resume(); - assertTrue(source + " 继续未生效", waitUntil(15_000L, () -> "playing".equals(probe.state))); - - // 下一段 - int before = probe.paragraph.get(); - controller.next(); - assertTrue(source + " 下一段未生效", waitUntil(20_000L, () -> probe.paragraph.get() > before)); - - controller.stop(null); - assertTrue(source + " 停止未生效", waitUntil(10_000L, () -> "stopped".equals(probe.state))); - } finally { - controller.release(); - } - } - - @Test - public void baiduOnlineEngineSynthesizesAndPlays() { - assertOnlineEngineReadsAloud(TtsEngines.SOURCE_BAIDU); - } - - @Test - public void edgeOnlineEngineSynthesizesAndPlays() { - // 微软 Edge 音源是本项目质量最高的免 Key 音源,且协议非公开稳定接口, - // 必须在真机上确认它当前仍可用(否则用户看到的只是「朗读失败」)。 - assertOnlineEngineReadsAloud(TtsEngines.SOURCE_EDGE); - } - - @Test - public void systemEngineFailsGracefullyWhenNoEngineInstalled() { - boolean available = TtsEngines.systemAvailable(context()); - ReaderTtsController controller = new ReaderTtsController(context()); - try { - Probe probe = startController(controller, TtsEngines.SOURCE_SYSTEM, null, 0); - if (!available) { - assertTrue("没有系统 TTS 引擎时必须上报可操作错误", - waitUntil(15_000L, () -> "error".equals(probe.state))); - String message = probe.message.get(); - assertTrue("错误信息要能指导用户改音源:" + message, - message.contains("引擎") || message.contains("安装")); - assertEquals("失败后控制器必须停止,不能空转", - "error", controller.state()); - } else { - assertTrue("有系统 TTS 引擎时应当能朗读", - waitUntil(LONG_WAIT_MS, () -> "playing".equals(probe.state))); - } - } finally { - controller.release(); - } - } - - @Test - public void timerExpiresAndStopsReadAloud() { - ReaderTtsController controller = new ReaderTtsController(context()); - try { - startController(controller, TtsEngines.SOURCE_BAIDU, null, 30); - assertEquals("定时值未下发", 30, controller.timerMinutes()); - controller.setTimer(0); - assertEquals("取消定时后不应残留定时值", 0, controller.timerMinutes()); - } finally { - controller.release(); - } - } - - /** - * 定时到点必须真的停下来(听书场景的刚需)。 - * 用一个 1 分钟定时跑满一分钟;正文取慢速长段落,保证到点时本章还没读完。 - */ - @Test - public void timerActuallyStopsPlaybackWhenItExpires() { - List longParagraphs = Arrays.asList( - "夜色还没有完全退去,山坳里的雾气沿着石阶缓慢地往上爬,像一层薄薄的纱。", - "他背着一只旧帆布包站在观景台上,听见远处传来第一声鸟叫,然后又归于安静。", - "风把松针的气味送过来,也送来了山下村庄里隐约的犬吠声和柴火的味道。", - "很多年前他也是这样站在这里,只是那时候他还不知道自己要往哪里走。", - "太阳终于越过东边的山脊,光线像水一样漫过整片山谷,把雾气染成了淡金色。", - "他深深地吸了一口气,转身沿着来时的路一步一步慢慢地往下走。"); - ReaderTtsController controller = new ReaderTtsController(context()); - try { - Probe probe = new Probe(); - controller.setListener(probe); - controller.start(TtsEngines.SOURCE_BAIDU, new TtsOptions(0.5f, 1f, ""), null, - longParagraphs, 0, 1, "定时验收"); - assertTrue("定时朗读未开始", waitUntil(60_000L, () -> "playing".equals(probe.state))); - assertTrue("定时到点后未停播(state=" + probe.state + " message=" + probe.message.get() + ")", - waitUntil(150_000L, () -> "stopped".equals(probe.state))); - assertEquals("停播原因应提示是定时结束", true, - probe.message.get().contains("定时") || probe.stateHistory.contains("playing")); - } finally { - controller.release(); - } - } - - /* ---------------- 端到端:reader.html + 桥 + 服务 ---------------- */ - - @Test - public void readerPageReadsAloudThroughTheNativeBridge() throws Exception { - String content = String.join("\n", READER_PARAGRAPHS); - String payload = "novel://" + new JSONObject() - .put("title", "第一章 朗读验收") - .put("content", content); - - ArrayList chapters = new ArrayList<>(); - chapters.add(Episode.create("第一章 朗读验收", "chapter://1")); - chapters.add(Episode.create("第二章 朗读验收", "chapter://2")); - - Intent intent = new Intent(context(), WebReaderActivity.class) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(WebReaderActivity.EXTRA_KIND, 1) - .putExtra(WebReaderActivity.EXTRA_PAYLOAD, payload) - .putExtra(WebReaderActivity.EXTRA_VOD_NAME, "朗读验收") - .putExtra(WebReaderActivity.EXTRA_INDEX, 0) - .putExtra(WebReaderActivity.EXTRA_CHAPTERS, chapters); - - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - AtomicReference holder = new AtomicReference<>(); - scenario.onActivity(activity -> holder.set(activity.findViewById(R.id.web_view))); - WebView webView = holder.get(); - assertNotNull("阅读器 WebView 未就绪", webView); - - // 页面脚本就绪 + 原生桥可用:这正是「点了没声音」的根因防线 - assertTrue("reader.html 未就绪", waitForJs(webView, - "typeof window.__injectReader === 'function' && typeof window.__onTtsState === 'function'", - LONG_WAIT_MS)); - assertEquals("原生朗读桥必须可用(不能回落到 WebView 不支持的 speechSynthesis)", - "true", evalJs(webView, "String(ttsNative())")); - - assertTrue("正文未渲染", waitForJs(webView, "novelBlocks().length >= 6", 30_000L)); - assertEquals("面板初始状态应为停止", "stopped", evalJs(webView, "ttsState")); - - // 与用户操作一致:面板选「百度」音源并点播放按钮 - evalJs(webView, "ttsSource='baidu'; putSet('novel_ttsSource','baidu'); ttsSpeed=1.5; " - + "putSet('novel_ttsSpeed','1.5'); ttsPitch=1; ttsTimer=0; ttsRefreshSource(); 'ok'"); - evalJs(webView, "document.getElementById('btnTtsPlay').click(); 'ok'"); - - assertTrue("原生朗读未进入 playing", waitForJs(webView, - "ttsState === 'playing' && ttsParagraph >= 0", LONG_WAIT_MS)); - assertTrue("跟随高亮未落在段落上", waitForJs(webView, - "document.querySelectorAll('.tts-highlight').length === 1", 20_000L)); - assertTrue("朗读未推进到下一段(音频没有真正播放)", waitForJs(webView, - "ttsParagraph >= 1", LONG_WAIT_MS)); - assertTrue("朗读必须由前台服务承载", ReaderTtsService.isRunning()); - assertTrue("朗读必须有常驻通知(后台/锁屏可控的前提)", - waitUntil(15_000L, ReaderTtsDeviceTest::hasReadAloudNotification)); - - // 切到后台(阅读页不可见)后朗读必须继续:这正是前台服务的意义 - scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED); - SystemClock.sleep(1500); - assertTrue("进入后台后朗读被中断", ReaderTtsService.isRunning()); - int backgroundParagraph = Integer.parseInt(evalJs(webView, "String(ttsParagraph)")); - assertTrue("后台朗读未继续推进(paragraph=" + backgroundParagraph + ")", - waitForJs(webView, "ttsParagraph !== " + backgroundParagraph, LONG_WAIT_MS)); - scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED); - - // 面板暂停/继续(真实按钮点击) - evalJs(webView, "document.getElementById('btnTtsPlay').click(); 'ok'"); - assertTrue("暂停未回传 UI", waitForJs(webView, "ttsState === 'paused'", 20_000L)); - evalJs(webView, "document.getElementById('btnTtsPlay').click(); 'ok'"); - assertTrue("继续未回传 UI", waitForJs(webView, "ttsState === 'playing'", 20_000L)); - - // 通知栏控制:走的是通知按钮同样的 service Intent 路径 - ReaderTtsService.dispatch(context(), ReaderTtsService.ACTION_PAUSE, 0); - assertTrue("通知栏暂停未生效", waitForJs(webView, "ttsState === 'paused'", 20_000L)); - ReaderTtsService.dispatch(context(), ReaderTtsService.ACTION_RESUME, 0); - assertTrue("通知栏继续未生效", waitForJs(webView, "ttsState === 'playing'", 20_000L)); - int beforeNext = Integer.parseInt(evalJs(webView, "String(ttsParagraph)")); - ReaderTtsService.dispatch(context(), ReaderTtsService.ACTION_NEXT, 0); - assertTrue("通知栏下一段未生效", waitForJs(webView, - "ttsParagraph > " + beforeNext, LONG_WAIT_MS)); - - // 拔耳机/音频路由丢失:广播本身是系统保护广播(应用与 shell 都发不出), - // 因此直接驱动接收器里调用的同一入口,验证「路由丢失 → 自动暂停」的行为。 - ReaderTtsService service = ReaderTtsService.get(); - assertNotNull("朗读服务实例缺失", service); - service.onAudioBecomingNoisy(); - assertTrue("拔耳机(音频路由丢失)未自动暂停", - waitForJs(webView, "ttsState === 'paused'", 20_000L)); - ReaderTtsService.dispatch(context(), ReaderTtsService.ACTION_RESUME, 0); - assertTrue("路由丢失暂停后无法继续朗读", waitForJs(webView, "ttsState === 'playing'", 20_000L)); - - // 章末续读:读完本章后阅读页应自动续读下一章(真实环境由 spider 注入新章) - String next = new JSONObject() - .put("kind", 1) - .put("title", "第二章 朗读验收") - .put("content", "第二章开始了。\n他继续往山下走。") - .toString(); - // 等本章自然读完(原生发章末事件);超时则手工触发,等价于用户等到章末 - boolean endedNaturally = waitForJs(webView, "ttsContinuePending === true", LONG_WAIT_MS); - if (!endedNaturally) { - evalJs(webView, "window.__onTtsChapterEnd && window.__onTtsChapterEnd(); 'ok'"); - } - SystemClock.sleep(1000); - evalJs(webView, "window.__updateChapter && window.__updateChapter(" + next + "); 'ok'"); - assertTrue("章末没有续读下一章:页面状态=" + evalJs(webView, - "DATA.title + '|state=' + ttsState + '|pending=' + ttsContinuePending" - + " + '|para=' + ttsParagraph + '|blocks=' + novelBlocks().length") - + " 服务=" + ReaderTtsService.isRunning(), - waitForJs(webView, - "DATA.title === '第二章 朗读验收' && ttsState === 'playing'", LONG_WAIT_MS)); - assertTrue("续读后仍应有高亮跟随", waitForJs(webView, - "document.querySelectorAll('.tts-highlight').length === 1", 30_000L)); - // 续读不是「停播」:停播时撤下的朗读通知必须随下一章重新挂上 - assertTrue("续读后朗读通知未重新挂上", - waitUntil(15_000L, ReaderTtsDeviceTest::hasReadAloudNotification)); - - evalJs(webView, "document.getElementById('btnTtsStop').click(); 'ok'"); - assertTrue("停止未回传 UI", waitForJs(webView, "ttsState === 'stopped'", 20_000L)); - assertTrue("退出朗读后服务必须停止", waitUntil(15_000L, () -> !ReaderTtsService.isRunning())); - // 停播后通知栏不能残留朗读控制条(服务销毁时也必须清掉) - assertTrue("停播后通知栏残留朗读通知", - waitUntil(15_000L, () -> !hasReadAloudNotification())); - } - } - - /** 通过系统通知服务确认朗读通知确实挂着(而不是只调了 notify 没人看)。 */ - private static boolean hasReadAloudNotification() { - try { - android.app.NotificationManager manager = - (android.app.NotificationManager) context().getSystemService(Context.NOTIFICATION_SERVICE); - if (manager == null) return false; - for (android.service.notification.StatusBarNotification item : manager.getActiveNotifications()) { - if (item.getId() == 0x7EAD && item.getPackageName().equals(context().getPackageName())) return true; - } - return false; - } catch (Throwable e) { - return false; - } - } - - /* ---------------- JS 交互辅助 ---------------- */ - - private static String evalJs(WebView view, String script) { - final AtomicReference result = new AtomicReference<>(); - final CountDownLatch latch = new CountDownLatch(1); - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> - view.evaluateJavascript(script, value -> { - result.set(value); - latch.countDown(); - })); - try { - latch.await(15, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - String value = result.get(); - if (value == null) return ""; - // evaluateJavascript 回传的是 JSON 字面量(字符串会带引号) - if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { - return value.substring(1, value.length() - 1).replace("\\\"", "\"").replace("\\/", "/"); - } - return value; - } - - private static boolean waitForJs(WebView view, String expression, long timeoutMs) { - long deadline = SystemClock.elapsedRealtime() + timeoutMs; - while (SystemClock.elapsedRealtime() < deadline) { - if ("true".equals(evalJs(view, "String(!!(" + expression + "))"))) return true; - SystemClock.sleep(400); - } - return "true".equals(evalJs(view, "String(!!(" + expression + "))")); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16KeylessSourceDetailDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16KeylessSourceDetailDeviceTest.java deleted file mode 100644 index 90bba2d8bd..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16KeylessSourceDetailDeviceTest.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.Intent; -import android.os.SystemClock; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.impl.Callback; -import com.fongmi.android.tv.setting.Setting; - -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -@RunWith(AndroidJUnit4.class) -public class C16KeylessSourceDetailDeviceTest { - - private static final String FIXTURE_URL = "http://127.0.0.1:18080/config.json"; - private String previousTmdbConfig; - private int previousDetailMode; - private String previousConfigUrl; - private int previousConfigId; - private boolean fixtureLoaded; - - @Before - public void loadKeylessFixture() throws Exception { - Assume.assumeTrue("C16 keyless fixture is not enabled", - "true".equals(InstrumentationRegistry.getArguments().getString("c16KeylessFixture"))); - previousTmdbConfig = Setting.getTmdbConfig(); - previousDetailMode = Setting.getDetailOpenMode(); - previousConfigUrl = Config.vod().getUrl(); - previousConfigId = Config.vod().getId(); - fixtureLoaded = true; - Setting.putTmdbConfig(""); - - Config fixture = Config.find(FIXTURE_URL, 0).url(FIXTURE_URL).name("C16 keyless fixture"); - reload(fixture, true); - assertFalse("fixture site did not load", VodConfig.get().getSite("c16_keyless_complete").isEmpty()); - } - - @After - public void restoreConfiguration() throws Exception { - if (!fixtureLoaded) return; - Setting.putTmdbConfig(previousTmdbConfig); - Setting.putDetailOpenMode(previousDetailMode); - if (previousConfigUrl == null || previousConfigUrl.isBlank()) return; - Config previous = Config.find(previousConfigUrl, 0); - if (previousConfigId > 0) previous.setId(previousConfigId); - reload(previous, false); - } - - @Test - public void completePayloadRendersStandaloneDetailWithoutKey() { - Setting.putDetailOpenMode(Setting.DETAIL_OPEN_ENHANCED); - assertTextEventually( - TmdbDetailActivity.class, - "c16_keyless_complete", - "C16 Keyless Complete", - List.of("Keyless Complete TMDB", "Embedded keyless overview", "Keyless Actor"), - true); - } - - @Test - public void completePayloadRendersNativeEnhancedDetailWithoutKey() { - Setting.putDetailOpenMode(Setting.DETAIL_OPEN_ORIGINAL_ENHANCED); - assertTextEventually( - VideoActivity.class, - "c16_keyless_complete", - "C16 Keyless Complete", - List.of("Keyless Complete TMDB", "Embedded keyless overview", "Keyless Actor"), - true); - } - - @Test - public void legacyPayloadFallsBackToNativeWithoutKeyPrompt() { - Setting.putDetailOpenMode(Setting.DETAIL_OPEN_ENHANCED); - assertTextEventually( - VideoActivity.class, - "c16_keyless_legacy", - "Fight Club", - List.of("Fight Club", "line-a", "正片"), - false); - } - - private static void reload(Config config, boolean required) throws Exception { - CountDownLatch latch = new CountDownLatch(1); - VodConfig.selectConfig(config.save(), new Callback() { - @Override - public void success() { - latch.countDown(); - } - - @Override - public void error(String msg) { - latch.countDown(); - } - }); - if (!latch.await(15, TimeUnit.SECONDS) && required) throw new AssertionError("fixture config timeout"); - } - - private static void assertTextEventually(Class activityClass, String key, String name, - List expectedTexts, boolean sourceTmdb) { - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, activityClass) - .putExtra("detail_mode", sourceTmdb ? Setting.getDetailOpenMode() : Setting.DETAIL_OPEN_ENHANCED) - .putExtra("fusion", false) - .putExtra("auto_play", false) - .putExtra("key", key) - .putExtra("id", key.replace('_', '-') + "-1") - .putExtra("name", name) - .putExtra("pic", "") - .putExtra("mark", ""); - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - long deadline = SystemClock.uptimeMillis() + 20_000L; - List texts = new ArrayList<>(); - while (SystemClock.uptimeMillis() < deadline) { - scenario.onActivity(activity -> { - texts.clear(); - collectText(activity.findViewById(android.R.id.content), texts); - }); - if (containsAll(texts, expectedTexts)) { - assertFalse("source-only page must not show a TMDB key prompt", contains(texts, "请先配置")); - return; - } - SystemClock.sleep(250L); - } - assertTrue("missing UI text for " + key + "; visible=" + texts, containsAll(texts, expectedTexts)); - } - } - - private static boolean containsAll(List texts, List expectedTexts) { - for (String expected : expectedTexts) if (!contains(texts, expected)) return false; - return true; - } - - private static boolean contains(List texts, String expected) { - return texts.stream().anyMatch(text -> text != null && text.contains(expected)); - } - - private static void collectText(View view, List texts) { - if (view == null || view.getVisibility() != View.VISIBLE) return; - if (view instanceof TextView textView) { - CharSequence text = textView.getText(); - if (text != null && !text.toString().isBlank()) texts.add(text.toString()); - } - if (!(view instanceof ViewGroup group)) return; - for (int i = 0; i < group.getChildCount(); i++) collectText(group.getChildAt(i), texts); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16SubscriptionCredentialDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16SubscriptionCredentialDeviceTest.java deleted file mode 100644 index 6fcb6bb015..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16SubscriptionCredentialDeviceTest.java +++ /dev/null @@ -1,230 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.Intent; -import android.os.SystemClock; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.api.SiteApi; -import com.fongmi.android.tv.api.config.SubscriptionTmdbCredentialStore; -import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.impl.Callback; -import com.fongmi.android.tv.setting.Setting; - -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.File; -import java.io.FileInputStream; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -@RunWith(AndroidJUnit4.class) -public class C16SubscriptionCredentialDeviceTest { - - private static final String FIXTURE_URL = "http://127.0.0.1:18081/config.json"; - private static final String NO_KEY_URL = "http://127.0.0.1:18081/no-key-config.json"; - private static final String INVALID_KEY_URL = "http://127.0.0.1:18081/invalid-key-config.json"; - private String previousTmdbConfig; - private int previousDetailMode; - private String previousConfigUrl; - private int previousConfigId; - private boolean fixtureLoaded; - - @Before - public void loadSubscriptionFixture() throws Exception { - Assume.assumeTrue("C16 subscription credential fixture is not enabled", - "true".equals(InstrumentationRegistry.getArguments().getString("c16SubscriptionCredentialFixture"))); - previousTmdbConfig = Setting.getTmdbConfig(); - previousDetailMode = Setting.getDetailOpenMode(); - previousConfigUrl = Config.vod().getUrl(); - previousConfigId = Config.vod().getId(); - fixtureLoaded = true; - Setting.putTmdbConfig(""); - Setting.putDetailOpenMode(Setting.DETAIL_OPEN_ENHANCED); - SubscriptionTmdbCredentialStore.clear(); - reload(Config.find(FIXTURE_URL, 0).url(FIXTURE_URL).name("C16 subscription credential fixture"), true); - assertFalse("fixture site did not load", VodConfig.get().getSite("c16_subscription_complete").isEmpty()); - assertTrue("subscription config root key was not accepted", - SubscriptionTmdbCredentialStore.snapshot(SubscriptionTmdbCredentialStore.currentScope()).isPresent()); - } - - @After - public void restoreConfiguration() throws Exception { - assertTestKeyNotPersisted(ApplicationProvider.getApplicationContext()); - SubscriptionTmdbCredentialStore.clear(); - if (!fixtureLoaded) return; - Setting.putTmdbConfig(previousTmdbConfig); - Setting.putDetailOpenMode(previousDetailMode); - if (previousConfigUrl == null || previousConfigUrl.isBlank()) return; - Config previous = Config.find(previousConfigUrl, 0); - if (previousConfigId > 0) previous.setId(previousConfigId); - reload(previous, true); - } - - @Test - public void subscriptionConfigRootKeyIsAcceptedAndRendersSourceData() throws Exception { - Result direct = SiteApi.detailContent("c16_subscription_complete", "c16-subscription-complete-1", true); - assertFalse(direct.getList().isEmpty()); - assertTrue("subscription config root key was not accepted", - SubscriptionTmdbCredentialStore.snapshot(SubscriptionTmdbCredentialStore.currentScope()).isPresent()); - assertDetailTextEventually("c16_subscription_complete", "C16 Subscription Complete", - List.of("Embedded subscription complete", "Subscription Complete Actor")); - } - - @Test - public void partialSourceUsesSubscriptionConfigRootKeyAndFallsBackWithoutBreakingUi() { - assertDetailTextEventually("c16_subscription_partial", "C16 Subscription Partial", - List.of("Source partial overview", "C16 Subscription Partial")); - } - - @Test - public void sameSubscriptionReusesKeyAndSwitchBackReacquiresFromConfig() throws Exception { - Result first = SiteApi.detailContent("c16_subscription_complete", "c16-subscription-complete-1", true); - assertFalse(first.getList().isEmpty()); - SubscriptionTmdbCredentialStore.Scope firstScope = SubscriptionTmdbCredentialStore.currentScope(); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(firstScope).isPresent()); - - reload(Config.find(FIXTURE_URL, 0).url(FIXTURE_URL).name("C16 subscription credential fixture"), true); - SubscriptionTmdbCredentialStore.Scope reloaded = SubscriptionTmdbCredentialStore.currentScope(); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(reloaded).isPresent()); - - reload(Config.find(NO_KEY_URL, 0).url(NO_KEY_URL).name("C16 no-key fixture"), true); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(SubscriptionTmdbCredentialStore.currentScope()).isEmpty()); - - reload(Config.find(FIXTURE_URL, 0).url(FIXTURE_URL).name("C16 subscription credential fixture"), true); - SubscriptionTmdbCredentialStore.Scope returned = SubscriptionTmdbCredentialStore.currentScope(); - assertNotEquals(firstScope.getEpoch(), returned.getEpoch()); - assertTrue("switching back did not reacquire the key from the new config response", - SubscriptionTmdbCredentialStore.snapshot(returned).isPresent()); - } - - @Test - public void userConfiguredCredentialWinsAndSubscriptionRootKeyIsNotStored() throws Exception { - Setting.putTmdbConfig("{\"apiKey\":\"user-key-0123456789abcdef\"}"); - SubscriptionTmdbCredentialStore.clear(); - reload(Config.find(FIXTURE_URL, 0).url(FIXTURE_URL).name("C16 subscription credential fixture"), true); - - Result result = SiteApi.detailContent("c16_subscription_complete", "c16-subscription-complete-1", true); - - assertFalse(result.getList().isEmpty()); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(SubscriptionTmdbCredentialStore.currentScope()).isEmpty()); - } - - @Test - public void invalidRootKeyDoesNotPopulateSubscriptionScope() throws Exception { - reload(Config.find(INVALID_KEY_URL, 0).url(INVALID_KEY_URL).name("C16 invalid key fixture"), true); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(SubscriptionTmdbCredentialStore.currentScope()).isEmpty()); - } - - private static void assertDetailTextEventually(String key, String name, List expectedTexts) { - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, TmdbDetailActivity.class) - .putExtra("detail_mode", Setting.DETAIL_OPEN_ENHANCED) - .putExtra("fusion", false) - .putExtra("auto_play", false) - .putExtra("key", key) - .putExtra("id", key.replace('_', '-') + "-1") - .putExtra("name", name) - .putExtra("pic", "") - .putExtra("mark", ""); - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - long deadline = SystemClock.uptimeMillis() + 20_000L; - List texts = new ArrayList<>(); - while (SystemClock.uptimeMillis() < deadline) { - scenario.onActivity(activity -> { - texts.clear(); - collectText(activity.findViewById(android.R.id.content), texts); - }); - if (containsAll(texts, expectedTexts)) { - assertFalse("temporary credential path must not show a TMDB key prompt", contains(texts, "请先配置")); - return; - } - SystemClock.sleep(250L); - } - assertTrue("missing UI text for " + key + "; visible=" + texts, containsAll(texts, expectedTexts)); - } - } - - private static void reload(Config config, boolean required) throws Exception { - CountDownLatch latch = new CountDownLatch(1); - VodConfig.selectConfig(config.save(), new Callback() { - @Override - public void success() { - latch.countDown(); - } - - @Override - public void error(String msg) { - latch.countDown(); - } - }); - if (!latch.await(15, TimeUnit.SECONDS) && required) throw new AssertionError("fixture config timeout"); - } - - private static void assertTestKeyNotPersisted(Context context) throws Exception { - List roots = new ArrayList<>(); - roots.add(context.getDataDir()); - File external = context.getExternalFilesDir(null); - if (external != null) roots.add(external); - for (File root : roots) scanForTestKey(root); - } - - private static void scanForTestKey(File file) throws Exception { - if (file == null || !file.exists()) return; - if (file.isDirectory()) { - File[] children = file.listFiles(); - if (children != null) for (File child : children) scanForTestKey(child); - return; - } - if (file.length() <= 0 || file.length() > 16L * 1024L * 1024L) return; - byte[] data = new byte[(int) file.length()]; - try (FileInputStream input = new FileInputStream(file)) { - int offset = 0; - while (offset < data.length) { - int read = input.read(data, offset, data.length - offset); - if (read < 0) break; - offset += read; - } - String text = new String(data, 0, offset, java.nio.charset.StandardCharsets.ISO_8859_1); - assertFalse("persistent test key found in " + file.getAbsolutePath(), text.contains("0123456789abcdef0123456789abcdef")); - } - } - - private static boolean containsAll(List texts, List expectedTexts) { - for (String expected : expectedTexts) if (!contains(texts, expected)) return false; - return true; - } - - private static boolean contains(List texts, String expected) { - return texts.stream().anyMatch(text -> text != null && text.contains(expected)); - } - - private static void collectText(View view, List texts) { - if (view == null || view.getVisibility() != View.VISIBLE) return; - if (view instanceof TextView textView) { - CharSequence text = textView.getText(); - if (text != null && !text.toString().isBlank()) texts.add(text.toString()); - } - if (!(view instanceof ViewGroup group)) return; - for (int i = 0; i < group.getChildCount(); i++) collectText(group.getChildAt(i), texts); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16TmdbSourceDetailDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16TmdbSourceDetailDeviceTest.java deleted file mode 100644 index 3f54e9bb13..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/C16TmdbSourceDetailDeviceTest.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.Intent; -import android.os.SystemClock; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.setting.Setting; - -import org.junit.Assume; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.ArrayList; -import java.util.List; - -@RunWith(AndroidJUnit4.class) -public class C16TmdbSourceDetailDeviceTest { - - @Before - public void loadFixtureSource() { - Assume.assumeTrue("C16 fixture source is not configured", isFixtureConfigured()); - VodConfig.get().ensureLoaded(); - long deadline = SystemClock.uptimeMillis() + 15_000L; - while (SystemClock.uptimeMillis() < deadline) { - if (!VodConfig.get().getSite("c16_t4_complete").isEmpty()) return; - SystemClock.sleep(250L); - } - assertTrue("fixture VOD config did not load", !VodConfig.get().getSite("c16_t4_complete").isEmpty()); - } - - private static boolean isFixtureConfigured() { - return "http://127.0.0.1:18080/config.json".equals(Config.vod().getUrl()); - } - - @Test - public void completeT4RendersEmbeddedCoreCreditsAndImages() { - assertDetailEventually( - "c16_t4_complete", - "c16-t4-complete-1", - "C16 Complete T4", - List.of("C16 Complete T4", "Embedded complete overview", "Embedded Actor") - ); - } - - @Test - public void partialT4KeepsSourceFieldsWhileTmdbFillsGaps() { - assertDetailEventually( - "c16_t4_partial", - "c16-t4-partial-1", - "C16 Partial T4", - List.of("C16 Partial T4", "Source partial overview must win") - ); - } - - @Test - public void legacySourceWithoutTmdbStillRendersOrdinaryDetail() { - assertDetailEventually( - "c16_legacy", - "c16-legacy-1", - "Fight Club", - List.of("Fight Club", "line-a", "正片") - ); - } - - @Test - public void completeT3RendersEmbeddedCoreCreditsAndImages() { - assertDetailEventually( - "c16_t3_complete", - "c16-t3-complete-1", - "C16 Complete T4", - List.of("C16 Complete T4", "Embedded complete overview", "Embedded Actor") - ); - } - - @Test - public void completeTvUsesEmbeddedSeasonEpisodeAndVideoCapabilities() { - assertDetailEventually( - "c16_t4_tv_complete", - "c16-t4-tv-1", - "C16 Complete TV", - List.of("C16 Complete TV", "Embedded complete TV overview", "Embedded Episode 1") - ); - } - - private static void assertDetailEventually(String key, String id, String name, List expectedTexts) { - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, TmdbDetailActivity.class) - .putExtra("detail_mode", Setting.DETAIL_OPEN_ENHANCED) - .putExtra("fusion", false) - .putExtra("auto_play", false) - .putExtra("key", key) - .putExtra("id", id) - .putExtra("name", name) - .putExtra("pic", "") - .putExtra("mark", ""); - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - long deadline = SystemClock.uptimeMillis() + 20_000L; - List texts = new ArrayList<>(); - while (SystemClock.uptimeMillis() < deadline) { - scenario.onActivity(activity -> { - texts.clear(); - collectText(activity.findViewById(android.R.id.content), texts); - }); - if (containsAll(texts, expectedTexts)) return; - SystemClock.sleep(250L); - } - assertTrue("missing UI text for " + key + "; visible=" + texts, containsAll(texts, expectedTexts)); - } - } - - private static boolean containsAll(List texts, List expectedTexts) { - for (String expected : expectedTexts) { - boolean found = texts.stream().anyMatch(text -> text != null && text.contains(expected)); - if (!found) return false; - } - return true; - } - - private static void collectText(View view, List texts) { - if (view == null || view.getVisibility() != View.VISIBLE) return; - if (view instanceof TextView textView) { - CharSequence text = textView.getText(); - if (text != null && !text.toString().isBlank()) texts.add(text.toString()); - } - if (!(view instanceof ViewGroup group)) return; - for (int i = 0; i < group.getChildCount(); i++) collectText(group.getChildAt(i), texts); - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/LiveSourceFallbackInstrumentedTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/LiveSourceFallbackInstrumentedTest.java deleted file mode 100644 index fbcf301629..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/LiveSourceFallbackInstrumentedTest.java +++ /dev/null @@ -1,234 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import android.content.Context; -import android.content.Intent; -import android.os.SystemClock; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.api.config.LiveConfig; -import com.fongmi.android.tv.bean.Live; -import com.fongmi.android.tv.setting.LiveSetting; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; - -@RunWith(AndroidJUnit4.class) -public class LiveSourceFallbackInstrumentedTest { - - @Test - public void unparsedMiddleSourceDoesNotSkipToLastBeforeParseCompletes() throws Exception { - boolean originalFallback = LiveSetting.isSourceFallback(); - List originalLives = new ArrayList<>(LiveConfig.get().getLives()); - Live originalHome = LiveConfig.get().getHome(); - LiveSetting.putSourceFallback(true); - try (PlaylistServer server = new PlaylistServer()) { - Live first = new Live("instrumented-first", server.url()); - Live middle = new Live("instrumented-middle", server.url()); - Live last = new Live("instrumented-last", server.url()); - setCustomLives(List.of(first, middle, last), first); - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, LiveActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - waitForService(scenario); - waitUntil(() -> !first.getGroups().isEmpty(), 15_000); - - scenario.onActivity(activity -> activity.setLive(middle)); - - waitUntil(() -> !middle.getGroups().isEmpty(), 15_000); - assertFalse("the selected middle source should parse successfully", middle.getGroups().isEmpty()); - assertEquals("an unparsed selected source must not be skipped before its parse completes", - 1, LiveConfig.get().getHomeIndex()); - assertEquals("instrumented-middle", LiveConfig.get().getHome().getName()); - } finally { - restore(originalLives, originalHome, originalFallback); - } - } finally { - LiveSetting.putSourceFallback(originalFallback); - } - } - - @Test - public void invalidSourceAdvancesOnlyToNextValidSource() throws Exception { - boolean originalFallback = LiveSetting.isSourceFallback(); - List originalLives = new ArrayList<>(LiveConfig.get().getLives()); - Live originalHome = LiveConfig.get().getHome(); - LiveSetting.putSourceFallback(true); - try (PlaylistServer server = new PlaylistServer()) { - Live first = new Live("instrumented-first", server.url()); - Live invalid = new Live("instrumented-invalid", server.invalidUrl()); - Live nextValid = new Live("instrumented-next", server.url()); - Live last = new Live("instrumented-last", server.url()); - setCustomLives(List.of(first, invalid, nextValid, last), first); - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, LiveActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - waitForService(scenario); - waitUntil(() -> !first.getGroups().isEmpty(), 15_000); - - scenario.onActivity(activity -> activity.setLive(invalid)); - - waitUntil(() -> LiveConfig.get().getHomeIndex() == 2 && !nextValid.getGroups().isEmpty(), 20_000); - assertEquals("an invalid source must advance only to the next valid source", - 2, LiveConfig.get().getHomeIndex()); - assertEquals("instrumented-next", LiveConfig.get().getHome().getName()); - } finally { - restore(originalLives, originalHome, originalFallback); - } - } finally { - LiveSetting.putSourceFallback(originalFallback); - } - } - - private static void waitForService(ActivityScenario scenario) throws Exception { - long deadline = SystemClock.uptimeMillis() + 15_000; - while (SystemClock.uptimeMillis() < deadline) { - AtomicBoolean ready = new AtomicBoolean(false); - scenario.onActivity(activity -> ready.set(activity.isServiceReady())); - if (ready.get()) return; - SystemClock.sleep(100); - } - fail("LiveActivity playback service did not become ready"); - } - - private static void waitUntil(Check check, long timeoutMs) throws Exception { - long deadline = SystemClock.uptimeMillis() + timeoutMs; - while (SystemClock.uptimeMillis() < deadline) { - if (check.done()) return; - SystemClock.sleep(25); - } - fail("timed out waiting for the selected live source to parse"); - } - - private static void setCustomLives(List lives, Live home) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - LiveConfig.get().getLives().clear(); - LiveConfig.get().getLives().addAll(lives); - LiveConfig.get().setHome(home); - }); - } - - private static void restore(List lives, Live home, boolean fallback) { - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - LiveConfig.get().getLives().clear(); - LiveConfig.get().getLives().addAll(lives); - LiveConfig.get().setHome(home); - LiveSetting.putSourceFallback(fallback); - }); - } - - private interface Check { - boolean done(); - } - - private static final class PlaylistServer implements AutoCloseable { - - private final ServerSocket server; - private final Thread worker; - private final ExecutorService clients = Executors.newCachedThreadPool(); - - private PlaylistServer() throws IOException { - server = new ServerSocket(0, 20, InetAddress.getByName("127.0.0.1")); - worker = new Thread(this::serve, "live-source-instrumented-server"); - worker.setDaemon(true); - worker.start(); - } - - private String url() { - return "http://127.0.0.1:" + server.getLocalPort() + "/playlist.txt"; - } - - private String invalidUrl() { - return "http://127.0.0.1:" + server.getLocalPort() + "/invalid.txt"; - } - - private void serve() { - while (!server.isClosed()) { - try { - Socket client = server.accept(); - clients.execute(() -> handle(client)); - } catch (IOException error) { - if (!server.isClosed()) error.printStackTrace(); - } - } - } - - private void handle(Socket client) { - try (client) { - client.setSoTimeout(2_000); - String path = requestPath(client); - byte[] body = playlist().getBytes(StandardCharsets.UTF_8); - int status = 200; - String type = "text/plain; charset=utf-8"; - if (path != null && path.startsWith("/invalid")) { - status = 404; - body = "not a playlist".getBytes(StandardCharsets.UTF_8); - } else if (path != null && path.contains(".m3u8")) { - type = "application/vnd.apple.mpegurl"; - body = manifest().getBytes(StandardCharsets.UTF_8); - } - OutputStream output = client.getOutputStream(); - output.write(("HTTP/1.1 " + status + (status == 200 ? " OK" : " Not Found") + "\r\n" - + "Content-Type: " + type + "\r\n" - + "Content-Length: " + body.length + "\r\nConnection: close\r\n\r\n") - .getBytes(StandardCharsets.US_ASCII)); - output.write(body); - output.flush(); - } catch (IOException error) { - if (!server.isClosed()) error.printStackTrace(); - } - } - - private String requestPath(Socket client) throws IOException { - BufferedReader reader = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.US_ASCII)); - String request = reader.readLine(); - if (request == null) return null; - String[] parts = request.split(" "); - return parts.length > 1 ? parts[1] : null; - } - - private String playlist() { - return "#EXTM3U\n" - + "#EXTINF:-1,Instrumented middle source\n" - + "http://127.0.0.1:" + server.getLocalPort() + "/live.m3u8\n"; - } - - private String manifest() { - return "#EXTM3U\n" - + "#EXT-X-VERSION:3\n" - + "#EXT-X-TARGETDURATION:1\n" - + "#EXT-X-MEDIA-SEQUENCE:0\n" - + "#EXTINF:1.0,\n" - + "http://127.0.0.1:" + server.getLocalPort() + "/segment.ts\n" - + "#EXT-X-ENDLIST\n"; - } - - @Override - public void close() throws IOException { - server.close(); - clients.shutdownNow(); - } - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayInstrumentedTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayInstrumentedTest.java deleted file mode 100644 index 15f39653ba..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayInstrumentedTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.Intent; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.setting.Setting; - -import org.junit.Test; -import org.junit.runner.RunWith; - -@RunWith(AndroidJUnit4.class) -public class TmdbDetailDirectPlayInstrumentedTest { - - @Test - public void directModeWaitsForExplicitPlayAction() { - Context context = ApplicationProvider.getApplicationContext(); - Intent intent = new Intent(context, TmdbDetailActivity.class) - .putExtra("detail_mode", Setting.DETAIL_OPEN_PLAYER) - .putExtra("fusion", false) - .putExtra("auto_play", false) - .putExtra("key", "") - .putExtra("id", "instrumented-direct-play") - .putExtra("name", "详情直放验收") - .putExtra("pic", "") - .putExtra("mark", ""); - - try (ActivityScenario scenario = ActivityScenario.launch(intent)) { - scenario.onActivity(activity -> { - assertEquals(TmdbDetailActivity.class, activity.getClass()); - assertNotNull(activity.findViewById(R.id.detailActions)); - assertEquals(android.view.View.VISIBLE, activity.findViewById(R.id.detailActions).getVisibility()); - assertNotNull(activity.findViewById(R.id.playerPanel)); - assertEquals(android.view.View.GONE, activity.findViewById(R.id.playerPanel).getVisibility()); - }); - } - } - - -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingCancelDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingCancelDeviceTest.java deleted file mode 100644 index be7f6c2af6..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingCancelDeviceTest.java +++ /dev/null @@ -1,215 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import android.app.Instrumentation; -import android.content.Context; -import android.content.Intent; -import android.view.View; -import android.widget.TextView; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingDeviceDataRule; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingStore; -import com.fongmi.android.tv.setting.Setting; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.lang.reflect.Method; - -/** - * 锁定详情页「已追更」按钮的用户可见行为(FOLLOW-DETAIL-CANCEL)。 - *

- * 背景:播放页已改为就地取消(写墓碑 + 立即刷新按钮,不跳页),但详情页此前仍然是 - * 「已追更 → 跳转追更页」。用户要求详情页补齐同一语义:点击「已追更」必须**立即生效**, - * 且**不离开当前页**。 - *

- * 本用例在真实设备上真正点击详情页的追更按钮,然后断言: - *

    - *
  1. 没有启动 {@code FollowingActivity}(没有跳页);
  2. - *
  3. 该身份已写入墓碑(取消立即落库);
  4. - *
  5. 按钮文字立即回到「加入追更」(状态立即刷新,不需要退出重进)。
  6. - *
- */ -@RunWith(AndroidJUnit4.class) -public class TmdbDetailFollowingCancelDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private static final int TMDB_ID = 993378; - private static final String IDENTITY = "tmdb:tv:" + TMDB_ID + ":s0"; - private static final String SHOW_NAME = "详情页就地取消设备用例"; - private static final long POLL_TIMEOUT_MS = 15_000L; - private static final long POLL_INTERVAL_MS = 100L; - /** 按钮文字必须连续稳定这么久才算落定(避开 updateFollowingState 的乐观中间态)。 */ - private static final long STABLE_WINDOW_MS = 1_500L; - /** 至少等这么久再开始判断稳定,给 Room 异步写 + 回主线程刷新留足时间。 */ - private static final long MIN_SETTLE_MS = 2_000L; - - private boolean previousEnabled; - - @Before - public void setUp() { - previousEnabled = FollowingSettings.isEnabled(); - FollowingSettings.setEnabled(true); - FollowingStore.purge(IDENTITY); - } - - @After - public void tearDown() { - FollowingStore.purge(IDENTITY); - FollowingSettings.setEnabled(previousEnabled); - } - - @Test - public void tappingAlreadyFollowedCancelsInPlaceWithoutLeavingThePage() throws Exception { - // 真实监测 FollowingActivity 是否被启动:命中 0 次才是“没有跳页”。 - Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); - Instrumentation.ActivityMonitor followingMonitor = - instrumentation.addMonitor(FollowingActivity.class.getName(), null, false); - try (ActivityScenario scenario = ActivityScenario.launch(detailIntent())) { - // 0) 前置:当前身份必须可追更,否则按钮是 GONE,用例会变成空断言。 - saveActive(); - refreshFollowingState(scenario); - assertEquals("活跃行必须显示已追更", "已追更", awaitSettledButtonText(scenario)); - - // 1) 真正点击详情页的追更按钮(与用户手势同一条路径)。 - clickFollowing(scenario); - - // 2) 取消必须立即生效:写入墓碑行,而不是物理删除。 - Following tombstone = awaitTombstone(); - assertNotNull("点击后必须写入墓碑行(取消立即落库)", tombstone); - assertTrue("取消必须是写墓碑而不是物理删除", tombstone.isDeleted()); - - // 3) 按钮必须立即回到「加入追更」,全程停留在当前详情页。 - assertEquals("详情页取消追更必须立即生效并就地刷新按钮", - "加入追更", awaitSettledButtonText(scenario)); - - // 4) 绝不跳转追更页:监测器命中数必须为 0,且当前页仍可交互(未被 finish)。 - assertEquals("详情页取消追更不得启动 FollowingActivity", 0, followingMonitor.getHits()); - scenario.onActivity(activity -> { - assertFalse("取消追更不得结束当前详情页", activity.isFinishing()); - assertEquals("取消追更后仍必须停留在 TmdbDetailActivity", - TmdbDetailActivity.class, activity.getClass()); - }); - } finally { - instrumentation.removeMonitor(followingMonitor); - } - } - - private Intent detailIntent() { - Context context = ApplicationProvider.getApplicationContext(); - return new Intent(context, TmdbDetailActivity.class) - .putExtra("detail_mode", Setting.DETAIL_OPEN_PLAYER) - .putExtra("fusion", false) - .putExtra("auto_play", false) - .putExtra("key", "instrumented") - .putExtra("id", "instrumented-following-cancel") - .putExtra("name", SHOW_NAME) - .putExtra("pic", "") - .putExtra("mark", "") - .putExtra("tmdb_id", TMDB_ID) - .putExtra("tmdb_media_type", "tv") - .putExtra("tmdb_title", SHOW_NAME); - } - - private void saveActive() { - Following item = FollowingDeviceDataRule.newFollowing(IDENTITY); - item.seriesKey = "tmdb:tv:" + TMDB_ID; - item.tmdbId = TMDB_ID; - item.mediaType = "tv"; - item.siteKey = "instrumented"; - item.vodId = "instrumented-following-cancel"; - item.vodName = SHOW_NAME; - item.trackedSeason = 0; - item.nextCheckAt = Long.MAX_VALUE; - FollowingStore.saveNew(item, null); - assertNotNull("活跃行必须可被 find() 查到", FollowingStore.find(IDENTITY)); - } - - private void clickFollowing(ActivityScenario scenario) { - scenario.onActivity(activity -> { - TextView button = activity.findViewById(R.id.following); - assertNotNull("详情页必须存在追更按钮", button); - assertEquals("追更按钮必须可见(该身份可追更)", View.VISIBLE, button.getVisibility()); - assertTrue("详情页追更按钮必须可点击", button.isEnabled()); - button.performClick(); - }); - } - - /** 等墓碑行落库;取消是异步写库,但必须是「立即」发生而不是等用户返回列表。 */ - private Following awaitTombstone() throws Exception { - long start = System.currentTimeMillis(); - while (System.currentTimeMillis() - start < POLL_TIMEOUT_MS) { - Following raw = FollowingStore.findAny(IDENTITY); - if (raw != null && raw.isDeleted()) return raw; - Thread.sleep(POLL_INTERVAL_MS); - } - return FollowingStore.findAny(IDENTITY); - } - - /** 触发一次追更状态刷新(与真实进入详情页走的同一条判定路径)。 */ - private void refreshFollowingState(ActivityScenario scenario) { - scenario.onActivity(activity -> { - try { - Method refresh = TmdbDetailActivity.class.getDeclaredMethod("updateFollowingState"); - refresh.setAccessible(true); - refresh.invoke(activity); - } catch (Exception e) { - throw new AssertionError("updateFollowingState 调用失败: " + e, e); - } - }); - } - - /** - * 等按钮文字落定后返回它。 - *

- * 不能"一读到期望值就返回":`updateFollowingState()` 先乐观地写成"加入追更", - * 再由 Room 异步回调覆盖为最终态。若提前返回就会读到中间态,让用例变成空断言。 - */ - private String awaitSettledButtonText(ActivityScenario scenario) throws Exception { - long start = System.currentTimeMillis(); - String current = readButtonText(scenario); - long stableSince = start; - while (System.currentTimeMillis() - start < POLL_TIMEOUT_MS) { - Thread.sleep(POLL_INTERVAL_MS); - String next = readButtonText(scenario); - long now = System.currentTimeMillis(); - if (!next.equals(current)) { - current = next; - stableSince = now; - continue; - } - if (now - start >= MIN_SETTLE_MS && now - stableSince >= STABLE_WINDOW_MS) { - return current; - } - } - return current; - } - - private String readButtonText(ActivityScenario scenario) { - String[] captured = new String[1]; - scenario.onActivity(activity -> { - TextView button = activity.findViewById(R.id.following); - assertNotNull("详情页必须存在追更按钮", button); - assertEquals("追更按钮必须可见(该身份可追更)", View.VISIBLE, button.getVisibility()); - captured[0] = button.getText().toString(); - }); - return captured[0]; - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingTombstoneDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingTombstoneDeviceTest.java deleted file mode 100644 index 52ca2a1b9b..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingTombstoneDeviceTest.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.Intent; -import android.view.View; -import android.widget.TextView; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingDeviceDataRule; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingStore; -import com.fongmi.android.tv.setting.Setting; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.lang.reflect.Method; - -/** - * 锁定「已追更」判定的用户可见行为(C37 复评修复点)。 - *

- * 背景:`FollowingStore.resolveTmdb` 对墓碑目标会**故意返回墓碑行而不是 null**(C9 防复活守卫)。 - * 若 `TmdbDetailActivity` 用 `item != null` 判定「已追更」,则用户取消追更后按钮仍显示「已追更」, - * 内联播放中再点只会重复写墓碑、永远无法重新追更。 - *

- * 本用例在真实设备上对同一个 TMDB 身份验证按钮文字:活跃行 → 「已追更」;写入墓碑后 → 「加入追更」。 - * 该用例在修复前会失败(实测 expected:<加入追更> but was:<已追更>),因此不是空断言。 - */ -@RunWith(AndroidJUnit4.class) -public class TmdbDetailFollowingTombstoneDeviceTest { - - @Rule - public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); - - private static final int TMDB_ID = 993377; - private static final String IDENTITY = "tmdb:tv:" + TMDB_ID + ":s0"; - private static final String SHOW_NAME = "墓碑判定设备用例"; - private static final long POLL_TIMEOUT_MS = 15_000L; - private static final long POLL_INTERVAL_MS = 100L; - /** 按钮文字必须连续稳定这么久才算落定(避开 updateFollowingState 的乐观中间态)。 */ - private static final long STABLE_WINDOW_MS = 1_500L; - /** 至少等这么久再开始判断稳定,给 Room 异步查询 + 主线程回调留足时间。 */ - private static final long MIN_SETTLE_MS = 2_000L; - - private boolean previousEnabled; - - @Before - public void setUp() { - previousEnabled = FollowingSettings.isEnabled(); - FollowingSettings.setEnabled(true); - FollowingStore.purge(IDENTITY); - } - - @After - public void tearDown() { - FollowingStore.purge(IDENTITY); - FollowingSettings.setEnabled(previousEnabled); - } - - @Test - public void tombstonedIdentityShowsAddFollowingInsteadOfAlreadyFollowed() throws Exception { - try (ActivityScenario scenario = ActivityScenario.launch(detailIntent())) { - // 1) 活跃行:按钮必须是「已追更」。 - saveActive(); - refreshFollowingState(scenario); - assertEquals("活跃行必须显示已追更", "已追更", awaitSettledButtonText(scenario)); - - // 2) 取消追更(写墓碑,与 cancelFollowing 的实际落库效果一致)。 - FollowingStore.delete(IDENTITY); - assertNotNull(FollowingStore.findAny(IDENTITY)); - assertTrue("删除必须写墓碑而不是物理删除", FollowingStore.findAny(IDENTITY).isDeleted()); - - // 3) 墓碑必须被判为未追更:按钮回到「加入追更」。 - refreshFollowingState(scenario); - assertEquals("墓碑行必须视为未追更,否则用户永远无法重新追更", - "加入追更", awaitSettledButtonText(scenario)); - } - } - - private Intent detailIntent() { - Context context = ApplicationProvider.getApplicationContext(); - return new Intent(context, TmdbDetailActivity.class) - .putExtra("detail_mode", Setting.DETAIL_OPEN_PLAYER) - .putExtra("fusion", false) - .putExtra("auto_play", false) - .putExtra("key", "instrumented") - .putExtra("id", "instrumented-following") - .putExtra("name", SHOW_NAME) - .putExtra("pic", "") - .putExtra("mark", "") - .putExtra("tmdb_id", TMDB_ID) - .putExtra("tmdb_media_type", "tv") - .putExtra("tmdb_title", SHOW_NAME); - } - - private void saveActive() { - Following item = FollowingDeviceDataRule.newFollowing(IDENTITY); - item.seriesKey = "tmdb:tv:" + TMDB_ID; - item.tmdbId = TMDB_ID; - item.mediaType = "tv"; - item.siteKey = "instrumented"; - item.vodId = "instrumented-following"; - item.vodName = SHOW_NAME; - item.trackedSeason = 0; - item.nextCheckAt = Long.MAX_VALUE; - FollowingStore.saveNew(item, null); - assertNotNull("活跃行必须可被 find() 查到", FollowingStore.find(IDENTITY)); - } - - /** 触发一次追更状态刷新(与真实进入详情页走的同一条判定路径)。 */ - private void refreshFollowingState(ActivityScenario scenario) { - scenario.onActivity(activity -> { - try { - Method refresh = TmdbDetailActivity.class.getDeclaredMethod("updateFollowingState"); - refresh.setAccessible(true); - refresh.invoke(activity); - } catch (Exception e) { - throw new AssertionError("updateFollowingState 调用失败: " + e, e); - } - }); - } - - /** - * 等按钮文字落定后返回它。 - *

- * 不能"一读到期望值就返回":`updateFollowingState()` 先乐观地写成"加入追更", - * 再由 Room 异步回调覆盖为最终态。若提前返回就会读到中间态,让用例变成空断言 - * (实测:对未修复代码也会误判通过)。因此要求文字连续 {@link #STABLE_WINDOW_MS} 不变, - * 且总等待不少于 {@link #MIN_SETTLE_MS},才认为已落定。 - */ - private String awaitSettledButtonText(ActivityScenario scenario) throws Exception { - long start = System.currentTimeMillis(); - String current = readButtonText(scenario); - long stableSince = start; - while (System.currentTimeMillis() - start < POLL_TIMEOUT_MS) { - Thread.sleep(POLL_INTERVAL_MS); - String next = readButtonText(scenario); - long now = System.currentTimeMillis(); - if (!next.equals(current)) { - current = next; - stableSince = now; - continue; - } - if (now - start >= MIN_SETTLE_MS && now - stableSince >= STABLE_WINDOW_MS) { - return current; - } - } - return current; - } - - private String readButtonText(ActivityScenario scenario) { - String[] captured = new String[1]; - scenario.onActivity(activity -> { - TextView button = activity.findViewById(R.id.following); - assertNotNull("详情页必须存在追更按钮", button); - assertEquals("追更按钮必须可见(该身份可追更)", View.VISIBLE, button.getVisibility()); - captured[0] = button.getText().toString(); - }); - return captured[0]; - } -} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialogLayoutTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialogLayoutTest.java deleted file mode 100644 index 0e9904f080..0000000000 --- a/app/src/androidTest/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialogLayoutTest.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.content.Context; -import android.content.res.Configuration; -import android.text.Layout; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.cache.CacheAvailability; -import com.fongmi.android.tv.cache.CacheMeasurement; -import com.fongmi.android.tv.cache.CacheModuleId; -import com.fongmi.android.tv.cache.CacheSnapshot; -import com.fongmi.android.tv.databinding.DialogCacheManagementBinding; -import com.fongmi.android.tv.utils.Util; - -import org.junit.Test; - -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.Assert.*; -import static org.junit.Assume.assumeTrue; - -/** Measures the real dialog views without starting scans, changing policies, or deleting cache. */ -public class CacheManagementDialogLayoutTest { - - @Test - public void testNarrowChinesePortrait() { - verifyLayout(320, 640, 1f, Locale.SIMPLIFIED_CHINESE); - } - - @Test - public void testLargeFontChinesePortrait() { - verifyLayout(360, 800, 1.3f, Locale.SIMPLIFIED_CHINESE); - } - - @Test - public void testLargestFontNarrowPortrait() { - verifyLayout(320, 640, 2f, Locale.SIMPLIFIED_CHINESE); - } - - @Test - public void testEnglishLandscape() { - verifyLayout(640, 360, 1f, Locale.ENGLISH); - } - - private void verifyLayout(int screenWidthDp, int screenHeightDp, float fontScale, Locale locale) { - assumeTrue("Mobile-only layout regression", Util.isMobile()); - runOnMainThread(() -> { - Context target = InstrumentationRegistry.getInstrumentation().getTargetContext(); - Configuration configuration = new Configuration(target.getResources().getConfiguration()); - configuration.screenWidthDp = screenWidthDp; - configuration.screenHeightDp = screenHeightDp; - configuration.smallestScreenWidthDp = Math.min(screenWidthDp, screenHeightDp); - configuration.orientation = screenWidthDp > screenHeightDp - ? Configuration.ORIENTATION_LANDSCAPE : Configuration.ORIENTATION_PORTRAIT; - configuration.fontScale = fontScale; - configuration.setLocale(locale); - Context context = new ContextThemeWrapper(target.createConfigurationContext(configuration), R.style.Theme_App); - DialogCacheManagementBinding binding = DialogCacheManagementBinding.inflate(LayoutInflater.from(context)); - CacheManagementDialog dialog = new CacheManagementDialog() { - @Override - public Context getContext() { - return context; - } - }; - try { - Field field = CacheManagementDialog.class.getDeclaredField("binding"); - field.setAccessible(true); - field.set(dialog, binding); - List modules = new ArrayList<>(); - for (CacheModuleId id : CacheModuleId.values()) { - modules.add(new CacheMeasurement(id, 37L * 1024 * 1024, 203, - 1791198720000L, 1791198720000L, CacheAvailability.AVAILABLE, List.of())); - } - Method render = CacheManagementDialog.class.getDeclaredMethod("renderModules", CacheSnapshot.class); - render.setAccessible(true); - render.invoke(dialog, new CacheSnapshot(0, 87L * 1024 * 1024, - 881L * 1024 * 1024, 0, 0, modules, List.of())); - dialog.initEvent(); - } catch (ReflectiveOperationException error) { - throw new AssertionError(error); - } - binding.summary.setText(context.getString(R.string.cache_management_summary_with_quota, "87.6 MB", "881.9 MB")); - binding.status.setText(context.getString(R.string.cache_cleanup_done, "163.7 MB", 1)); - // Include the third footer action: it must remain readable while cleanup is running. - binding.cancel.setVisibility(View.VISIBLE); - float density = context.getResources().getDisplayMetrics().density; - int width = Math.round(screenWidthDp * 0.9f * density); - int height = Math.round(screenHeightDp * 0.9f * density); - View root = binding.getRoot(); - root.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)); - root.layout(0, 0, width, height); - - assertEquals(CacheModuleId.values().length, binding.modules.getChildCount()); - for (int index = 0; index < binding.modules.getChildCount(); index++) { - ViewGroup row = (ViewGroup) binding.modules.getChildAt(index); - View detail = row.getChildAt(0); - assertEquals("Module details must get the full row width, not leftover button space", - row.getWidth() - row.getPaddingLeft() - row.getPaddingRight(), detail.getWidth()); - } - assertVisibleTextFits(root); - for (View action : new View[]{binding.autoCleanup, binding.retention, binding.totalLimit, - binding.cleanupLight, binding.cleanupStandard, binding.cleanupDeep, - binding.cancel, binding.refresh, binding.close}) { - assertTrue("Action needs a readable touch target", action.getWidth() >= 48 * density - 1); - assertTrue("Action needs a readable touch target", action.getHeight() >= 48 * density - 1); - assertTrue("Existing action must stay clickable", action.hasOnClickListeners()); - } - }); - } - - private void runOnMainThread(Runnable check) { - AtomicReference failure = new AtomicReference<>(); - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - try { - check.run(); - } catch (Throwable error) { - failure.set(error); - } - }); - if (failure.get() != null) throw new AssertionError(failure.get()); - } - - private void assertVisibleTextFits(View view) { - if (view.getVisibility() == View.GONE) return; - ViewGroup parent = view.getParent() instanceof ViewGroup group ? group : null; - if (parent != null) { - assertTrue("Child extends beyond the left edge", view.getLeft() >= parent.getPaddingLeft()); - assertTrue("Child extends beyond the right edge", view.getRight() <= parent.getWidth() - parent.getPaddingRight()); - } - if (view instanceof TextView text && text.length() > 0) { - String message = "Clipped label: " + text.getText(); - Layout layout = text.getLayout(); - assertNotNull(message, layout); - assertTrue(message, text.getHeight() - text.getCompoundPaddingTop() - text.getCompoundPaddingBottom() - >= layout.getHeight()); - assertEquals(message, text.length(), layout.getLineEnd(layout.getLineCount() - 1)); - for (int line = 0; line < layout.getLineCount(); line++) { - assertEquals(message, 0, layout.getEllipsisCount(line)); - assertTrue(message, layout.getLineMax(line) - <= text.getWidth() - text.getCompoundPaddingLeft() - text.getCompoundPaddingRight() + 1); - } - } - if (view instanceof ViewGroup group) { - for (int index = 0; index < group.getChildCount(); index++) { - assertVisibleTextFits(group.getChildAt(index)); - } - } - } -} diff --git a/app/src/androidTestMobile/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialogDeviceTest.java b/app/src/androidTestMobile/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialogDeviceTest.java deleted file mode 100644 index 0db08f8e97..0000000000 --- a/app/src/androidTestMobile/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialogDeviceTest.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Instrumentation; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.res.TypedArray; -import android.view.ActionMode; -import android.view.ContextThemeWrapper; -import android.view.Menu; -import android.view.MenuItem; -import android.view.inputmethod.EditorInfo; -import android.widget.EditText; - -import androidx.appcompat.app.AlertDialog; -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.setting.DanmakuSetting; -import com.fongmi.android.tv.theme.TmdbScrimHostActivity; -import com.fongmi.android.tv.ui.fragment.SettingDanmakuFragment; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -/** Exercises the real mobile dialog and its system text-action surfaces on a device. */ -@RunWith(AndroidJUnit4.class) -public class DanmakuApiDialogDeviceTest { - - private static final String URL = "https://example.com/danmaku?key=clipboard"; - private final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); - private ActivityScenario scenario; - private SettingDanmakuFragment settings; - private DanmakuApiDialog fragment; - private AlertDialog dialog; - private EditText input; - private ClipboardManager clipboard; - private ClipData originalClip; - private String originalApi; - - @Before - public void setUp() { - originalApi = DanmakuSetting.getApiUrl(); - scenario = ActivityScenario.launch(TmdbScrimHostActivity.class); - scenario.onActivity(activity -> { - clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE); - originalClip = clipboard.getPrimaryClip(); - settings = SettingDanmakuFragment.newInstance(); - activity.getSupportFragmentManager().beginTransaction() - .replace(android.R.id.content, settings).commitNow(); - openDialog(); - }); - instrumentation.waitForIdleSync(); - } - - @After - public void tearDown() { - if (scenario != null) { - scenario.onActivity(activity -> { - if (fragment != null) fragment.dismissAllowingStateLoss(); - DanmakuSetting.putApiUrl(originalApi); - if (originalClip != null) clipboard.setPrimaryClip(originalClip); - else clipboard.clearPrimaryClip(); - }); - scenario.close(); - } - } - - @Test - public void floatingToolbarContextDoesNotInheritPanelTint() { - scenario.onActivity(activity -> { - // Android 14 LocalFloatingToolbarPopup applies a DeviceDefault theme on top - // of the dialog context. AppCompat-only attributes survive that overlay. - for (int theme : new int[]{android.R.style.Theme_DeviceDefault_Light, - android.R.style.Theme_DeviceDefault}) { - Context floatingContext = new ContextThemeWrapper(dialog.getContext(), theme); - TypedArray tint = floatingContext.obtainStyledAttributes( - new int[]{androidx.appcompat.R.attr.backgroundTint}); - try { - assertFalse("panel backgroundTint leaked into system text actions", tint.hasValue(0)); - } finally { - tint.recycle(); - } - } - TypedArray panel = dialog.getContext().obtainStyledAttributes( - new int[]{androidx.appcompat.R.attr.alertDialogStyle}); - try { - assertEquals("rounded dialog appearance is still selected", - R.style.MaterialAlertDialog_WebHTV_Rounded, panel.getResourceId(0, 0)); - } finally { - panel.recycle(); - } - }); - } - - @Test - public void typingFloatingToolbarCopyCutPasteAndPositiveSaveKeepWorking() { - scenario.onActivity(activity -> { - input.setText(""); - input.requestFocus(); - }); - instrumentation.waitForIdleSync(); - instrumentation.sendStringSync(URL); - instrumentation.waitForIdleSync(); - scenario.onActivity(activity -> assertEquals(URL, input.getText().toString())); - - scenario.onActivity(activity -> { - input.selectAll(); - assertTrue(input.onTextContextMenuItem(android.R.id.copy)); - assertEquals(URL, clipboard.getPrimaryClip().getItemAt(0).getText().toString()); - assertTrue(input.onTextContextMenuItem(android.R.id.cut)); - assertEquals("", input.getText().toString()); - assertTrue(input.onTextContextMenuItem(android.R.id.paste)); - assertEquals(URL, input.getText().toString()); - }); - instrumentation.waitForIdleSync(); - - scenario.onActivity(activity -> { - input.selectAll(); - ActionMode mode = input.startActionMode(new OverflowActionModeCallback(), ActionMode.TYPE_FLOATING); - assertNotNull("floating text toolbar must be creatable without crashing", mode); - mode.finish(); - input.getText().insert(0, " "); - input.getText().append(" "); - assertEquals("field must still hold the URL before saving", - " " + URL + " ", input.getText().toString()); - // AlertController dispatches button clicks through a Handler, so the - // listener runs on a later looper pass; assert only after that drains. - dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick(); - }); - instrumentation.waitForIdleSync(); - scenario.onActivity(activity -> assertEquals( - "save must retain the exact URL and trim surrounding whitespace", - URL, DanmakuSetting.getApiUrl())); - instrumentation.waitForIdleSync(); - scenario.onActivity(activity -> { - openDialog(); - assertEquals("saved API must survive closing and reopening the dialog", - URL, input.getText().toString()); - }); - } - - @Test - public void cancelDoesNotSaveAndImeDoneStillSaves() { - scenario.onActivity(activity -> { - input.setText(URL); - dialog.getButton(AlertDialog.BUTTON_NEGATIVE).performClick(); - }); - instrumentation.waitForIdleSync(); - scenario.onActivity(activity -> assertEquals("cancel must not persist the draft URL", - originalApi, DanmakuSetting.getApiUrl())); - scenario.onActivity(activity -> { - openDialog(); - input.setText(URL); - input.onEditorAction(EditorInfo.IME_ACTION_DONE); - assertEquals(URL, DanmakuSetting.getApiUrl()); - }); - } - - private static final class OverflowActionModeCallback implements ActionMode.Callback { - @Override - public boolean onCreateActionMode(ActionMode mode, Menu menu) { - for (int i = 0; i < 20; i++) menu.add("Action " + i); - return true; - } - - @Override - public boolean onPrepareActionMode(ActionMode mode, Menu menu) { - return false; - } - - @Override - public boolean onActionItemClicked(ActionMode mode, MenuItem item) { - return true; - } - - @Override - public void onDestroyActionMode(ActionMode mode) { - } - } - - private void openDialog() { - DanmakuApiDialog.show(settings); - settings.getChildFragmentManager().executePendingTransactions(); - fragment = (DanmakuApiDialog) settings.getChildFragmentManager().getFragments().get(0); - dialog = (AlertDialog) fragment.requireDialog(); - input = dialog.findViewById(R.id.text); - assertNotNull(input); - } -} diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmpv.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmpv.so index c1b96a1e7c..09e09f02b4 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmpv.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmpv.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvcodec.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvcodec.so index f334ce034f..38e284dda1 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvcodec.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvcodec.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvdevice.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvdevice.so index 79aa34ad40..475d6f7a9e 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvdevice.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvdevice.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvfilter.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvfilter.so index 3ec1196218..1262031b63 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvfilter.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvfilter.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvformat.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvformat.so index 282693cdc5..d388099682 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvformat.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvformat.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvutil.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvutil.so index 90526ff735..868940bfe7 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvutil.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmvutil.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwresample.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwresample.so index 90ba1dbede..c51053d719 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwresample.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwresample.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwscale.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwscale.so index 395a8b8908..94d8f97bc9 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwscale.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libmwscale.so differ diff --git a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libplayer.so b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libplayer.so index 022b663712..4a047ea474 100644 Binary files a/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libplayer.so and b/app/src/arm64_v8a/assets/mpv-libs/arm64-v8a/libplayer.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmpv.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmpv.so index fc42ead3c1..227aff007a 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmpv.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmpv.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvcodec.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvcodec.so index 00bfc080c4..732146e39b 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvcodec.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvcodec.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvdevice.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvdevice.so index 368292cbb6..683a3919a3 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvdevice.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvdevice.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvfilter.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvfilter.so index a3aac78d39..0f30041a68 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvfilter.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvfilter.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvformat.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvformat.so index c1ad398321..52f5bd207c 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvformat.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvformat.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvutil.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvutil.so index c3a06f4e45..c66e433eec 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvutil.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmvutil.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwresample.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwresample.so index 80976a0502..1ece082fa5 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwresample.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwresample.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwscale.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwscale.so index 16b2e6cc13..0100c8ea3f 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwscale.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libmwscale.so differ diff --git a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libplayer.so b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libplayer.so index 861492d7de..e99bbbd363 100644 Binary files a/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libplayer.so and b/app/src/armeabi_v7a/assets/mpv-libs/armeabi-v7a/libplayer.so differ diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 47074a6530..0000000000 --- a/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/app/src/debug/assets/exo-ass/FIXTURE-NOTICES.txt b/app/src/debug/assets/exo-ass/FIXTURE-NOTICES.txt deleted file mode 100644 index 82634a2016..0000000000 --- a/app/src/debug/assets/exo-ass/FIXTURE-NOTICES.txt +++ /dev/null @@ -1,20 +0,0 @@ -sample-fonts.mkv contains a locally generated test pattern and tone (AVC + stereo PCM), -and Aileron-Regular.otf from libass/libass-tests at -10edd9ecd8054f2c4678d36b379d8372d0e573c6. -The font's name-table copyright is "No Rights Reserved". Its identity and source URL -are recorded in app/src/androidTest/assets/exo-ass/official/sources.json. -The PCM track avoids the connected device's existing AAC direct-output initialization -failure without changing production audio settings. animated.ass requests Aileron by -its internal family; its CJK line also exercises system fallback. -These generated media/font fixtures are included only in debug/test APKs. - -sample-embedded.mkv copies the first 8 seconds of sample-fonts.mkv and its Aileron -attachment, with embedded.ass muxed as a default Chinese ASS track. No external -subtitle configuration is needed. It exercises container packets, duplicate seek -preroll, and long events spanning video keyframes. -embedded.ass preserves animated.ass's four events and their layers, placing the -drawing event first so the legacy per-packet decoder's final Cue contains text. -The legacy SsaParser deliberately drops drawing-only Cues; this ordering gives -the fallback test a visible baseline without changing production parsing. -Reproduce with FFmpeg: -ffmpeg -i sample-fonts.mkv -i embedded.ass -map 0 -map 1:0 -c copy -t 8 -metadata:s:s:0 language=chi -disposition:s:0 default sample-embedded.mkv diff --git a/app/src/debug/assets/exo-ass/animated.ass b/app/src/debug/assets/exo-ass/animated.ass deleted file mode 100644 index b96df3699f..0000000000 --- a/app/src/debug/assets/exo-ass/animated.ass +++ /dev/null @@ -1,18 +0,0 @@ -[Script Info] -Title: WebHTV E4-LIBASS stage 1 fixture -ScriptType: v4.00+ -PlayResX: 960 -PlayResY: 540 -ScaledBorderAndShadow: yes -YCbCr Matrix: None - -[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Aileron,42,&H00FFFFFF,&H0000FFFF,&H00202020,&H90000000,0,0,0,0,100,100,0,0,1,2,1,2,20,20,30,1 - -[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -Dialogue: 0,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\move(100,100,800,100,0,8000)\c&H00FFFF&\t(0,8000,\frz25\fscx150)\blur1}ASS move + transform -Dialogue: 1,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\pos(480,250)\fnNoto Sans CJK SC\fs38}中文字体回退 · é · العربية -Dialogue: 2,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\pos(480,350)\kf150}Ka{\kf150}ra{\kf150}o{\kf150}ke {\kf300}timing -Dialogue: 3,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an7\pos(50,440)\p1\1c&H80FF80&\3c&HFFFFFF&\bord2}m 0 0 l 100 0 100 30 0 30 diff --git a/app/src/debug/assets/exo-ass/embedded.ass b/app/src/debug/assets/exo-ass/embedded.ass deleted file mode 100644 index 425bcf2846..0000000000 --- a/app/src/debug/assets/exo-ass/embedded.ass +++ /dev/null @@ -1,18 +0,0 @@ -[Script Info] -Title: WebHTV E4-LIBASS container fixture -ScriptType: v4.00+ -PlayResX: 960 -PlayResY: 540 -ScaledBorderAndShadow: yes -YCbCr Matrix: None - -[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Aileron,42,&H00FFFFFF,&H0000FFFF,&H00202020,&H90000000,0,0,0,0,100,100,0,0,1,2,1,2,20,20,30,1 - -[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -Dialogue: 3,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an7\pos(50,440)\p1\1c&H80FF80&\3c&HFFFFFF&\bord2}m 0 0 l 100 0 100 30 0 30 -Dialogue: 0,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\move(100,100,800,100,0,8000)\c&H00FFFF&\t(0,8000,\frz25\fscx150)\blur1}ASS move + transform -Dialogue: 1,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\pos(480,250)\fnNoto Sans CJK SC\fs38}中文字体回退 · é · العربية -Dialogue: 2,0:00:00.00,0:01:00.00,Default,,0,0,0,,{\an5\pos(480,350)\kf150}Ka{\kf150}ra{\kf150}o{\kf150}ke {\kf300}timing diff --git a/app/src/debug/assets/exo-ass/sample-embedded.mkv b/app/src/debug/assets/exo-ass/sample-embedded.mkv deleted file mode 100644 index d10e372479..0000000000 Binary files a/app/src/debug/assets/exo-ass/sample-embedded.mkv and /dev/null differ diff --git a/app/src/debug/assets/exo-ass/sample-fonts.mkv b/app/src/debug/assets/exo-ass/sample-fonts.mkv deleted file mode 100644 index 4448732e74..0000000000 Binary files a/app/src/debug/assets/exo-ass/sample-fonts.mkv and /dev/null differ diff --git a/app/src/debug/java/com/fongmi/android/tv/player/exo/ass/AssPrototypeActivity.java b/app/src/debug/java/com/fongmi/android/tv/player/exo/ass/AssPrototypeActivity.java deleted file mode 100644 index 5b0cc373be..0000000000 --- a/app/src/debug/java/com/fongmi/android/tv/player/exo/ass/AssPrototypeActivity.java +++ /dev/null @@ -1,242 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; -import android.os.Debug; -import android.os.Handler; -import android.os.SystemClock; -import android.view.View; -import android.view.WindowManager; -import android.widget.Button; -import android.widget.LinearLayout; - -import androidx.media3.common.MediaMetadata; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.PlaybackException; -import androidx.media3.common.Player; -import androidx.media3.exoplayer.DecoderCounters; -import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.exoplayer.analytics.AnalyticsListener; -import androidx.media3.ui.PlayerView; - -import com.fongmi.android.tv.bean.Sub; -import com.fongmi.android.tv.player.engine.ExoPlayerEngine; -import com.fongmi.android.tv.player.engine.PlaySpec; -import com.fongmi.android.tv.player.engine.PlayerEngine; -import com.fongmi.android.tv.player.exo.ExoUtil; - -import org.json.JSONObject; -import java.io.File; -import java.io.FileOutputStream; -import java.nio.charset.StandardCharsets; -import java.util.Collections; - -/** Debug-only playground and reproducible device fixture. No production settings are changed. */ -public final class AssPrototypeActivity extends Activity implements Player.Listener { - public ExoPlayerEngine engine; - public ExoPlayer player; - public PlayerView view; - public ExoAssSession session; - public long startupMs = -1, seekMs = -1; - public int underruns; - private long startedNs, seekNs; - private String videoUri, assUri; - private boolean enabled; - private boolean engineReleased, closing; - private String run; - private PlaySpec spec; - private PlaybackException error; - - @Override protected void onCreate(Bundle state) { - super.onCreate(state); - getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); - LinearLayout root = new LinearLayout(this); - root.setOrientation(LinearLayout.VERTICAL); - LinearLayout controls = new LinearLayout(this); - for (String[] action : new String[][]{{"暂停/播放", "pause_toggle"}, {"回到 2 秒", "seek"}, - {"延迟 +500ms", "delay"}, {"原样/兼容", "toggle"}, {"重建字幕层", "reattach"}}) { - Button button = new Button(this); - button.setText(action[0]); - button.setOnClickListener(v -> command(action[1], null)); - controls.addView(button, new LinearLayout.LayoutParams(0, -2, 1)); - } - root.addView(controls); - view = new PlayerView(this); - ExoUtil.setPlayerView(view); - view.setRender(0); - view.setUseController(false); - root.addView(view, new LinearLayout.LayoutParams(-1, 0, 1)); - setContentView(root); - open(getIntent()); - } - - private void open(Intent intent) { - enabled = intent.getBooleanExtra("enabled", true); - run = intent.getStringExtra("run"); - videoUri = intent.getStringExtra("video"); - assUri = intent.getStringExtra("subtitle"); - boolean embedded = intent.getBooleanExtra("embedded", false); - if (videoUri == null) videoUri = "asset:///exo-ass/" + (embedded ? "sample-embedded.mkv" : "sample-fonts.mkv"); - if (assUri == null) assUri = "asset:///exo-ass/animated.ass"; - engine = new ExoPlayerEngine(PlayerEngine.HARD, this); - bindPlayer(); - spec = PlaySpec.from("E4-LIBASS-prototype", videoUri, Collections.emptyMap(), MediaMetadata.EMPTY); - if (!embedded && !intent.getBooleanExtra("no_subtitle", false)) - spec.setSub(Sub.create("ASS prototype", assUri, "zh", MimeTypes.TEXT_SSA)); - startedNs = System.nanoTime(); - engine.start(spec); - } - - private void bindPlayer() { - player = (ExoPlayer) engine.getPlayer(); - player.addAnalyticsListener(new AnalyticsListener() { - @Override public void onAudioUnderrun(EventTime time, int size, long durationMs, long elapsedMs) { underruns++; } - }); - session = engine.getAssSession(); - if (session != null) { - session.setEnabled(enabled); - session.attach(view); - } - view.setPlayer(player); - } - - @Override protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - command(intent.getStringExtra("command"), intent); - } - - public void command(String command, Intent intent) { - if (command == null || engine == null) return; - switch (command) { - case "pause_toggle" -> player.setPlayWhenReady(!player.getPlayWhenReady()); - case "pause" -> player.pause(); - case "play" -> player.play(); - case "seek" -> { - seekNs = System.nanoTime(); - player.seekTo(intent == null ? 2000 : intent.getLongExtra("position", 2000)); - } - case "delay" -> player.setTextOffsetMs(intent == null ? 500 : intent.getLongExtra("offset", 500)); - case "toggle" -> { enabled = !enabled; if (session != null) session.setEnabled(enabled); } - case "fault" -> { if (session != null) session.injectFailure("surface"); } - case "detach" -> { if (session != null) session.detach(); } - case "attach" -> { if (session != null) session.attach(view); } - case "reattach" -> { if (session != null) { session.detach(); session.attach(view); } } - case "rebuild" -> { - long position = player.getCurrentPosition(); - view.setPlayer(null); - engine.rebuild(this); - bindPlayer(); - engine.start(spec, position, true); - } - case "subtitle_off" -> player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon() - .setTrackTypeDisabled(androidx.media3.common.C.TRACK_TYPE_TEXT, true).build()); - case "subtitle_on" -> player.setTrackSelectionParameters(player.getTrackSelectionParameters().buildUpon() - .setTrackTypeDisabled(androidx.media3.common.C.TRACK_TYPE_TEXT, false).build()); - case "texture" -> { if (session != null) session.detach(); view.setRender(1); if (session != null) session.attach(view); } - case "surface" -> { if (session != null) session.detach(); view.setRender(0); if (session != null) session.attach(view); } - case "dump" -> dumpMetrics(); - case "close" -> closeAfterRelease(); - default -> { } - } - } - - @Override public void onRenderedFirstFrame() { - if (startupMs < 0) startupMs = (System.nanoTime() - startedNs) / 1_000_000; - if (seekNs != 0) { seekMs = (System.nanoTime() - seekNs) / 1_000_000; seekNs = 0; } - } - - @Override public void onPlayerError(PlaybackException error) { this.error = error; } - - public JSONObject metrics() { - try { - JSONObject out = new JSONObject(); - out.put("run", run); out.put("enabled", enabled); - out.put("startupMs", startupMs); out.put("seekMs", seekMs); out.put("underruns", underruns); - out.put("positionMs", player.getCurrentPosition()); out.put("playing", player.isPlaying()); - Format video = player.getVideoFormat(); - if (video != null) { - out.put("videoMime", video.sampleMimeType); - out.put("videoWidth", video.width); out.put("videoHeight", video.height); - out.put("videoCodecs", video.codecs); - out.put("assSdrRgb", AssVideoPolicy.usesSdrRgb(video)); - if (video.colorInfo != null) { - out.put("videoColorSpace", video.colorInfo.colorSpace); - out.put("videoColorTransfer", video.colorInfo.colorTransfer); - out.put("videoColorRange", video.colorInfo.colorRange); - } - } - out.put("error", error == null ? "" : error.errorCode); - if (error != null) { - out.put("errorMessage", error.getMessage()); - out.put("errorCause", String.valueOf(error.getCause())); - } - DecoderCounters counters = player.getVideoDecoderCounters(); - if (counters != null) { - counters.ensureUpdated(); - out.put("renderedFrames", counters.renderedOutputBufferCount); - out.put("droppedFrames", counters.droppedBufferCount); - out.put("maxConsecutiveDropped", counters.maxConsecutiveDroppedBufferCount); - } - Debug.MemoryInfo memory = new Debug.MemoryInfo(); - Debug.getMemoryInfo(memory); - out.put("pssKb", memory.getTotalPss()); out.put("nativePssKb", memory.nativePss); - out.put("graphicsKb", memory.getMemoryStat("summary.graphics")); - if (session != null) { - ExoAssSession.Diagnostics d = session.diagnostics(); - out.put("assState", d.state()); out.put("failure", d.failure()); out.put("assFrames", d.frames()); - out.put("renderP95Us", d.renderP95Us()); out.put("uploadP95Us", d.uploadP95Us()); - out.put("swapP95Us", d.swapP95Us()); out.put("renderMaxUs", d.maxRenderUs()); - out.put("scriptCount", d.scripts()); out.put("staleFrames", d.staleFrames()); - out.put("assTimeMs", d.timeMs()); out.put("nativeAlive", d.nativeAlive()); - out.put("releaseComplete", d.releaseComplete()); out.put("workerAlive", d.workerAlive()); - out.put("workerTid", d.workerTid()); - out.put("generation", d.generation()); out.put("surfaceEpoch", d.surfaceEpoch()); - out.put("fontCount", d.fontCount()); out.put("fontBytes", d.fontBytes()); - out.put("packetCount", d.packetCount()); out.put("packetBytes", d.packetBytes()); - } - out.put("compatibleVisible", view.getSubtitleView().getVisibility() == View.VISIBLE); - out.put("cueCount", player.getCurrentCues().cues.size()); - return out; - } catch (Exception exception) { throw new IllegalStateException(exception); } - } - - private void dumpMetrics() { - try (FileOutputStream output = new FileOutputStream(new File(getFilesDir(), "exo-ass-metrics.jsonl"), true)) { - output.write((metrics() + "\n").getBytes(StandardCharsets.UTF_8)); - } catch (Exception exception) { throw new IllegalStateException(exception); } - } - - private void releaseEngine() { - if (engineReleased) return; - engineReleased = true; - view.setPlayer(null); - if (engine != null) engine.release(); - } - - private void closeAfterRelease() { - if (closing) return; - closing = true; - dumpMetrics(); - releaseEngine(); - Handler handler = new Handler(getMainLooper()); - long deadline = SystemClock.uptimeMillis() + 5000; - handler.post(new Runnable() { - @Override public void run() { - ExoAssSession.Diagnostics d = session == null ? null : session.diagnostics(); - if (d == null || d.releaseComplete() && !d.workerAlive() - || SystemClock.uptimeMillis() >= deadline) { - dumpMetrics(); - finish(); - } else handler.postDelayed(this, 25); - } - }); - } - - @Override protected void onDestroy() { - releaseEngine(); - super.onDestroy(); - } -} diff --git a/app/src/debug/java/com/fongmi/android/tv/theme/TmdbScrimHostActivity.java b/app/src/debug/java/com/fongmi/android/tv/theme/TmdbScrimHostActivity.java deleted file mode 100644 index c3042ebd8b..0000000000 --- a/app/src/debug/java/com/fongmi/android/tv/theme/TmdbScrimHostActivity.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.graphics.Color; -import android.os.Bundle; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; - -import androidx.appcompat.app.AppCompatActivity; - -/** Debug-only host with a deterministic full-screen background for scrim pixel probes. */ -public class TmdbScrimHostActivity extends AppCompatActivity { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - requestWindowFeature(Window.FEATURE_NO_TITLE); - getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); - View content = new View(this); - content.setBackgroundColor(Color.rgb(32, 96, 160)); - setContentView(content, new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - } -} diff --git a/app/src/leanback/AndroidManifest.xml b/app/src/leanback/AndroidManifest.xml index 3206b9d11f..4f0256732b 100644 --- a/app/src/leanback/AndroidManifest.xml +++ b/app/src/leanback/AndroidManifest.xml @@ -260,10 +260,6 @@ android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:screenOrientation="sensorLandscape" /> - - - { private final int resId; - private final String text; private int drawable; public static Func create(int resId) { @@ -21,7 +18,6 @@ public class Func implements Diffable { public Func(int resId) { this.resId = resId; this.setDrawable(); - this.text = resolveText(resId); } public int getResId() { @@ -33,32 +29,18 @@ public class Func implements Diffable { } public String getText() { - return text; + return ResUtil.getString(resId); } public void setDrawable() { if (resId == R.string.home_vod) this.drawable = R.drawable.ic_home_vod; else if (resId == R.string.home_live) this.drawable = R.drawable.ic_home_live; else if (resId == R.string.home_keep) this.drawable = R.drawable.ic_home_keep; - else if (resId == R.string.home_following) this.drawable = R.drawable.ic_home_following; else if (resId == R.string.home_push) this.drawable = R.drawable.ic_home_push; else if (resId == R.string.home_search) this.drawable = R.drawable.ic_home_search; else if (resId == R.string.home_setting) this.drawable = R.drawable.ic_home_setting; else if (resId == R.string.home_cast) this.drawable = R.drawable.ic_home_push; else if (resId == R.string.home_history_button) this.drawable = R.drawable.ic_setting_history; - else if (resId == R.string.home_custom_csp) this.drawable = R.drawable.ic_site_config; - } - - private static String resolveText(int resId) { - if (resId == R.string.home_following) { - int unread = FollowingPlaybackBridge.cachedUnreadCount(); - String text = ResUtil.getString(resId); - return unread > 0 ? text + " · " + unread : text; - } - if (resId != R.string.home_custom_csp) return ResUtil.getString(resId); - CustomCspSetting.Status status = CustomCspSetting.status(); - if (!status.available()) return ResUtil.getString(resId); - return ResUtil.getString(status.enabled() ? R.string.home_custom_csp_on : R.string.home_custom_csp_off); } @Override @@ -75,6 +57,6 @@ public class Func implements Diffable { @Override public boolean isSameContent(Func other) { - return equals(other) && getText().equals(other.getText()) && getDrawable() == other.getDrawable(); + return equals(other); } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/bean/HomeButton.java b/app/src/leanback/java/com/fongmi/android/tv/bean/HomeButton.java index 2997b1de96..905317d593 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/bean/HomeButton.java +++ b/app/src/leanback/java/com/fongmi/android/tv/bean/HomeButton.java @@ -6,7 +6,6 @@ import androidx.annotation.Nullable; import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.LiveConfig; -import com.fongmi.android.tv.following.FollowingSettings; import com.fongmi.android.tv.impl.Diffable; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.utils.ResUtil; @@ -21,7 +20,7 @@ public class HomeButton implements Diffable { private static final String KEY_BUTTON = "home_button"; private static final String KEY_SORTED = "home_button_sorted"; - private static final String ALL = "0,1,2,3,8,4,5,6,7,9"; + private static final String ALL = "0,1,2,3,4,5,6,7"; private static List buttons; private final int id; @@ -34,12 +33,10 @@ public class HomeButton implements Diffable { buttons.add(new HomeButton(1, R.string.home_live)); buttons.add(new HomeButton(2, R.string.home_search)); buttons.add(new HomeButton(3, R.string.home_keep)); - buttons.add(new HomeButton(8, R.string.home_following)); buttons.add(new HomeButton(4, R.string.home_push)); buttons.add(new HomeButton(5, R.string.home_cast)); buttons.add(new HomeButton(6, R.string.home_history_button)); buttons.add(new HomeButton(7, R.string.home_setting)); - buttons.add(new HomeButton(9, R.string.home_custom_csp)); return buttons; } @@ -51,7 +48,6 @@ public class HomeButton implements Diffable { List items = new ArrayList<>(); for (HomeButton button : getButtons()) { if (button.getResId() == R.string.home_live && !LiveConfig.hasUrl()) continue; - if (button.getResId() == R.string.home_following && !FollowingSettings.isEnabled()) continue; items.add(button); } return items; @@ -111,11 +107,9 @@ public class HomeButton implements Diffable { ids.add("1"); ids.add("2"); ids.add("3"); - ids.add("8"); ids.add("4"); if (!Setting.isHomeHistory()) ids.add("6"); ids.add("7"); - // 站点注入(id 9)默认不开启:仍保留在 all()/sortedAll() 目录中,由用户在“个性设置 → 首页按钮”内手动启用。 return TextUtils.join(",", ids); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CastActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CastActivity.java index 253eff7e96..95301ed9fd 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CastActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CastActivity.java @@ -32,7 +32,6 @@ import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.ui.custom.CustomKeyDownVod; import com.fongmi.android.tv.ui.custom.CustomSeekView; import com.fongmi.android.tv.ui.dialog.PlayerKernelDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.ui.dialog.SubtitleDialog; import com.fongmi.android.tv.ui.dialog.TrackDialog; import com.fongmi.android.tv.utils.Clock; @@ -191,11 +190,8 @@ public class CastActivity extends PlaybackActivity implements CustomKeyDownVod.L } private void onSpeed() { - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || player().isEmpty()) return; - mBinding.control.action.speed.setText(player().setSpeed(speed)); - PlayerSetting.putDefaultSpeed(player().getSpeed()); - }); + mBinding.control.action.speed.setText(player().addSpeed()); + PlayerSetting.putDefaultSpeed(player().getSpeed()); } private void onSpeedAdd() { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java index af3a695187..ae993f644e 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java @@ -47,7 +47,6 @@ import com.fongmi.android.tv.ui.adapter.SearchAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.custom.CustomScroller; import com.fongmi.android.tv.ui.dialog.SliderNumberDialog; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.SearchPageState; @@ -58,9 +57,7 @@ import com.google.android.material.textview.MaterialTextView; import com.google.gson.reflect.TypeToken; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; public class CollectActivity extends BaseActivity implements CollectAdapter.OnClickListener, SearchAdapter.OnClickListener, CustomScroller.Callback { @@ -77,8 +74,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private CollectAdapter mCollectAdapter; private SearchAdapter mSearchAdapter; private CustomScroller mScroller; - private Runnable mSearchFocusRequest; - private int mSearchFocusGeneration; private SiteViewModel mViewModel; private RecyclerView.OnScrollListener mImageScrollListener; private List mSites; @@ -92,10 +87,8 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private final List mPendingItems = new ArrayList<>(); private Runnable mApplyCollect; private String mPendingCollectSiteKey = ""; - private String mBoundSearchSiteKey = ""; private boolean mScrolling; private boolean mLeavingForPlayback; - private final Map mSearchPositions = new HashMap<>(); public static void start(Activity activity, String keyword) { start(activity, keyword, null, null); @@ -243,7 +236,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl mBinding.collect.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { @Override public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { - if (TouchOptimizationHelper.isTouchActive(parent)) return; scheduleCollect(position, 260); } }); @@ -254,15 +246,10 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl mBinding.collectHorizontal.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { @Override public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { - if (TouchOptimizationHelper.isTouchActive(parent)) return; scheduleCollect(position, 260); } }); setSearchLayout(); - mBinding.collect.setOnKeyListener((view, keyCode, event) -> { - if (event.getAction() != KeyEvent.ACTION_DOWN || keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) return false; - return focusFirstSearchResult(); - }); mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setItemAnimator(null); mBinding.recycler.setItemViewCacheSize(count * 3); @@ -271,13 +258,9 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl mBinding.recycler.addOnScrollListener(mImageScrollListener = new RecyclerView.OnScrollListener() { @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { - saveSearchPosition(getActiveSiteKey()); if (!canLoadImage()) return; - recyclerView.post(() -> { - if (!canLoadImage()) return; - ensureSearchRows(count, 2); - preloadNextRows(count); - }); + ensureSearchRows(count, 2); + preloadNextRows(count); } @Override @@ -320,7 +303,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private void setSearchProgress(SearchProgress progress) { if (progress == null) return; - mCollectAdapter.setProgress(progress.current(), progress.total()); mSearchCompleted = progress.total() > 0 && progress.current() >= progress.total(); if (mSearchCompleted && mCollectAdapter.getItemCount() > 0) updateEmptyState(mCollectAdapter.getActivated()); } @@ -391,7 +373,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl SliderNumberDialog.show(this, R.string.search_filter_similarity, mSimilarity, 0, 100, "%", R.string.search_filter_similarity_hint, value -> { mSimilarity = value; Setting.putSearchSimilarity(value); - mSearchPositions.clear(); updateFilterControls(); applyFilters(activeSiteKey); }); @@ -464,7 +445,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private void setFilterGroup(String group) { String activeSiteKey = getActiveSiteKey(); - mSearchPositions.clear(); mFilterGroup = Objects.toString(group, ""); updateFilterControls(); applyFilters(activeSiteKey); @@ -501,11 +481,12 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl Collect all = Collect.all(); all.setSelected("all".equals(activeSiteKey)); items.add(all); + boolean fixedOrder = Setting.getSearchResultSort() == 1; for (int i = 1; i < mAllCollectItems.size(); i++) { Collect raw = mAllCollectItems.get(i); if (!matchFilter(raw.getSite())) continue; List visible = SearchResultFilter.filter(raw.getList(), getKeyword(), mSimilarity); - if (!SearchSourceVisibility.shouldShow(!visible.isEmpty())) continue; + if (!SearchSourceVisibility.shouldShow(mSimilarity, fixedOrder, !visible.isEmpty())) continue; Collect item = new Collect(raw.getSite(), visible); item.setPage(mPaging.getPage(raw.getSite().getKey())); item.setSelected(raw.getSite().getKey().equals(activeSiteKey)); @@ -589,8 +570,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private void search() { mSearchCompleted = false; - mSearchPositions.clear(); - mBoundSearchSiteKey = ""; mPaging.clear(); removeApplyCollect(); mCollectAdapter.clear(); @@ -665,8 +644,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl } private void updateRecyclerLayout() { - mSearchPositions.clear(); - mBoundSearchSiteKey = ""; int count = getCount(); GridLayoutManager layoutManager = (GridLayoutManager) mBinding.recycler.getLayoutManager(); if (layoutManager != null) { @@ -778,65 +755,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl mPendingItems.clear(); } - private void focusSearchTarget(int position) { - int generation = ++mSearchFocusGeneration; - if (mSearchFocusRequest != null) App.removeCallbacks(mSearchFocusRequest); - mSearchFocusRequest = () -> { - if (generation != mSearchFocusGeneration || isFinishing() || isDestroyed()) return; - if (!canFocusSearchResult(position)) return; - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - View target = manager == null ? null : manager.findViewByPosition(position); - if (target != null) { - alignSearchResultCard(position, target); - requestSearchResultFocus(position, generation); - } else { - scrollToSearchResult(position); - mBinding.recycler.post(() -> { - if (generation != mSearchFocusGeneration || isFinishing() || isDestroyed()) return; - View laidOutTarget = mBinding.recycler.getLayoutManager() == null ? null : mBinding.recycler.getLayoutManager().findViewByPosition(position); - if (laidOutTarget != null) { - alignSearchResultCard(position, laidOutTarget); - requestSearchResultFocus(position, generation); - } - }); - } - }; - App.post(mSearchFocusRequest); - } - - private void requestSearchResultFocus(int position, int generation) { - mBinding.recycler.post(() -> { - if (generation != mSearchFocusGeneration || isFinishing() || isDestroyed()) return; - View target = mBinding.recycler.getLayoutManager() == null ? null : mBinding.recycler.getLayoutManager().findViewByPosition(position); - if (target != null) target.requestFocus(); - }); - } - - private boolean canFocusSearchResult(int position) { - return mBinding != null && mBinding.recycler != null && mSearchAdapter != null - && position >= 0 && position < mSearchAdapter.getItemCount(); - } - - private void scrollToSearchResult(int position) { - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - if (manager instanceof GridLayoutManager layoutManager) layoutManager.scrollToPosition(position); - } - - private void alignSearchResultCard(int position, View focusedView) { - if (focusedView == null || mBinding == null || mBinding.recycler.getHeight() <= 0) return; - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - if (!(manager instanceof GridLayoutManager layoutManager)) return; - int cardHeight = focusedView.getHeight(); - if (cardHeight <= 0) return; - int spanCount = Math.max(1, layoutManager.getSpanCount()); - int rowStart = Math.max(0, position - position % spanCount); - int padding = ResUtil.dp2px(8); - int bottom = mBinding.recycler.getHeight() - mBinding.recycler.getPaddingBottom() - padding; - int offset = Math.max(mBinding.recycler.getPaddingTop(), bottom - cardHeight); - mBinding.recycler.stopScroll(); - layoutManager.scrollToPositionWithOffset(rowStart, offset); - } - private void preloadNextRows(int count) { RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); if (!(manager instanceof GridLayoutManager layoutManager)) return; @@ -844,7 +762,7 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl int last = layoutManager.findLastVisibleItemPosition(); if (first < 0 || last < 0) return; int direction = last >= first ? last + 1 : first + 1; - ensureSearchItemsLoaded(direction, count * 2); + mSearchAdapter.ensureLoaded(direction, count * 2); } private void ensureSearchRows(int count, int rows) { @@ -852,15 +770,7 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl if (!(manager instanceof GridLayoutManager layoutManager)) return; int last = layoutManager.findLastVisibleItemPosition(); if (last < 0) return; - ensureSearchItemsLoaded(last + 1, count * rows); - } - - private void ensureSearchItemsLoaded(int position, int count) { - if (mBinding.recycler.isComputingLayout()) { - mBinding.recycler.post(() -> ensureSearchItemsLoaded(position, count)); - return; - } - mSearchAdapter.ensureLoaded(position, count); + mSearchAdapter.ensureLoaded(last + 1, count * rows); } @Override @@ -880,8 +790,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl if (position == 0 && keyCode == KeyEvent.KEYCODE_DPAD_LEFT) return true; return false; } - // 横排站点的左右键交给列表导航;只有竖排站点按右键进入搜索结果。 - if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) return focusFirstSearchResult(); // 在第一项按上键,跳转到切换按钮 if (position == 0 && keyCode == KeyEvent.KEYCODE_DPAD_UP) { mBinding.searchColumn.requestFocus(); @@ -892,12 +800,10 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl private void scheduleCollect(int position, long delayMillis) { if (position < 0 || position >= mCollectAdapter.getItemCount()) return; - saveSearchPosition(getActiveSiteKey()); Collect item = mCollectAdapter.get(position); String siteKey = item.getSite().getKey(); boolean same = siteKey.equals(getActiveSiteKey()); mCollectAdapter.setSelected(position); - if (!same) mBoundSearchSiteKey = ""; restoreScroller(siteKey); mPendingItems.clear(); if (same && delayMillis > 0 && siteKey.equals(mPendingCollectSiteKey)) return; @@ -925,71 +831,14 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl } private void setSearchItemsLazy(List items) { - String siteKey = getActiveSiteKey(); - mBoundSearchSiteKey = siteKey; - SearchPosition saved = mSearchPositions.get(siteKey); - int position = saved == null ? 0 : saved.adapterPosition; - mSearchAdapter.setSource(items, Math.max(getCount() * 4, position + getCount())); + mBinding.recycler.scrollToPosition(0); + mSearchAdapter.setSource(items, getCount() * 4); mBinding.recycler.post(() -> { - if (!siteKey.equals(getActiveSiteKey())) return; - restoreSearchPosition(siteKey); ensureSearchRows(getCount(), 2); preloadNextRows(getCount()); }); } - private void saveSearchPosition(String siteKey) { - if (TextUtils.isEmpty(siteKey) || mBinding == null || mBinding.recycler == null) return; - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - if (!(manager instanceof GridLayoutManager layoutManager)) return; - int position = layoutManager.findFirstVisibleItemPosition(); - if (position == RecyclerView.NO_POSITION) return; - View first = layoutManager.findViewByPosition(position); - if (first == null) return; - int offset = first.getTop() - mBinding.recycler.getPaddingTop(); - mSearchPositions.put(siteKey, new SearchPosition(position, offset)); - } - - private void restoreSearchPosition(String siteKey) { - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - if (!(manager instanceof GridLayoutManager layoutManager)) return; - int itemCount = mSearchAdapter == null ? 0 : mSearchAdapter.getItemCount(); - if (itemCount <= 0) return; - SearchPosition saved = mSearchPositions.get(siteKey); - int position = saved == null ? 0 : Math.min(saved.adapterPosition, itemCount - 1); - int offset = saved == null ? 0 : saved.offset; - layoutManager.scrollToPositionWithOffset(Math.max(0, position), offset); - } - - private boolean focusFirstSearchResult() { - if (mSearchAdapter == null) return false; - String siteKey = getActiveSiteKey(); - if (!siteKey.equals(mBoundSearchSiteKey)) { - removeApplyCollect(); - Collect activated = mCollectAdapter.findActivated(siteKey); - if (activated == null) return false; - setSearchItemsLazy(new ArrayList<>(activated.getList())); - updateEmptyState(activated); - maybeLoadSelectedPage(); - } - if (mSearchAdapter.getItemCount() == 0) return false; - mBinding.recycler.post(() -> { - if (!siteKey.equals(mBoundSearchSiteKey) || !siteKey.equals(getActiveSiteKey())) return; - restoreSearchPosition(siteKey); - mBinding.recycler.post(() -> { - if (!siteKey.equals(mBoundSearchSiteKey) || !siteKey.equals(getActiveSiteKey())) return; - RecyclerView.LayoutManager manager = mBinding.recycler.getLayoutManager(); - SearchPosition saved = mSearchPositions.get(siteKey); - int itemCount = mSearchAdapter == null ? 0 : mSearchAdapter.getItemCount(); - int position = saved == null || itemCount <= 0 ? 0 : Math.min(saved.adapterPosition, itemCount - 1); - View target = manager == null ? null : manager.findViewByPosition(position); - if (target != null) target.requestFocus(); - else mBinding.recycler.requestFocus(); - }); - }); - return true; - } - @Override public void onItemClick(Vod item) { if (!item.isFolder() && isHistoryResume()) { @@ -1040,7 +889,9 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl } private boolean focusFirstResult() { - return focusFirstSearchResult(); + if (mBinding.recycler.getChildCount() == 0) return false; + mBinding.recycler.getChildAt(0).requestFocus(); + return true; } private void focusSelectedCollect() { @@ -1056,12 +907,8 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl if (next + count >= mSearchAdapter.getItemCount()) flushPendingItems(); mSearchAdapter.ensureLoaded(next + 1, count * 3); boolean bottom = next >= mSearchAdapter.getItemCount(); - if (bottom) { - mScroller.checkMore(); - return true; - } - focusSearchTarget(next); - return true; + if (bottom) mScroller.checkMore(); + return bottom; } @Override @@ -1102,16 +949,6 @@ public class CollectActivity extends BaseActivity implements CollectAdapter.OnCl if (canLoadImage()) Glide.with(this).resumeRequests(); } - private static final class SearchPosition { - private final int adapterPosition; - private final int offset; - - private SearchPosition(int adapterPosition, int offset) { - this.adapterPosition = Math.max(0, adapterPosition); - this.offset = offset; - } - } - @Override protected void onDestroy() { if (mBinding != null) { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java index 397c8da67f..8358eef27b 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java @@ -6,7 +6,6 @@ import android.os.Bundle; import androidx.viewbinding.ViewBinding; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.ActivityFileBinding; import com.fongmi.android.tv.ui.adapter.FileAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; @@ -38,34 +37,10 @@ public class FileActivity extends BaseActivity implements FileAdapter.OnClickLis @Override protected void initView(Bundle savedInstanceState) { selectDir = getIntent().getBooleanExtra("select_dir", false); - setEmptyContent(); setRecyclerView(); checkPermission(); } - /** - * 空态内容:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 - * - *

主动作选「重新扫描」而不是「返回上级」:根目录没有上级(返回会退出页面, - * 与按钮文案不符),而"重新扫描当前目录"在任何目录都成立,且能反映外部变化 - * (刚用别的应用放进来的文件)——这正是本地文件浏览器最需要的能力。 - * - *

重新扫描复用 {@link #update(File)}:它是本页**唯一**的列表填充入口,既重算 - * {@link #list(File)} 又驱动状态机,所以"扫描后仍为空"会正确地停在空态而不是留白。 - */ - private void setEmptyContent() { - mBinding.progressLayout.setEmptyContent( - R.string.file_empty_title, - R.string.file_empty_description, - R.string.file_empty_action, - view -> rescan()); - } - - /** 重新扫描当前目录;尚未拿到目录时(权限回调之前)退回根目录。 */ - private void rescan() { - update(dir == null ? Path.root() : dir); - } - private void setRecyclerView() { mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setVerticalSpacing(ResUtil.dp2px(16)); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java index 4539dfe9c1..a39ced6171 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java @@ -3,10 +3,8 @@ package com.fongmi.android.tv.ui.activity; import android.app.Activity; import android.content.Intent; import android.os.Bundle; -import android.view.View; import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.Product; @@ -16,10 +14,8 @@ import com.fongmi.android.tv.databinding.ActivityHistoryBinding; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.HistoryAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; -import com.fongmi.android.tv.ui.custom.FocusRestorer; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.ui.dialog.ViewingReportRangeDialog; @@ -30,7 +26,6 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl private ActivityHistoryBinding mBinding; private HistoryAdapter mAdapter; - private final FocusRestorer mFocusRestorer = new FocusRestorer(); public static void start(Activity activity) { activity.startActivity(new Intent(activity, HistoryActivity.class)); @@ -43,34 +38,12 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl @Override protected void initView(Bundle savedInstanceState) { - setEmptyContent(); setRecyclerView(); getHistory(); mBinding.deleteButton.setOnClickListener(v -> onDelete()); mBinding.reportButton.setOnClickListener(v -> onReport()); } - /** - * 空态内容:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 - * - *

改动前这一页只显示布局里的默认单行文案 {@code @string/error_empty} - * (「这里什麽都没有 👀」),既不解释为什么空,也不给出下一步——与电视端收藏页 - * 此前的问题相同,只是当时只接了收藏页。 - */ - private void setEmptyContent() { - mBinding.progressLayout.setEmptyContent( - R.string.history_empty_title, - R.string.history_empty_description, - R.string.history_empty_action, - view -> goHome()); - } - - /** 空态主按钮:回点播首页去挑一部。与收藏页/播放页的回首页写法一致。 */ - private void goHome() { - startActivity(new Intent(this, HomeActivity.class).addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); - } - private void onReport() { ViewingReportRangeDialog.create(this) .callback(range -> ViewingReportActivity.start(this, range)) @@ -79,7 +52,7 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl private void onDelete() { if (mAdapter.isDelete()) { - new WebHtvAlertDialogBuilder(this) + new androidx.appcompat.app.AlertDialog.Builder(this) .setTitle(R.string.dialog_delete_record) .setMessage(Setting.isGlobalHistoryEnabled() ? R.string.dialog_delete_global_history : R.string.dialog_delete_history) .setNegativeButton(R.string.dialog_negative, null) @@ -94,41 +67,12 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setItemAnimator(null); mBinding.recycler.setAdapter(mAdapter = new HistoryAdapter(this)); - // 列表数量变化后统一驱动状态机(BaseDiffAdapter 的钩子):注册一次,之后无论从哪条 - // 路径改动列表(单条删除、删除全部、刷新)状态都会跟着列表走,宿主不可能再忘。 - mAdapter.setOnCountChanged(count -> mBinding.progressLayout.showContent(true, count)); mBinding.recycler.setLayoutManager(new GridLayoutManager(this, Product.getColumn())); mBinding.recycler.addItemDecoration(new SpaceItemDecoration(Product.getColumn(), 16)); } private void getHistory() { - int position = focusedPosition(); - History focused = position < 0 || position >= mAdapter.getItemCount() ? null : mAdapter.getItem(position); - mAdapter.setItems(History.getForDisplay(), () -> { - if (mAdapter.getItemCount() == 0) mAdapter.setDelete(false); - else if (focused != null) restoreFocus(reposition(focused, position)); - }); - } - - // 刷新会按 createTime 重排,优先按条目身份重新定位;条目已不在新列表里(被别处删掉) - // 就退回原位置,让焦点留在附近而不是掉给右上角的删除按钮 - private int reposition(History focused, int position) { - int found = mAdapter.getItems().indexOf(focused); - if (found >= 0) return found; - return FocusRestorer.nextPosition(position, mAdapter.getItemCount(), Product.getColumn()); - } - - private int focusedPosition() { - if (isFinishing() || isDestroyed() || !mBinding.recycler.hasFocus()) return RecyclerView.NO_POSITION; - View focus = mBinding.recycler.getFocusedChild(); - return focus == null ? RecyclerView.NO_POSITION : mBinding.recycler.getChildAdapterPosition(focus); - } - - // 只在列表原本持有焦点时恢复:条目被移除时 RecyclerView 已 clearFocus, - // 焦点被窗口兜底给了右上角的删除按钮,需要主动交回列表 - private void restoreFocus(int position) { - if (isFinishing() || isDestroyed()) return; - mFocusRestorer.restore(mBinding.recycler, position); + mAdapter.setItems(History.getForDisplay(), () -> mBinding.progressLayout.showContent(true, mAdapter.getItemCount())); } @Subscribe(threadMode = ThreadMode.MAIN) @@ -149,31 +93,21 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl } @Override - public void onItemDelete(History item, int position) { - // diff 挂起时 holder 的绑定下标会失效,退回当前聚焦位置(遥控下点击必然带焦点) - int target = position >= 0 ? position : focusedPosition(); + public void onItemDelete(History item) { mAdapter.remove(item.deleteDisplayItem(), () -> { + mBinding.progressLayout.showContent(true, mAdapter.getItemCount()); if (mAdapter.getItemCount() == 0) mAdapter.setDelete(false); - else restoreFocus(FocusRestorer.nextPosition(target, mAdapter.getItemCount(), Product.getColumn())); }); } @Override public boolean onLongClick() { - mFocusRestorer.cancel(); mAdapter.setDelete(!mAdapter.isDelete()); return true; } - @Override - protected void onStop() { - mFocusRestorer.cancel(); - super.onStop(); - } - @Override protected void onBackInvoked() { - mFocusRestorer.cancel(); if (mAdapter.isDelete()) mAdapter.setDelete(false); else super.onBackInvoked(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java index 8f7dd023c3..0315e262f5 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java @@ -42,13 +42,10 @@ import com.fongmi.android.tv.bean.Style; import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.databinding.ActivityHomeBinding; import com.fongmi.android.tv.db.AppDatabase; -import com.fongmi.android.tv.setting.ConfigSyncPolicy; import com.fongmi.android.tv.event.CastEvent; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.event.ServerEvent; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingScheduler; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.impl.ConfigListener; import com.fongmi.android.tv.model.SiteViewModel; @@ -58,17 +55,13 @@ import com.fongmi.android.tv.service.DLNARendererService; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.AutoBackupPolicy; import com.fongmi.android.tv.setting.AppBranding; -import com.fongmi.android.tv.setting.CustomCspSetting; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.BaseDiffCallback; import com.fongmi.android.tv.ui.adapter.TypeAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.ui.custom.CustomRowPresenter; import com.fongmi.android.tv.ui.custom.CustomSelector; import com.fongmi.android.tv.ui.custom.CustomTitleView; -import com.fongmi.android.tv.ui.dialog.CustomCspDialog; import com.fongmi.android.tv.ui.dialog.HistoryDialog; import com.fongmi.android.tv.ui.dialog.ExitConfirmDialog; import com.fongmi.android.tv.ui.dialog.HomeMenuDialog; @@ -80,7 +73,6 @@ import com.fongmi.android.tv.ui.presenter.HistoryPresenter; import com.fongmi.android.tv.ui.presenter.ProgressPresenter; import com.fongmi.android.tv.ui.presenter.VodPresenter; import com.fongmi.android.tv.utils.Clock; -import com.fongmi.android.tv.utils.CrashRestartMode; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.KeyUtil; import com.fongmi.android.tv.utils.Notify; @@ -102,7 +94,6 @@ import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Locale; @@ -135,20 +126,11 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List private String webChromeMode = TV_NORMAL; private String webDefaultChromeMode = TV_FULL; private boolean webToolbarVisible = true; - private boolean toolbarRequestedVisible = true; private boolean loadingHomeCategory; private boolean skipNextVodConfigRefresh; private boolean pendingOpenVod; // 手动点击"点播"后等待数据加载完成再进分类页 private boolean webConfirmKeyDown; private boolean webConfirmLongPress; - private boolean mTypeSelectionFromTouch; - private boolean mPendingCategoryCleanup; - private boolean mPendingContentRefresh; - private boolean mPendingContentRefreshForceNative; - private int focusGeneration; - private Runnable mPendingTypeUpdate; - private boolean mTypeUpdatePosted; - private Runnable mPendingCategoryFocus; private final Runnable mTypeSwitch = this::switchType; private final Runnable mWebConfirmLongPress = this::triggerWebFocusedLongPress; private final Runnable mDelayedInitConfig = this::initConfig; @@ -227,14 +209,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List syncNativeContentInset(); syncWebOverlayLayout(); }); - mBinding.typeRecycler.setOnFocusChangeListener((view, hasFocus) -> { - if (!hasFocus) { - invalidatePendingFocusRequests(); - if (mFolder != null) mFolder.clearContentFocusRequest(); - } else if (isCategoryVisible() && mFolder != null) { - mFolder.scrollContentToTop(); - } - }); mBinding.recycler.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { @Override public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { @@ -254,82 +228,11 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List mSelectedTypeView = child.itemView; mSelectedTypeView.setSelected(true); if (parent.hasFocus()) updateToolbarVisibility(true); - mTypeSelectionFromTouch = TouchOptimizationHelper.isTouchActive(parent); - if (Setting.isHomeVodAutoLoad() && !mTypeSelectionFromTouch) scheduleTypeSwitch(position); + if (Setting.isHomeVodAutoLoad()) scheduleTypeSwitch(position); } }); } - /** - * 类型栏的结构性修改(换 adapter 数据)在 RecyclerView 正在计算布局时必须让路:布局 - * 过程中增删子 View 会让某个子 View 拿不到 ViewHolder,RecyclerView 随后在 - * dispatchLayoutStep1 里崩溃。 - * - *

这是类型栏结构性写入的唯一入口。本方法自己决定「立即执行」还是「入槽延后」,并且 - * 两个分支都由本方法负责真正调用 {@code update}:调用方只允许写一句裸语句 - * {@code runTypeMutation(...)},不得再自己执行目标,也不得把本方法当成判定条件去决定 - * 是否重复执行。(历史上它返回 boolean,于是非延后路径「方法内 run 一次 + 调用方再 run - * 一次」造成双执行,属于真缺陷,不是风格问题。) - * - *

单槽合并成立的前提是「写入均为整表替换」:槽位里只允许放整体覆盖 adapter 数据的 - * 写入,后来的写入直接顶掉先前的,所以被顶掉的旧写入不需要单独执行,也不需要任何代际号。 - * - *

延后不是一次性的:投递到下一帧动画时布局仍可能没结束,所以闭包必须像 - * {@code BaseActivity#postRecyclerUpdate} 那样重新复查 {@link #isTypeLayoutBusy()}, - * 未就绪就把同一个 Runnable 再挂一帧。 - * - *

排队语义是「单槽合并 + 必执行」,不是「代际号丢弃」:丢弃式代际号会让一次布局中的 - * 写入被随后的焦点失效静默作废,类型栏就永久空白。这里的待写入只有一个槽位 - * ({@link #mPendingTypeUpdate}),后来的写入直接覆盖先前的,因此旧写入不会盖掉新写入; - * 排队只允许存在一个 runner({@link #mTypeUpdatePosted}),它执行时取出并执行槽位里的 - * 最新写入,所以最新写入必然落地,不会变成无人执行的孤儿。丢弃只发生在一种情况下: - * 宿主正在结束或已销毁({@code isFinishing() || isDestroyed()})时,那时的 adapter - * 写入没有任何意义。 - */ - private void runTypeMutation(Runnable update) { - final RecyclerView view = mBinding.typeRecycler; - if (!isTypeLayoutBusy()) { - // 这次写入立刻落地,此前排队的那次已被它取代,必须清掉,否则旧数据会在下一帧 - // 覆盖刚刚同步写入的新数据。 - mPendingTypeUpdate = null; - update.run(); - return; - } - // 单槽合并:后写者覆盖前者,这就是「旧不盖新」的全部实现,不需要任何代际号。 - mPendingTypeUpdate = update; - // 同一时刻只允许一个 runner 在排队;已在排队时它执行时自然读到刚放进去的最新写入。 - if (mTypeUpdatePosted) return; - mTypeUpdatePosted = true; - view.postOnAnimation(new Runnable() { - @Override - public void run() { - mTypeUpdatePosted = false; - // 宿主正在结束或已销毁:此时的 adapter 写入没有任何意义,丢弃是安全的。 - if (!canDeferFragmentWork()) { - mPendingTypeUpdate = null; - return; - } - if (isTypeLayoutBusy()) { - mTypeUpdatePosted = true; - view.postOnAnimation(this); - return; - } - // 取出并执行槽位里的最新写入:它必然被执行,不会被静默丢弃。 - Runnable pending = mPendingTypeUpdate; - mPendingTypeUpdate = null; - if (pending != null) pending.run(); - } - }); - } - - /** - * 布局忙碌判定:类型栏自己或内容行 recycler 正在算布局时,结构性写入都必须让路。 - * 单点定义,避免同一个条件在守卫里出现两遍、改一处漏一处。 - */ - private boolean isTypeLayoutBusy() { - return mBinding.typeRecycler.isComputingLayout() || mBinding.recycler.isComputingLayout(); - } - private void scheduleTypeSwitch(int position) { mPendingTypePosition = position; mBinding.typeRecycler.removeCallbacks(mTypeSwitch); @@ -337,7 +240,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } private void resumeTypeSwitch() { - if (!Setting.isHomeVodAutoLoad() || mTypeSelectionFromTouch || mBinding.typeRecycler.getVisibility() != View.VISIBLE) return; + if (!Setting.isHomeVodAutoLoad() || mBinding.typeRecycler.getVisibility() != View.VISIBLE) return; int position = mBinding.typeRecycler.getSelectedPosition(); if (position >= 0) scheduleTypeSwitch(position); } @@ -354,13 +257,13 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List @Override public void onCategoryContentHorizontalEdge(Class item, int contentRow, boolean towardEnd) { if (!isCurrentCategory(item) || contentRow < 0) return; - mTypeSelectionFromTouch = false; item = getAdjacentCategory(item, towardEnd); if (item == null) return; mBinding.typeRecycler.removeCallbacks(mTypeSwitch); int position = mTypeAdapter.indexOf(item); mBinding.typeRecycler.setSelectedPosition(position); - focusCategoryButton(item); + if (contentRow == 0) focusFirstCard(item); + else focusCategoryButton(item); } private Class getAdjacentCategory(Class item, boolean towardEnd) { @@ -374,61 +277,24 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List private void focusFirstCard(Class item) { showCategoryContent(item); getSupportFragmentManager().executePendingTransactions(); - if (mFolder != null && isCurrentCategory(item)) mFolder.requestContentFocus(0, focusGeneration); + if (mFolder != null && isCurrentCategory(item)) mFolder.requestContentFocus(0); } private void focusCategoryButton(Class item) { - if (item == null || getSupportFragmentManager().isStateSaved()) return; + mBinding.typeRecycler.setVisibility(View.VISIBLE); + updateToolbarVisibility(true); + mBinding.typeRecycler.requestFocus(); showCategoryContent(item); - // Complete the switch before checking the new page; otherwise the posted callback can - // run while the target fragment is not added yet and drop the header/focus restoration. - getSupportFragmentManager().executePendingTransactions(); - final int generation = ++focusGeneration; - mPendingCategoryFocus = () -> { - if (generation != focusGeneration) return; - mPendingCategoryFocus = null; - if (isFinishing() || isDestroyed() || !isCurrentCategory(item) || mFolder == null) return; - int position = mTypeAdapter.indexOf(item); - if (position < 0 || mBinding.typeRecycler.getSelectedPosition() != position) return; - mBinding.typeRecycler.setVisibility(View.VISIBLE); - updateToolbarVisibility(true); - mBinding.typeRecycler.requestFocus(); - mFolder.scrollContentToTop(generation); - mBinding.typeRecycler.setSelectedPosition(position, holder -> { - if (generation != focusGeneration || !mBinding.typeRecycler.hasFocus()) return; - if (isCurrentCategory(item) && mBinding.typeRecycler.getSelectedPosition() == position) { - holder.itemView.requestFocus(); - } - }); - }; - mBinding.typeRecycler.post(mPendingCategoryFocus); - } - - private void invalidatePendingFocusRequests() { - focusGeneration++; - if (mBinding != null && mPendingCategoryFocus != null) { - mBinding.typeRecycler.removeCallbacks(mPendingCategoryFocus); - } - if (mBinding != null) mBinding.typeRecycler.removeCallbacks(mTypeSwitch); - mPendingCategoryFocus = null; } private void showHomeContent() { - invalidatePendingFocusRequests(); mCurrentType = null; - // 分类页里的滚动隐藏是分类页的临时状态,不该跨内容模式留下来:否则在分类页往下滚过 - // 再返回首页,记录下来的“不想显示”会让首页工具条一直藏着(CustomVerticalGridView - // 会把 typeRecycler 和 toolbar 一起藏掉并回调到这里)。 - toolbarRequestedVisible = true; mBinding.progressLayout.setVisibility(View.VISIBLE); mBinding.categoryContainer.setVisibility(View.GONE); - applyToolbarVisibility(); if (mFolder != null && mFolder.isAdded() && !mFolder.isHidden()) { mFolder.clearContentFocusRequest(); mFolder.setUserVisibleHint(false); - // 这条路径也会从选中监听(updateTypeRecyclerVisibility)进来,同样可能 - // 落在已保存状态窗口里;本地可见性状态照旧清,事务则只在能提交时才提交。 - if (canCommitFragmentTransaction()) getSupportFragmentManager().beginTransaction().hide(mFolder).commit(); + getSupportFragmentManager().beginTransaction().hide(mFolder).commit(); } } @@ -437,14 +303,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } private void showCategoryContent(Class item, boolean toggleFilter) { - if (getSupportFragmentManager().isStateSaved()) { - invalidatePendingFocusRequests(); - return; - } - final int generation = ++focusGeneration; - if (mBinding != null && mPendingCategoryFocus != null) mBinding.typeRecycler.removeCallbacks(mPendingCategoryFocus); - mPendingCategoryFocus = null; - if (mFolder != null) mFolder.clearContentFocusRequest(); + if (getSupportFragmentManager().isStateSaved()) return; getSupportFragmentManager().executePendingTransactions(); if (item == null || item.isHome()) { showHomeContent(); @@ -466,7 +325,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List FolderFragment target = existing instanceof FolderFragment ? (FolderFragment) existing : FolderFragment.newInstance(getHome().getKey(), item); if (target == mFolder && target.isAdded() && !target.isHidden()) { mCurrentType = item; - applyToolbarVisibility(); if (toggleFilter) updateFilter(item); return; } @@ -481,15 +339,12 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List else transaction.add(R.id.categoryContainer, target, tag); target.setUserVisibleHint(true); transaction.runOnCommit(() -> { - if (generation != focusGeneration || target != mFolder || !isCurrentCategory(item)) return; - target.scrollContentToTop(generation); restoreTypeFocus(keepTypeFocus, item); if (toggleFilter && target == mFolder && isCurrentCategory(item)) updateFilter(item); }); transaction.commit(); mFolder = target; mCurrentType = item; - applyToolbarVisibility(); } private void restoreTypeFocus(boolean keepTypeFocus, Class item) { @@ -508,59 +363,14 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List clearCategoryContent(); } - /** - * 宿主已保存状态、正在结束或已销毁时不能再提交 Fragment 事务。 - * - *

首页的分类页重建由**进程级**单例 {@code VodConfig} 的加载回调驱动 - * ({@code BaseConfig.loadConfig()} 用 {@code App.post(callback::success)} 投递), - * 回调与 Activity 生命周期无关:外观页改主题色/主题模式/UI 缩放都会 - * {@code recreate()},而配置加载可能在这个重建窗口里才返回。此前 - * {@link #clearCategoryContent()} 无条件 {@code commit()},于是这条延迟回调在 - * 已保存状态上抛 {@code IllegalStateException: Can not perform this action after - * onSaveInstanceState} 并崩溃(见 docs/CRASH-FRAGMENT-STATE-20261022.md)。 - * - *

判定条件与文件内既有的 4 处守卫({@code switchType}、 - * {@code focusCategoryButton}、{@code showCategoryContent}、 - * {@code openCustomCsp})保持一致。 - */ - private boolean canCommitFragmentTransaction() { - return !isFinishing() && !isDestroyed() && !getSupportFragmentManager().isStateSaved(); - } - - /** - * 宿主仍然存活、只是被保存了状态(例如配置回调在用户切走之后才返回)时, - * 被 {@link #canCommitFragmentTransaction()} 拒绝的工作要推迟到回到前台补做; - * 正在结束或已销毁的实例没有可恢复的宿主,直接丢弃。 - */ - private boolean canDeferFragmentWork() { - return !isFinishing() && !isDestroyed(); - } - private void clearCategoryContent() { - invalidatePendingFocusRequests(); mBinding.typeRecycler.removeCallbacks(mTypeSwitch); - // 换源时不需要额外作废上一站点在途的写入:清理本身是最后一次写入,单槽合并让 - // 它直接覆盖先前排队的那次(后写者胜)。 - clearStaleSiteTypes(); + mPendingTypePosition = -1; mCurrentType = null; mFolder = null; - toolbarRequestedVisible = true; mBinding.progressLayout.setVisibility(View.VISIBLE); mBinding.categoryContainer.setVisibility(View.GONE); - applyToolbarVisibility(); - // 状态清理是纯内存赋值,与能否提交事务无关;这里必须已经丢弃 mFolder, - // 否则 isCurrentCategory() 会把一个即将消失的 Fragment 当成当前分类。 - if (!canCommitFragmentTransaction()) { - // 这次移除不能丢:旧站点的分类页(含子 Fragment)会一直留在容器里, - // 等容器再次可见就会和新站点的页面叠加。 - if (canDeferFragmentWork()) mPendingCategoryCleanup = true; - return; - } - removeCategoryFragments(); - } - /** 移除所有已加入的分类页;调用方必须先通过 {@link #canCommitFragmentTransaction()}。 */ - private void removeCategoryFragments() { FragmentTransaction transaction = null; for (Fragment fragment : getSupportFragmentManager().getFragments()) { if (!(fragment instanceof FolderFragment folder)) continue; @@ -571,47 +381,14 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List if (transaction != null) transaction.commit(); } - private void clearStaleSiteTypes() { - // Site switching starts asynchronously. Remove the old type row immediately so stale - // category buttons cannot be mistaken for the newly selected site during loading. - // 隐藏与选中的撤销是纯状态量,必须同步做完:延后它们会让旧分类按钮至少多活一帧, - // 期间它仍然可见、可聚焦,switchType() 还会按旧 adapter 判定并打开旧 Class。 - mPendingTypePosition = -1; - mBinding.typeRecycler.setVisibility(View.GONE); - // 只有真正的结构性写入(adapter 换数据)需要让路:这条链路 - // (clearCategoryContent ← syncCategorySite ← 站点切换)就是目标崩溃场景。 - runTypeMutation(this::clearStaleTypeItems); - } - - /** 清空旧站点的分类按钮;结构性修改只允许经 {@link #runTypeMutation(Runnable)} 这条守卫进入。 */ - private void clearStaleTypeItems() { - mTypeAdapter.addAll(Collections.emptyList()); - } - private void updateToolbarVisibility(boolean visible) { - toolbarRequestedVisible = visible; - applyToolbarVisibility(); - } - - /** - * 首页顶部工具条(logo + 站点名 + 时钟)只属于首页内容。 - * - *

点播内联分类页要和关闭「默认加载点播」后 VodActivity 分类页保持一致:那边只有分类按钮行 + - * 内容容器,没有这条工具条(见 {@code app/src/leanback/res/layout/activity_vod.xml}),所以分类页 - * 把工具条藏起来;分类按钮行照旧留在顶部,方向键还能继续切分类。首页里按最近一次滚动请求 - * 恢复,切换内容模式时(离开或回到首页)这个请求会复位成显示。 - */ - private void applyToolbarVisibility() { - boolean show = toolbarRequestedVisible && webToolbarVisible && !isCategoryVisible(); - mBinding.toolbar.setVisibility(show ? View.VISIBLE : View.GONE); + mBinding.toolbar.setVisibility(visible && webToolbarVisible ? View.VISIBLE : View.GONE); syncNativeContentInset(); syncWebOverlayLayout(); } private void syncNativeContentInset() { - // 工具条藏掉后首页内容贴到顶部;分类页的分类按钮行需要自己的留白,与 VodActivity 分类页一致。 - // 三臂的单位都是 px(getPaddingTop/setPadding 和 toolbarHeight 都是 px),别在这里改成 dp。 - int top = isToolbarVisible() ? toolbarHeight() : (isCategoryVisible() ? getResources().getDimensionPixelSize(R.dimen.webhtv_space_6) : 0); + int top = isToolbarVisible() ? toolbarHeight() : 0; if (mBinding.nativeContent.getPaddingTop() == top) return; mBinding.nativeContent.setPadding(mBinding.nativeContent.getPaddingLeft(), top, mBinding.nativeContent.getPaddingRight(), mBinding.nativeContent.getPaddingBottom()); } @@ -668,12 +445,9 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List selector.addPresenter(Integer.class, new HeaderPresenter()); selector.addPresenter(String.class, new ProgressPresenter()); selector.addPresenter(Vod.class, new VodPresenter(this, Style.list())); - // 首页三条卡片行统一读设计系统的 TV 卡片间距 token(§6「TV 横向间距 20dp」)。 - // 此前是硬编码 16dp,而 webhtv_size_tv_card_gap 只声明、从未被引用。 - int tvCardGap = ResUtil.dpOf(R.dimen.webhtv_size_tv_card_gap); - selector.addPresenter(ListRow.class, new CustomRowPresenter(tvCardGap), VodPresenter.class); - selector.addPresenter(ListRow.class, new CustomRowPresenter(tvCardGap), FuncPresenter.class); - selector.addPresenter(ListRow.class, new CustomRowPresenter(tvCardGap, FocusHighlight.ZOOM_FACTOR_SMALL, HorizontalGridView.FOCUS_SCROLL_ALIGNED), HistoryPresenter.class); + selector.addPresenter(ListRow.class, new CustomRowPresenter(16), VodPresenter.class); + selector.addPresenter(ListRow.class, new CustomRowPresenter(16), FuncPresenter.class); + selector.addPresenter(ListRow.class, new CustomRowPresenter(16, FocusHighlight.ZOOM_FACTOR_SMALL, HorizontalGridView.FOCUS_SCROLL_ALIGNED), HistoryPresenter.class); mBinding.recycler.setAdapter(new ItemBridgeAdapter(mAdapter = new ArrayObjectAdapter(selector))); mBinding.recycler.setVerticalSpacing(ResUtil.dp2px(16)); mBinding.typeRecycler.setHorizontalSpacing(ResUtil.dp2px(16)); @@ -758,11 +532,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } private void initConfig() { - if (CrashRestartMode.consume()) { - SpiderDebug.log("startup", "skip config load once after crash restart"); - showContent(); - return; - } SpiderDebug.log("startup", "config load start cost=%sms", System.currentTimeMillis() - App.time()); VodConfig.get().init().load(getCallback()); LiveConfig.get().init().load(); @@ -778,62 +547,16 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List showContent(); } - /** - * 还没有配置任何内容源(重置应用、重装或恢复出厂后的首次启动)。 - * - *

这不是失败:没有地址可请求、没有失败原因、重试也永远不会成功。改动前 - * {@code BaseConfig} 把它当 {@code error("")} 抛出来,首页便显示「内容源加载失败 / - * 重试」——一个不存在的故障加一个点了没用的按钮。这里改走「未配置」状态: - * 不接管首页、直接进入内容态(导航恢复);添加接口仍走既有的 - * 「设置 → 点播」里的管理面板(`HistoryDialog` 的「添加」→ `ConfigDialog`)。 - */ - @Override - public void noConfig() { - SpiderDebug.log("startup", "no vod config configured"); - skipNextVodConfigRefresh = true; - showNoConfig(); - } - @Override public void error(String msg) { SpiderDebug.log("startup", "config load error cost=%sms msg=%s", System.currentTimeMillis() - App.time(), msg); Notify.show(msg); skipNextVodConfigRefresh = true; - // 设计文档 §9.9:错误态要给出「错误色 + 原因 + 重试动作」。 - // 改动前这里只弹一个会消失的吐司,然后 showContent() 留一个没有任何内容源的 - // 空首页——用户既不知道原因,也没有重试入口。吐司保留(不删既有行为), - // 页面改为进入错误态。 - showConfigError(msg); + showContent(); } }; } - /** 内容源加载失败:进入错误态,原因取服务端返回的 message,主动作是重新加载配置。 */ - private void showConfigError(String msg) { - mBinding.progressLayout.showError( - R.string.error_config_load, - msg, - R.string.error_retry, - view -> { - mBinding.progressLayout.showProgress(); - initConfig(); - }); - } - - /** - * 还没有配置内容源:这不是失败,所以**不接管首页**。 - * - *

点播/直播/设置这一行不是独立导航栏,而是首页列表的第一行({@link #setFunc()} - * 往 {@code mFuncAdapter} 里填按钮);一旦走 {@code progressLayout.showEmpty()}, - * {@code ProgressLayout} 会把内容视图整块 INVISIBLE,导航行跟着消失——新装用户只剩一个 - * 「添加接口」按钮,连设置都进不去。因此这里照常进入内容态(导航恢复);导航行仍只由 - * {@code HomeButton.getVisibleButtons()} 构成,不再额外插入「添加接口」入口(添加接口走 - * 既有的「设置 → 点播」管理面板)。 - */ - private void showNoConfig() { - showContent(); - } - private void showContent() { SpiderDebug.log("startup", "home showContent start cost=%sms", System.currentTimeMillis() - App.time()); mBinding.progressLayout.showContent(); @@ -878,16 +601,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List private void getVideo(boolean forceNative) { syncCategorySite(); - // 这次刷新打给的是一个已经过去(已保存状态)的窗口:重建后的实例会由 - // initAfterFirstFrame → initConfig 自己加载。继续重排只会再撞一次事务守卫。 - if (!canCommitFragmentTransaction()) { - // 没有重建、只是被保存了状态时这次刷新不能丢:回到前台(onResume)再补。 - if (canDeferFragmentWork()) { - mPendingContentRefresh = true; - mPendingContentRefreshForceNative |= forceNative; - } - return; - } if (!forceNative && WebHomeTarget.canLoad(getHome())) { ensureWebView(); } @@ -927,23 +640,14 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List private void setTypes(Result result) { if (result.getTypes().isEmpty()) { - // 守卫自己负责执行或入槽;这里直接返回,绝不能自己再执行一次目标。 - runTypeMutation(this::clearTypes); + mTypeAdapter.addAll(java.util.Collections.emptyList()); + mBinding.typeRecycler.setVisibility(View.GONE); + showHomeContent(); return; } List items = new ArrayList<>(); if (Setting.isHomeVodAutoLoad()) items.add(createHomeType()); items.addAll(result.getTypes()); - runTypeMutation(() -> applyTypes(items)); - } - - private void clearTypes() { - mTypeAdapter.addAll(Collections.emptyList()); - mBinding.typeRecycler.setVisibility(View.GONE); - showHomeContent(); - } - - private void applyTypes(List items) { mTypeAdapter.addAll(items); if (Setting.isHomeVodAutoLoad()) { mBinding.typeRecycler.setSelectedPosition(0); @@ -1016,12 +720,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List mFuncAdapter.setItems(items, new BaseDiffCallback()); } - private void refreshFollowingCount() { - FollowingPlaybackBridge.refreshUnreadCountAsync(unread -> { - if (!isFinishing() && !isDestroyed()) setFunc(); - }); - } - private void getHistory() { getHistory(false); } @@ -1066,7 +764,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List performClearHistory(); return; } - new WebHtvAlertDialogBuilder(this) + new androidx.appcompat.app.AlertDialog.Builder(this) .setTitle(R.string.dialog_delete_record) .setMessage(R.string.dialog_delete_global_history) .setNegativeButton(R.string.dialog_negative, null) @@ -1120,7 +818,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List switch (event.getType()) { case HOME: setTitle(); - dismissStateIfConfigured(); SpiderDebug.log("site-dialog", "home refresh start key=%s homePage=%s", getHome().getKey(), getHome().hasHomePage()); if (mWeb != null && mWeb.isVisible()) { if (!mWeb.load(getHome(), true)) getVideo(true); @@ -1140,21 +837,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } } - /** - * 状态浮层只会被「又一次启动 / 点重试」收掉,而配置可能在别的路径上变得可用: - * 恢复备份、远程指令切换接口、设置页切换接口、接收推送接口。这些路径发的是 - * {@link RefreshEvent#home()} 而不是重新走 {@link #showContent()},于是用户在这些页面上 - * 操作完回到首页,首页仍顶着那张状态卡,**必须杀进程冷启动才恢复**。 - * - *

这里把「配置真的可用了」当成收卡的唯一判据:HOME 刷新意味着首页马上会重新拉内容, - * 此时若还停在空态/错误态就直接收起,不依赖任何调用方记得收。 - */ - private void dismissStateIfConfigured() { - if (VodConfig.get().getSites().isEmpty()) return; - if (!mBinding.progressLayout.isEmpty() && !mBinding.progressLayout.isError()) return; - mBinding.progressLayout.showContent(); - } - @Subscribe(threadMode = ThreadMode.MAIN) public void onServerEvent(ServerEvent event) { switch (event.type()) { @@ -1205,21 +887,11 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } } else if (item.getResId() == R.string.home_live) LiveActivity.start(this); else if (item.getResId() == R.string.home_keep) KeepActivity.start(this); - else if (item.getResId() == R.string.home_following) FollowingActivity.start(this, null); else if (item.getResId() == R.string.home_push) PushActivity.start(this); else if (item.getResId() == R.string.home_search) SearchActivity.start(this); else if (item.getResId() == R.string.home_setting) SettingActivity.start(this); else if (item.getResId() == R.string.home_cast) PushActivity.start(this, 3); else if (item.getResId() == R.string.home_history_button) HistoryActivity.start(this); - else if (item.getResId() == R.string.home_custom_csp) openCustomCsp(); - } - - private void openCustomCsp() { - PermissionUtil.requestFile(this, granted -> { - if (isFinishing() || isDestroyed() || getSupportFragmentManager().isStateSaved()) return; - if (granted) CustomCspDialog.show(this, this::setFunc); - else Notify.show(R.string.setting_custom_csp_permission_required); - }); } @Override @@ -1280,7 +952,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List @Override public void onItemClick(Class item) { - mTypeSelectionFromTouch = false; if (item.isHome()) showHomeContent(); else if (isCurrentCategory(item)) updateFilter(item); else { @@ -1295,15 +966,9 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List else if (mFolder != null && item.equals(mCurrentType)) mFolder.onRefresh(); } - @Override - public void onLongPress(Class item) { - if (item == null || item.isHome() || !isCurrentCategory(item)) return; - mFolder.resetFilter(); - } - @Override public void onItemClick(Vod item) { - if (item.isAction()) com.fongmi.android.tv.content.ActionCardHelper.handleAction(this, getHome().getKey(), item.getAction()); + if (item.isAction()) mViewModel.action(getHome().getKey(), item.getAction()); else if (getHome().isIndex()) CollectActivity.start(this, item.getName()); else VideoActivity.start(this, getHome().getKey(), item.getId(), item.getName(), item.getPic()); } @@ -1350,7 +1015,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } /** - * 执行 select_home_menu_key 中某一项对应的动作,下标 1..11(0 是「选项弹窗」自身,不会走到这里)。 + * 执行 select_home_menu_key 中某一项对应的动作,下标 1..9(0 是「选项弹窗」自身,不会走到这里)。 */ @Override public void onHomeMenuItem(int index) { @@ -1364,8 +1029,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List case 7 -> PushActivity.start(this, 3); case 8 -> KeepActivity.start(this); case 9 -> SettingActivity.start(this); - case 10 -> FollowingActivity.start(this, null); - case 11 -> openCustomCsp(); } } @@ -1400,20 +1063,12 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List public void setConfig(Config config) { if (config.getType() != 0) return; if (config.getUrl().startsWith("file")) { - PermissionUtil.requestFile(this, allGranted -> loadVodConfig(config)); + PermissionUtil.requestFile(this, allGranted -> VodConfig.load(config, getCallback())); } else { - loadVodConfig(config); + VodConfig.load(config, getCallback()); } } - private void loadVodConfig(Config config) { - String previousVodUrl = VodConfig.getUrl(); - VodConfig.load(config, getCallback()); - if (!ConfigSyncPolicy.shouldSyncLive(previousVodUrl, LiveConfig.getUrl())) return; - Config liveConfig = AppDatabase.get().getConfigDao().find(config.getUrl(), 1); - if (liveConfig != null) LiveConfig.load(liveConfig, new Callback()); - } - @Override public void setSite(Site item) { SpiderDebug.log("site-dialog", "set site key=%s name=%s homePage=%s", item.getKey(), item.getName(), item.hasHomePage()); @@ -1497,12 +1152,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } private boolean requestContentFocus() { - if (isCategoryVisible()) { - invalidatePendingFocusRequests(); - if (mFolder == null) return false; - mFolder.clearContentFocusRequest(); - return mFolder.requestContentFocus(); - } + if (isCategoryVisible()) return mFolder != null && mFolder.requestContentFocus(); if (mBinding.recycler.getVisibility() != View.VISIBLE || mBinding.recycler.getChildCount() == 0) return false; View child = mBinding.recycler.getFocusedChild(); if (child == null) child = mBinding.recycler.getChildAt(0); @@ -1515,33 +1165,13 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List mClock.start(); syncHomeSiteLock(); if (mWeb != null) mWeb.onResume(); - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); setFunc(); - refreshFollowingCount(); syncTypeItems(); resumeTypeSwitch(); - resumeDeferredFragmentWork(); - } - - /** 回到前台后补做在「已保存状态」窗口里被推迟的分类页清理与首页内容刷新。 */ - private void resumeDeferredFragmentWork() { - if (!canCommitFragmentTransaction()) return; - if (mPendingCategoryCleanup) { - mPendingCategoryCleanup = false; - removeCategoryFragments(); - } - if (!mPendingContentRefresh) return; - mPendingContentRefresh = false; - boolean forceNative = mPendingContentRefreshForceNative; - mPendingContentRefreshForceNative = false; - getVideo(forceNative); } @Override protected void onPause() { - invalidatePendingFocusRequests(); - if (mFolder != null) mFolder.clearContentFocusRequest(); mBinding.typeRecycler.removeCallbacks(mTypeSwitch); cancelWebConfirmKey(); if (mWeb != null) mWeb.onPause(); @@ -1609,8 +1239,6 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List @Override protected void onDestroy() { - invalidatePendingFocusRequests(); - if (mFolder != null) mFolder.clearContentFocusRequest(); mBinding.typeRecycler.removeCallbacks(mTypeSwitch); cancelPendingStartupTasks(); if (mWeb != null) mWeb.destroy(); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java index 9ba5115bd2..bcf1a307d3 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java @@ -8,7 +8,6 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.Product; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.Config; import com.fongmi.android.tv.bean.Keep; @@ -39,44 +38,20 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override protected void initView(Bundle savedInstanceState) { - setEmptyContent(); setRecyclerView(); getKeep(); } - /** - * 空态内容:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 - * - *

收藏页是最典型的空态——用户第一次进来时列表必为空,此前的实现只有一句 - * {@code error_empty}(「这里什麽都没有」),既不解释为什么空,也不给出下一步。 - */ - private void setEmptyContent() { - mBinding.progressLayout.setEmptyContent( - R.string.keep_empty_title, - R.string.keep_empty_description, - R.string.keep_empty_action, - view -> goHome()); - } - - /** 空态主按钮:回点播首页去挑一部。与 LiveActivity/VideoActivity 的回首页写法一致。 */ - private void goHome() { - startActivity(new Intent(this, HomeActivity.class).addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); - } - private void setRecyclerView() { mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setItemAnimator(null); mBinding.recycler.setAdapter(mAdapter = new KeepAdapter(this)); - // 列表数量变化后统一驱动状态机(BaseDiffAdapter 的钩子):注册一次,之后无论从哪条 - // 路径改动列表(单条删除、删除全部、刷新)状态都会跟着列表走,宿主不可能再忘。 - mAdapter.setOnCountChanged(count -> mBinding.progressLayout.showContent(true, count)); mBinding.recycler.setLayoutManager(new GridLayoutManager(this, Product.getColumn())); mBinding.recycler.addItemDecoration(new SpaceItemDecoration(Product.getColumn(), 16)); } private void getKeep() { - mAdapter.setItems(Keep.getVod()); + mAdapter.setItems(Keep.getVod(), () -> mBinding.progressLayout.showContent(true, mAdapter.getItemCount())); } private void loadConfig(Config config, Keep item) { @@ -89,18 +64,6 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override public void error(String msg) { Notify.show(msg); - // 设计文档 §9.9:错误态要给出「错误色 + 原因 + 重试动作」。 - // 改动前这里只弹一个会消失的吐司,页面没有任何变化——用户既不知道原因会 - // 停留多久,也没有重试入口(只能再点一次卡片,但卡片看起来毫无反应)。 - // 重试直接复用同一条加载路径,所以重试成功与首次成功的行为完全一致。 - mBinding.progressLayout.showError( - R.string.error_config_load, - msg, - R.string.error_retry, - view -> { - mBinding.progressLayout.showProgress(); - loadConfig(config, item); - }); } }); } @@ -121,8 +84,6 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override public void onItemDelete(Keep item) { mAdapter.remove(item.delete(), () -> { - // 状态由 BaseDiffAdapter 的 setOnCountChanged 钩子统一驱动,这里不再手写—— - // 「手写」正是此前漏掉删除全部那条路径的原因。 if (mAdapter.getItemCount() == 0) mAdapter.setDelete(false); }); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java index c8ff17aba5..7f6036e621 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java @@ -46,15 +46,10 @@ import com.fongmi.android.tv.impl.LiveListener; import com.fongmi.android.tv.impl.PassListener; import com.fongmi.android.tv.model.LiveViewModel; import com.fongmi.android.tv.player.PlayerHelper; -import com.fongmi.android.tv.player.LiveSourceFallbackPolicy; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.Source; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.LiveSetting; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.LiveListScrim; -import com.fongmi.android.tv.theme.PlaybackMenuScrim; -import com.fongmi.android.tv.setting.PlayerButtonSetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.ui.adapter.ChannelAdapter; import com.fongmi.android.tv.ui.adapter.EpgDataAdapter; @@ -64,7 +59,6 @@ import com.fongmi.android.tv.ui.custom.CustomLiveListView; import com.fongmi.android.tv.ui.custom.CustomSeekView; import com.fongmi.android.tv.ui.custom.PlayerOsdController; import com.fongmi.android.tv.ui.dialog.HistoryDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.ui.dialog.LiveDialog; import com.fongmi.android.tv.ui.dialog.PassDialog; import com.fongmi.android.tv.ui.dialog.PlayerKernelDialog; @@ -81,20 +75,12 @@ import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.util.ArrayList; -import java.util.HashMap; import java.util.Iterator; import java.util.List; -import java.util.Map; public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnClickListener, ChannelAdapter.OnClickListener, EpgDataAdapter.OnClickListener, CustomKeyDownLive.Listener, CustomLiveListView.Callback, TrackDialog.Listener, PassListener, ConfigListener, LiveListener { private static final long PLAYBACK_END_RETRY_DELAY = 500; - private static final long LIVE_BUFFERING_TIMEOUT = 15000; - - @Override - protected boolean shouldAutoPlay() { - return true; - } private ActivityLiveBinding mBinding; private ChannelAdapter mChannelAdapter; @@ -106,7 +92,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private LiveViewModel mViewModel; private PlayerOsdController mOsd; private List mHides; - private Map mActionButtons; private String mPlaybackKey; private String mPendingReloadUrl; private String mPendingReloadMsg; @@ -119,19 +104,10 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private Runnable mR3; private Runnable mR4; private Runnable mEndRetry; - private Runnable mBufferingTimeout; private Clock mClock; private View mFocus2; private boolean playbackCatchup; private int count; - private boolean mFailedThisSession; - /** - * 本轮播放失败自动换线的起点线路下标,配合 {@link #mLineFallbackExhausted} 使用。 - * -1 表示当前没有进行中的失败回退轮换;自动换线绕回该下标即本频道所有线路都已试过, - * 置位 exhausted 阻止 onError 立即开启新一轮死循环;手动换线/换台/换源或播放成功后重置。 - */ - private int mLineFallbackAnchor = -1; - private boolean mLineFallbackExhausted; public static void start(Context context) { context.startActivity(new Intent(context, LiveActivity.class).putExtra("empty", LiveConfig.isEmpty())); @@ -196,8 +172,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli @Override protected void initView(Bundle savedInstanceState) { super.initView(savedInstanceState); - applyPlaybackOverlay(); - applyLiveListScrim(); mClock = Clock.create(mBinding.widget.clock); mKeyDown = CustomKeyDownLive.create(this); mObserveEpg = this::setEpg; @@ -206,7 +180,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mR0 = this::setSelected; mR1 = this::hideControl; mR2 = this::setTraffic; - mBufferingTimeout = this::onBufferingTimeout; mR3 = this::hideInfo; mR4 = this::hideUI; mEndRetry = this::checkNext; @@ -224,7 +197,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli }, 14f); setVideoView(); setViewModel(); - setupActionButtons(); } @Override @@ -253,7 +225,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mBinding.control.action.player.setOnClickListener(view -> onPlayerKernel()); mBinding.control.action.player.setOnLongClickListener(view -> onPlayerKernelLong()); mBinding.control.action.decode.setOnClickListener(view -> onDecode()); - mBinding.control.action.playParams.setOnClickListener(view -> onPlayParams()); mBinding.control.action.speed.setOnLongClickListener(view -> onSpeedLong()); mBinding.video.setOnTouchListener((view, event) -> mKeyDown.onTouchEvent(event)); mBinding.group.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { @@ -273,42 +244,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mBinding.epgData.setAdapter(mEpgDataAdapter = new EpgDataAdapter(this)); } - /** - * 直播动作栏登记点播同名的播放器按钮,使「设置-播放-播放器按钮」的开关与排序对直播同样生效。 - * 直播专属按钮(配置、OSD 设置、直播源、播放/暂停、线路、反色、跨类)不登记,始终按布局显示。 - */ - private void setupActionButtons() { - mActionButtons = new HashMap<>(); - addActionButton(PlayerButtonSetting.PLAYER, mBinding.control.action.player); - addActionButton(PlayerButtonSetting.DECODE, mBinding.control.action.decode); - addActionButton(PlayerButtonSetting.PLAY_PARAMS, mBinding.control.action.playParams); - addActionButton(PlayerButtonSetting.SPEED, mBinding.control.action.speed); - addActionButton(PlayerButtonSetting.SCALE, mBinding.control.action.scale); - addActionButton(PlayerButtonSetting.CHANGE, mBinding.control.action.change); - addActionButton(PlayerButtonSetting.TEXT, mBinding.control.action.text); - addActionButton(PlayerButtonSetting.AUDIO, mBinding.control.action.audio); - addActionButton(PlayerButtonSetting.VIDEO, mBinding.control.action.video); - PlayerButtonSetting.applyOrder(mBinding.control.action.container, mActionButtons); - } - - private void addActionButton(String id, View view) { - mActionButtons.put(id, view); - } - - /** - * 先恢复无动态条件的按钮,再统一套用用户关闭项。 - * 顺序不能反过来:applyVisibility 只隐藏、不恢复,必须在条件判定之后执行。 - */ - private void applyActionButtonVisibility() { - if (mActionButtons == null) return; - mBinding.control.action.player.setVisibility(View.VISIBLE); - mBinding.control.action.decode.setVisibility(View.VISIBLE); - mBinding.control.action.playParams.setVisibility(View.VISIBLE); - mBinding.control.action.scale.setVisibility(View.VISIBLE); - mBinding.control.action.change.setVisibility(View.VISIBLE); - PlayerButtonSetting.applyVisibility(mActionButtons); - } - private void setVideoView() { setScale(LiveSetting.getScale()); findViewById(R.id.timeBar).setNextFocusUpId(R.id.config); @@ -317,15 +252,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mBinding.control.action.change.setSelected(LiveSetting.isChange()); } - private void applyPlaybackOverlay() { - if (Setting.isPlaybackOverlayEnabled()) mBinding.control.getRoot().setBackground(PlaybackMenuScrim.create(this)); - else mBinding.control.getRoot().setBackgroundResource(R.color.transparent); - } - - private void applyLiveListScrim() { - mBinding.recycler.setBackground(LiveListScrim.create(this)); - } - private void setDecode() { mBinding.control.action.decode.setText(player().getDecodeText()); } @@ -345,11 +271,15 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mViewModel.url().observeForever(mObserveUrl); mViewModel.xml().observe(this, this::setEpg); mViewModel.epg().observeForever(mObserveEpg); - mViewModel.live().observe(this, this::renderLive); + mViewModel.live().observe(this, live -> { + mViewModel.parseXml(live); + setGroup(live); + setWidth(live); + }); } private void checkLive() { - if (LiveConfig.isEmpty()) { + if (isEmpty()) { LiveConfig.get().init().load(getCallback()); } else { getLive(); @@ -371,35 +301,10 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli } private void getLive() { - Live live = getHome(); - setSource(live); - mViewModel.parse(live); + mViewModel.parse(getHome()); showProgress(); } - /** - * 控制栏的「直播源」按钮显示当前直播源名称:固定写「直播源」看不出此刻播的是哪一路源, - * 用户换源(含失效自动换源)都会重新走 {@link #getLive()} 刷新。名称为空时回退原文案。 - * 按钮文案被源名取代后读屏会只剩一个孤立的源名,因此同步补回「直播源」动作语义。 - */ - private void setSource(Live live) { - String name = live == null ? "" : live.getDisplayName(); - boolean named = !name.isEmpty(); - String label = named ? name : getString(R.string.live_control_source); - mBinding.control.action.source.setText(label); - mBinding.control.action.source.setContentDescription(named ? getString(R.string.live_control_source_named, name) : null); - } - - private void renderLive(Live live) { - if (live == null || live.getGroups().isEmpty()) { - if (LiveSetting.isSourceFallback()) startSourceFallback(); - return; - } - mViewModel.parseXml(live); - setGroup(live); - setWidth(live); - } - private void setGroup(Live live) { List items = new ArrayList<>(); for (Group group : live.getGroups()) (group.isHidden() ? mHides : items).add(group); @@ -409,7 +314,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void setWidth(Live live) { int padding = ResUtil.dp2px(52); - if (live.getWidth() == 0) for (Group item : live.getGroups()) live.setWidth(Math.max(live.getWidth(), ResUtil.getTextWidth(item.getDisplayName(), 16))); + if (live.getWidth() == 0) for (Group item : live.getGroups()) live.setWidth(Math.max(live.getWidth(), ResUtil.getTextWidth(item.getName(), 16))); int width = live.getWidth() == 0 ? 0 : Math.min(live.getWidth() + padding, ResUtil.getScreenWidth() / 4); setWidth(mBinding.group, width); } @@ -513,11 +418,8 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void onSpeed() { if (!player().isVod()) return; - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || !player().isVod()) return; - mBinding.control.action.speed.setText(player().setSpeed(speed)); - PlayerSetting.putDefaultSpeed(player().getSpeed()); - }); + mBinding.control.action.speed.setText(player().addSpeed()); + PlayerSetting.putDefaultSpeed(player().getSpeed()); } private void onSpeedAdd() { @@ -648,37 +550,14 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli setTrackVisible(); } - @Override - protected boolean onSourceHttpError(int statusCode, String msg) { - // HTTP 播放错误优先交给直播回退流程:开启自动换线或接口回退任一时短路播放器重试链。 - if (!LiveSetting.isChange() && !LiveSetting.isSourceFallback()) return false; - onError(msg); - return true; - } - @Override protected void onError(String msg) { - App.removeCallbacks(mBufferingTimeout); Track.delete(player().getKey()); player().resetTrack(); player().reset(); player().stop(); - if (!mFailedThisSession) { - mFailedThisSession = true; - showError(msg); - startFlow(); - } - } - - /** - * 缓冲超时(既没出画面也没报错):等价于本条线路播放失败。 - *

- * 与 onError 不同,本路径不受 mFailedThisSession 限制:上一轮换线后新线路若一直卡在 - * 解析/缓冲阶段,mFailedThisSession 仍为 true,但此时必须仍能继续换线; - * 确实无线路可换(单线路或本轮已绕回起点)时给出可见的失败提示,避免进度条一直转。 - */ - private void onBufferingTimeout() { - if (!startFlow()) showError(ResUtil.getString(R.string.error_play_url)); + showError(msg); + startFlow(); } @Override @@ -705,14 +584,11 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli protected void onStateChanged(int state) { switch (state) { case Player.STATE_BUFFERING: - mFailedThisSession = false; showProgress(); break; case Player.STATE_READY: hideProgress(); player().reset(); - mFailedThisSession = false; - resetLineFallback(); break; case Player.STATE_ENDED: checkEnded(); @@ -723,7 +599,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli @Override protected void onPlayingChanged(boolean isPlaying) { - if (isPlaying) App.removeCallbacks(mBufferingTimeout); if (isPlaying || isPaused()) updatePlayControl(isPlaying); } @@ -785,12 +660,11 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli } private void showControl(View view) { - // 与 mobile 一致:唤出控制栏时重算轨道类按钮,用户重新开启后不必等下一次轨道变化。 - if (service() != null) setTrackVisible(); - setPlayParamsState(); mBinding.control.getRoot().setVisibility(View.VISIBLE); - // 控制栏显示时统一由 PlayerOsdController 显示标题、分辨率和时间,避免旧栏重影 - mBinding.widget.top.setVisibility(View.GONE); + // OSD 启用时,不显示 widget.top(避免与 OSD 的 topLeft/topRight 重影) + if (!PlayerSetting.isOsdEnabled()) { + mBinding.widget.top.setVisibility(View.VISIBLE); + } if (mOsd != null) mOsd.setControlsVisible(true); App.post(view::requestFocus, 25); setR1Callback(); @@ -804,20 +678,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli App.removeCallbacks(mR1); } - private void onPlayParams() { - if (mOsd == null) return; - boolean visible = !mOsd.isDiagnosticsVisible(); - PlayerSetting.putOsdDiagnostics(visible); - mOsd.setDiagnosticsVisible(visible); - setPlayParamsState(); - hideControl(); - } - - private void setPlayParamsState() { - mBinding.control.action.playParams.setSelected(mOsd != null && mOsd.isDiagnosticsVisible()); - applyActionButtonVisibility(); - } - private void hideCenter() { mBinding.widget.action.setImageResource(R.drawable.ic_widget_play); mBinding.widget.center.setVisibility(View.GONE); @@ -933,7 +793,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void setChannel(Channel item) { App.post(mR0, 100); mChannel = item; - resetLineFallback(); setArtwork(); showInfo(); } @@ -972,8 +831,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void fetch(EpgData item) { if (mChannel == null) return; App.removeCallbacks(mEndRetry); - App.removeCallbacks(mBufferingTimeout); - App.post(mBufferingTimeout, LIVE_BUFFERING_TIMEOUT); playbackCatchup = true; mViewModel.getUrl(mChannel, item); player().clear(); @@ -984,8 +841,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void fetch() { if (mChannel == null) return; App.removeCallbacks(mEndRetry); - App.removeCallbacks(mBufferingTimeout); - App.post(mBufferingTimeout, LIVE_BUFFERING_TIMEOUT); playbackCatchup = false; LiveConfig.get().setKeep(mChannel); mViewModel.getUrl(mChannel); @@ -1018,14 +873,11 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli } private void handleSameReloadUrl(String msg) { - // 同 URL 重载失败等价于该线路播放失败:走带绕圈/耗尽保护的自动换线, - // 不重置本轮回退状态,避免 reload 路径重新开启死循环。 - // 沿用重载路径既有语义(不校验用户的自动换线开关),只补齐保护: - // 换不动(单线路或本轮已绕回起点)时必须给出可见失败反馈, - // 否则 fetch() 刚 showProgress() 的进度条会一直转、错误文案永远不出现。 - if (advanceLineForFallback()) return; - App.removeCallbacks(mBufferingTimeout); - showError(msg); + if (mChannel != null && !mChannel.isOnly()) { + nextLine(true); + } else { + onError(msg); + } } private void resetAdapter() { @@ -1074,11 +926,11 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli @Override public void setLive(Live item) { + if (item.isSelected()) item.getGroups().clear(); LiveConfig.get().setHome(item); player().reset(); player().clear(); player().stop(); - resetLineFallback(); resetAdapter(); hideControl(); getLive(); @@ -1126,7 +978,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mBinding.control.action.audio.setVisibility(player().haveTrack(C.TRACK_TYPE_AUDIO) ? View.VISIBLE : View.GONE); mBinding.control.action.video.setVisibility(player().haveTrack(C.TRACK_TYPE_VIDEO) ? View.VISIBLE : View.GONE); mBinding.control.action.speed.setVisibility(player().isVod() ? View.VISIBLE : View.GONE); - applyActionButtonVisibility(); } private MediaMetadata buildMetadata() { @@ -1138,74 +989,9 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli player().setMetadata(buildMetadata()); } - /** - * 线路播放失败的自动回退:只在当前频道的线路间轮换(含最后一条绕回), - * 试完一圈即停,绝不因播放失败直接跳到配置里的下一个直播接口。 - * 调用点:onError、缓冲超时(mBufferingTimeout -> onBufferingTimeout)。 - *

- * 本方法只负责「还能不能再换一条线路」,换不动时返回 false, - * 失败文案由调用方负责显示。 - * - * @return true 表示已换到另一条线路并重新拉流 - */ - private boolean startFlow() { - LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decideLineFailure( - LiveSetting.isChange(), - mChannel != null, - mChannel != null && !mChannel.isOnly()); - return action == LiveSourceFallbackPolicy.Action.NEXT_LINE && advanceLineForFallback(); - } - - /** - * 直播接口节目列表拉取失败(renderLive 空结果)时的回退:只有此时才直接 - * 跳转到配置里的下一个直播接口。 - */ - private void startSourceFallback() { - Live next = LiveConfig.getNextHome(); - LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decideSourceFailure( - LiveSetting.isSourceFallback(), - next != null); - if (action == LiveSourceFallbackPolicy.Action.NEXT_SOURCE) setLive(next); - } - - /** - * 播放失败自动换线:从失败起点开始轮换,绕回起点即本轮所有线路都已试过,置位 - * exhausted 停止后续回退;否则刷新线路信息并重新拉流。 - *

- * 本方法是唯一改动 mChannel 线路下标的地方,因此 exhausted 短路必须放在这里: - * 无论从 onError、缓冲超时还是同 URL 重载进来,都不可能再开启新一轮 - * A→B→A 死循环(否则 anchor 未清时换线会重新绕回起点并重新拉流)。 - * - * @return true 表示已换到另一条线路并重新拉流;false 表示没有可换的线路 - * (单线路)或本轮线路已绕回起点(回退耗尽),调用方需要自行给出失败反馈。 - */ - private boolean advanceLineForFallback() { - if (isLineFallbackExhausted()) return false; - if (mChannel == null || mChannel.isOnly()) return false; - if (mLineFallbackAnchor < 0) mLineFallbackAnchor = mChannel.getIndex(); - mChannel.switchLine(true); - if (mChannel.getIndex() == mLineFallbackAnchor) { - mLineFallbackExhausted = true; - return false; - } - showInfo(); - fetch(); - return true; - } - - /** - * 播放失败回退已耗尽(本轮所有线路都试过且都失败)时不再自动换线,避免死循环。 - */ - private boolean isLineFallbackExhausted() { - return mLineFallbackExhausted; - } - - /** - * 手动换线、换台、换直播接口或播放成功后,重置失败回退轮换状态。 - */ - private void resetLineFallback() { - mLineFallbackAnchor = -1; - mLineFallbackExhausted = false; + private void startFlow() { + if (!LiveSetting.isChange()) return; + if (!mChannel.isLast()) nextLine(true); } private void prevChannel() { @@ -1261,7 +1047,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void prevLine() { if (mChannel == null || mChannel.isOnly()) return; - resetLineFallback(); mChannel.switchLine(false); showInfo(); fetch(); @@ -1269,7 +1054,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void nextLine(boolean show) { if (mChannel == null || mChannel.isOnly()) return; - resetLineFallback(); mChannel.switchLine(true); if (show) showInfo(); else setInfo(); @@ -1388,7 +1172,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mOsd.setDiagnosticsVisible(PlayerSetting.isOsdDiagnostics()); mOsd.start(); } - setPlayParamsState(); } @Override @@ -1425,7 +1208,6 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli Source.get().exit(); App.removeCallbacks(mR0, mR1, mR2, mR3, mR4); App.removeCallbacks(mEndRetry); - App.removeCallbacks(mBufferingTimeout); if (mOsd != null) mOsd.release(); mViewModel.url().removeObserver(mObserveUrl); mViewModel.epg().removeObserver(mObserveEpg); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java index 6683e58f0c..21c98c6bd7 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingActivity.java @@ -18,17 +18,17 @@ import com.fongmi.android.tv.bean.Live; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.databinding.ActivitySettingBinding; import com.fongmi.android.tv.db.AppDatabase; -import com.fongmi.android.tv.setting.ConfigSyncPolicy; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.impl.ConfigListener; import com.fongmi.android.tv.impl.LiveListener; import com.fongmi.android.tv.impl.SiteListener; +import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.AboutDialog; -import com.fongmi.android.tv.ui.dialog.AppearanceDialog; +import com.fongmi.android.tv.ui.dialog.ConfigDialog; import com.fongmi.android.tv.ui.dialog.DohDialog; import com.fongmi.android.tv.ui.dialog.HistoryDialog; import com.fongmi.android.tv.ui.dialog.LiveDialog; @@ -36,12 +36,10 @@ import com.fongmi.android.tv.ui.dialog.RestoreDialog; import com.fongmi.android.tv.ui.dialog.BackupProgressDialog; import com.fongmi.android.tv.ui.dialog.SiteDialog; import com.fongmi.android.tv.utils.AppVersion; -import com.fongmi.android.tv.cache.CacheCenter; -import com.fongmi.android.tv.cache.CachePolicyStore; -import com.fongmi.android.tv.ui.dialog.CacheManagementDialog; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; +import com.fongmi.android.tv.utils.ResUtil; import com.github.catvod.bean.Doh; import com.github.catvod.net.OkHttp; @@ -54,6 +52,8 @@ import java.util.List; public class SettingActivity extends BaseActivity implements ConfigListener, SiteListener, LiveListener, DohDialog.Listener { private ActivitySettingBinding mBinding; + private String[] size; + private String[] language; public static void start(Activity activity) { activity.startActivity(new Intent(activity, SettingActivity.class)); @@ -92,26 +92,15 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit private void setOtherText() { mBinding.dohText.setText(getDohList()[getDohIndex()]); mBinding.incognitoText.setText(getSwitch(Setting.isIncognito())); + mBinding.languageText.setText((language = ResUtil.getStringArray(R.array.select_language))[Setting.getLanguageIndex()]); + mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[PlayerSetting.getSize()]); } private void setCacheText() { - if (!CachePolicyStore.isManagementEnabled()) { - FileUtil.getCacheSize(new Callback() { - @Override - public void success(String result) { - if (mBinding != null) mBinding.cacheText.setText(result); - } - }); - return; - } - mBinding.cacheText.setText(R.string.cache_management_scanning); - CacheCenter.get().requestSnapshot(false, snapshot -> { - if (mBinding != null && !isFinishing() && !isDestroyed()) { - String total = FileUtil.byteCountToDisplaySize(snapshot.totalBytes()); - long quota = snapshot.systemQuotaBytes(); - mBinding.cacheText.setText(quota > 0 - ? getString(R.string.cache_management_inline, total, FileUtil.byteCountToDisplaySize(quota)) - : total); + FileUtil.getCacheSize(new Callback() { + @Override + public void success(String result) { + mBinding.cacheText.setText(result); } }); } @@ -122,11 +111,9 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit mBinding.doh.setOnClickListener(this::setDoh); mBinding.live.setOnClickListener(this::onLive); mBinding.wall.setOnClickListener(this::onWall); - mBinding.cache.setOnClickListener(this::onCacheClear); - // The plain click restores the pre-split one-key clear; the panel moved to the long press. - // The row stays single-line by user request, so §15.2's "not long-press only" intent is a - // recorded deviation rather than a printed hint (docs/CACHE-MGMT-01-cache-management-design.md). - mBinding.cache.setOnLongClickListener(this::onCacheManage); + mBinding.size.setOnClickListener(this::setSize); + mBinding.language.setOnClickListener(this::setLanguage); + mBinding.cache.setOnClickListener(this::onCache); mBinding.backup.setOnClickListener(this::onBackup); mBinding.enhance.setOnClickListener(this::onEnhance); mBinding.tmdb.setOnClickListener(this::onTmdb); @@ -135,12 +122,14 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit mBinding.player.setOnClickListener(this::onPlayer); mBinding.danmaku.setOnClickListener(this::onDanmaku); mBinding.subtitle.setOnClickListener(this::onSubtitle); - mBinding.appearance.setOnClickListener(this::onAppearance); mBinding.personal.setOnClickListener(this::onPersonal); mBinding.restore.setOnClickListener(this::onRestore); mBinding.version.setOnClickListener(this::onVersion); + mBinding.vod.setOnLongClickListener(this::onVodEdit); mBinding.vodHome.setOnClickListener(this::onVodHome); + mBinding.live.setOnLongClickListener(this::onLiveEdit); mBinding.liveHome.setOnClickListener(this::onLiveHome); + mBinding.wall.setOnLongClickListener(this::onWallEdit); mBinding.incognito.setOnClickListener(this::setIncognito); mBinding.vodHistory.setOnClickListener(this::onVodHistory); mBinding.liveHistory.setOnClickListener(this::onLiveHistory); @@ -163,12 +152,7 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit private void load(Config config) { switch (config.getType()) { case 0: - String previousVodUrl = VodConfig.getUrl(); VodConfig.load(config, getCallback()); - if (ConfigSyncPolicy.shouldSyncLive(previousVodUrl, LiveConfig.getUrl())) { - Config liveConfig = AppDatabase.get().getConfigDao().find(config.getUrl(), 1); - if (liveConfig != null) LiveConfig.load(liveConfig, new Callback()); - } break; case 1: LiveConfig.load(config, getCallback()); @@ -212,15 +196,30 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit } private void onVod(View view) { - HistoryDialog.create().vod().manage().show(this); + ConfigDialog.create().vod().show(this); } private void onLive(View view) { - HistoryDialog.create().live().manage().show(this); + ConfigDialog.create().live().show(this); } private void onWall(View view) { - HistoryDialog.create().wall().manage().show(this); + ConfigDialog.create().wall().show(this); + } + + private boolean onVodEdit(View view) { + ConfigDialog.create().vod().edit().show(this); + return true; + } + + private boolean onLiveEdit(View view) { + ConfigDialog.create().live().edit().show(this); + return true; + } + + private boolean onWallEdit(View view) { + ConfigDialog.create().wall().edit().show(this); + return true; } private void onVodHome(View view) { @@ -267,10 +266,6 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit SettingSubtitleActivity.start(this); } - private void onAppearance(View view) { - AppearanceDialog.show(this); - } - private void onPersonal(View view) { SettingPersonalActivity.start(this); } @@ -301,6 +296,19 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit mBinding.incognitoText.setText(getSwitch(Setting.isIncognito())); } + private void setSize(View view) { + int index = (PlayerSetting.getSize() + 1) % size.length; + mBinding.sizeText.setText(size[index]); + PlayerSetting.putSize(index); + RefreshEvent.size(); + } + + private void setLanguage(View view) { + int index = (Setting.getLanguageIndex() + 1) % language.length; + Setting.putLanguageIndex(index); + RefreshEvent.language(); + } + private void setDoh(View view) { DohDialog.create().index(getDohIndex()).show(this); } @@ -312,45 +320,13 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit mBinding.dohText.setText(doh.getName()); } - /** - * The settings row's plain click: clear every cache at once, the way this row behaved before it - * opened the cache management panel. - * - *

The full clean runs in the background and reports itself through a notification without - * opening the panel; the row re-reads itself when the cleanup publishes its change. With cache - * management switched off the legacy {@link FileUtil#clearCache} path is kept, because the panel - * and its cleanup engine belong to the feature that switch turns off.

- */ - private void onCacheClear(View view) { - if (!CachePolicyStore.isManagementEnabled()) { - FileUtil.clearCache(new Callback() { - @Override - public void success() { - setCacheText(); - } - }); - return; - } - CacheManagementDialog.cleanEverything(); - } - - /** - * The settings row's long press: open the cache management panel (graded cleanup, module - * limits, auto cleanup and the running state). - * - *

With the plain click now clearing everything this is the panel's only entry. The row stays - * single-line by user request, so the gesture is not printed under the title and the panel is - * reached by the long press alone (deviation recorded in - * docs/CACHE-MGMT-01-cache-management-design.md). With cache management switched off there is - * no panel to open, so the long press keeps the legacy behaviour and repeats the plain clear.

- */ - private boolean onCacheManage(View view) { - if (!CachePolicyStore.isManagementEnabled()) { - onCacheClear(view); - return true; - } - CacheManagementDialog.show(this); - return true; + private void onCache(View view) { + FileUtil.clearCache(new Callback() { + @Override + public void success() { + setCacheText(); + } + }); } private void onBackup(View view) { @@ -415,19 +391,6 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit setWallText(); } - /** - * Re-reads the cache inventory the moment a cleanup (or any other cache mutation) finishes. - * - *

Without this the settings row kept the pre-cleanup value until the user left the activity - * and opened it again, because nothing told the row the cache had changed.

- */ - @Subscribe(threadMode = ThreadMode.MAIN) - public void onRefreshEvent(RefreshEvent event) { - if (event.getType() != RefreshEvent.Type.CACHE) return; - if (mBinding == null || isFinishing() || isDestroyed()) return; - setCacheText(); - } - private void setWallText() { mBinding.wallUrl.setText(Setting.getWallDesc(WallConfig.getDesc())); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java index e5f92ea41c..83424b1c50 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java @@ -2,29 +2,56 @@ package com.fongmi.android.tv.ui.activity; import android.app.Activity; import android.content.Intent; +import android.net.Uri; import android.os.Bundle; -import android.util.TypedValue; +import android.text.InputType; +import android.view.Gravity; import android.view.View; +import android.widget.EditText; import android.widget.TextView; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.app.AlertDialog; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; +import com.fongmi.android.tv.ad.audio.AdAudioRuleSnapshot; +import com.fongmi.android.tv.ad.audio.AdAudioRuleStore; +import com.fongmi.android.tv.ad.audio.AdAudioSetting; +import com.fongmi.android.tv.ad.audio.AdSkipPolicyController; +import com.fongmi.android.tv.ad.audio.ProbeRuleDownloader; +import com.fongmi.android.tv.ad.audio.ProbeRuleStore; +import com.fongmi.android.tv.ad.audio.SpeechAdConfig; +import com.fongmi.android.tv.ad.audio.SpeechAdSetting; 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.databinding.ActivitySettingAdBinding; -import com.fongmi.android.tv.player.IntroSkipKinds; +import com.fongmi.android.tv.server.Server; +import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.subtitle.RealtimeSubtitleSpeechRecognitionFactory; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.AdBlockStatsDialog; import com.fongmi.android.tv.ui.dialog.AdRuleManageDialog; +import com.fongmi.android.tv.ui.dialog.LightDialog; +import com.fongmi.android.tv.utils.Notify; +import com.fongmi.android.tv.utils.Task; import com.github.catvod.crawler.SpiderDebug; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.Locale; public class SettingAdActivity extends BaseActivity { private ActivitySettingAdBinding mBinding; private String[] introSkipMode; + private volatile AdAudioRuleSnapshot adAudioSnapshot = AdAudioRuleStore.get().current(); + private volatile long probeRevision; + private volatile int probeRuleCount; + private final ActivityResultLauncher adAudioRulePicker = + registerForActivityResult(new ActivityResultContracts.OpenDocument(), this::importAdAudioRules); public static void start(Activity activity) { activity.startActivity(new Intent(activity, SettingAdActivity.class)); @@ -44,6 +71,8 @@ public class SettingAdActivity extends BaseActivity { mBinding.adblock.requestFocus(); introSkipMode = getResources().getStringArray(R.array.select_auto_skip_intro_outro); setText(); + loadAdAudioRules(); + loadProbeRules(); } @Override @@ -52,8 +81,34 @@ public class SettingAdActivity extends BaseActivity { mBinding.aiAdDetection.setOnClickListener(this::setAiAdDetection); mBinding.adRuleManage.setOnClickListener(view -> AdRuleManageDialog.create().show(this, this::setText)); mBinding.adBlockStats.setOnClickListener(view -> AdBlockStatsDialog.create(this).show()); + mBinding.adAudioFingerprint.setOnClickListener(this::toggleAdAudioFingerprint); + mBinding.adAudioFingerprint.setOnLongClickListener(this::manageAdAudioRules); + mBinding.adAudioAutoSkip.setOnClickListener(this::toggleAdAudioAutoSkip); + mBinding.probeRuleSource.setOnClickListener(this::editProbeRuleSource); + mBinding.probeRuleRefresh.setOnClickListener(this::refreshProbeRules); + mBinding.speechAdEnabled.setOnClickListener(this::toggleSpeechAdEnabled); + mBinding.speechAdKeywords.setOnClickListener(this::editSpeechAdKeywords); + mBinding.speechAdSkipSeconds.setOnClickListener(this::editSpeechAdSkipSeconds); + mBinding.speechAdSkipMode.setOnClickListener(this::selectSpeechAdSkipMode); mBinding.autoSkipIntroOutro.setOnClickListener(this::setAutoSkipIntroOutro); - mBinding.introSkipKinds.setOnClickListener(view -> IntroSkipKinds.show(this, this::setText)); + } + + /** 规则文件要读盘并完整解析,放后台线程,取回后再刷新摘要。 */ + private void loadAdAudioRules() { + Task.execute(() -> { + adAudioSnapshot = AdAudioRuleStore.get().load(); + runOnUiThread(this::setText); + }); + } + + private void loadProbeRules() { + Task.execute(() -> { + ProbeRuleStore store = ProbeRuleStore.get(); + AdAudioRuleSnapshot snapshot = store.load(); + probeRuleCount = snapshot.ruleSet().rules().size(); + probeRevision = store.revision(); + runOnUiThread(this::setText); + }); } private void setText() { @@ -64,8 +119,17 @@ public class SettingAdActivity extends BaseActivity { safeSet("adRuleManage", mBinding.adRuleManageText, () -> getString(R.string.ad_rule_count_with_pending, UserAdRuleStore.load().size() + RuleConfig.get().getDefaultRules().size(), ImportedAdRuleCandidateStore.pending().size())); + safeSet("adAudioFingerprint", mBinding.adAudioFingerprintText, this::getAdAudioFingerprintText); + safeSet("adAudioAutoSkip", mBinding.adAudioAutoSkipText, () -> getSwitch(AdAudioSetting.isAutoSkipEnabled())); + safeSet("probeRuleSource", mBinding.probeRuleSourceText, this::getProbeRuleSourceText); + safeSet("probeRuleRefresh", mBinding.probeRuleRefreshText, this::getProbeRuleRefreshText); + SpeechAdConfig speech = SpeechAdSetting.snapshot(); + safeSet("speechAdEnabled", mBinding.speechAdEnabledText, () -> getSpeechAdEnabledText(speech)); + safeSet("speechAdKeywords", mBinding.speechAdKeywordsText, () -> getString(R.string.speech_ad_keyword_count, speech.keywords().values().size())); + safeSet("speechAdSkipSeconds", mBinding.speechAdSkipSecondsText, () -> getString(R.string.speech_ad_skip_seconds_value, speech.skipSeconds())); + safeSet("speechAdSkipMode", mBinding.speechAdSkipModeText, () -> speech.mode() == AdSkipPolicyController.Mode.AUTO + ? getString(R.string.speech_ad_skip_mode_auto) : getString(R.string.speech_ad_skip_mode_prompt)); safeSet("autoSkipIntroOutro", mBinding.autoSkipIntroOutroText, () -> introSkipMode[Setting.getIntroSkipMode()]); - safeSet("introSkipKinds", mBinding.introSkipKindsText, IntroSkipKinds::summary); } private boolean canSetText() { @@ -73,22 +137,7 @@ public class SettingAdActivity extends BaseActivity { } private void safeSet(String name, TextView view, TextSupplier supplier) { - safeRun(name, () -> setSummary(view, supplier.get()), () -> setError(view)); - } - - /** - * 写入取值摘要,并把颜色复位到常规壁纸前景色。 - * - *

复位这一步是必需的:{@link #setError} 会把同一个 TextView 染成错误色,而这些摘要 - * 全部**复用同一个控件**——不复位的话,某一行失败一次之后,即使后续刷新成功, - * 它也会一直显示为红色。 - */ - private void setSummary(TextView view, CharSequence text) { - if (view == null) return; - view.setText(text); - view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaper)); - // 撤掉错误底材:错误态是"临时"的,刷新成功必须完全回到原样(这些 TextView 原本没有背景)。 - view.setBackground(null); + safeRun(name, () -> view.setText(supplier.get()), () -> setError(view)); } private void safeRun(String name, Runnable action, Runnable fallback) { @@ -104,32 +153,8 @@ public class SettingAdActivity extends BaseActivity { } } - /** - * 内联错误:设计文档 §9.9 要求错误态有「错误色」。 - * - *

不能直接用 {@code ?attr/colorError}:那是跟随调色板的角色,浅色表的深红 - * ({@code #B3261E})压在深色壁纸行色带上实测只有 1.06:1,完全不可读。这里用与壁纸 - * 同族的 {@code ?attr/webhtvColorOnWallpaperError}(同一底材实测 4.05–4.50:1)。 - * - *

底材:错误色直接画在行色带上只有 4.05–4.50:1,略低于正文门槛 4.5:1,而继续调色无解 - * (要达标就得接近纯白,错误色随之失去语义)。因此这里同时换上一块**实心深底** - * ({@code @drawable/shape_error_value}),核算为 8.84:1,稳过门槛。 - * - *

如实记录:该取值在行色带最亮处是 4.05:1,**略低于正文门槛 4.5:1**。这是底材 - * (中亮度壁纸)决定的取舍,理由与替代方案见 {@code values/webhtv_tokens.xml} 的同名条目 - * 与 docs/TV-ERROR-STATE-20261012.md §7。 - */ private void setError(TextView view) { - if (view == null) return; - view.setText(R.string.error_config_get); - view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaperError)); - view.setBackgroundResource(R.drawable.shape_error_value); - } - - /** 解析主题属性里的颜色值;未赋值时返回 0(透明)。 */ - private int themeColor(int attr) { - TypedValue value = new TypedValue(); - return getTheme().resolveAttribute(attr, value, true) ? value.data : 0; + if (view != null) view.setText(R.string.error_config_get); } private interface TextSupplier { @@ -151,6 +176,258 @@ public class SettingAdActivity extends BaseActivity { setText(); } + private String getAdAudioFingerprintText() { + String enabled = getSwitch(AdAudioSetting.isEnabled()); + AdAudioRuleSnapshot snapshot = adAudioSnapshot; + if (snapshot == null || snapshot.hasError()) { + return enabled + " · " + getString(R.string.setting_ad_audio_rule_error); + } + int count = snapshot.ruleSet().rules().size(); + if (count == 0) return enabled + " · " + getString(R.string.setting_ad_audio_no_rules); + return enabled + " · " + getString(R.string.setting_ad_audio_rule_count, count, snapshot.version()); + } + + private void toggleAdAudioFingerprint(View view) { + AdAudioSetting.setEnabled(!AdAudioSetting.isEnabled()); + notifyAdAudioRuntime(); + setText(); + } + + private void toggleAdAudioAutoSkip(View view) { + AdAudioSetting.setAutoSkipEnabled(!AdAudioSetting.isAutoSkipEnabled()); + notifyAdAudioRuntime(); + setText(); + } + + private boolean manageAdAudioRules(View view) { + new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.setting_ad_audio_title) + .setItems(new String[]{ + getString(R.string.setting_ad_audio_import), + getString(R.string.setting_ad_audio_clear) + }, (dialog, which) -> { + if (which == 0) { + adAudioRulePicker.launch(new String[]{"application/json", "text/json"}); + } else { + confirmClearAdAudioRules(); + } + }) + .setNegativeButton(R.string.dialog_negative, null) + .show(); + return true; + } + + private void importAdAudioRules(Uri uri) { + if (uri == null) return; + Task.execute(() -> { + String message; + try { + adAudioSnapshot = AdAudioRuleStore.get().importUri(getContentResolver(), uri); + message = getString(R.string.setting_ad_audio_imported, + adAudioSnapshot.ruleSet().rules().size(), adAudioSnapshot.version()); + } catch (Exception e) { + message = getString(R.string.setting_ad_audio_import_failed); + } + String result = message; + runOnUiThread(() -> { + if (!canSetText()) return; + Notify.show(result); + notifyAdAudioRuntime(); + setText(); + }); + }); + } + + private void confirmClearAdAudioRules() { + new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.setting_ad_audio_clear) + .setMessage(R.string.setting_ad_audio_clear_confirm) + .setPositiveButton(R.string.dialog_positive, (dialog, which) -> Task.execute(() -> { + try { + adAudioSnapshot = AdAudioRuleStore.get().clear(); + runOnUiThread(() -> { + if (!canSetText()) return; + Notify.show(R.string.setting_ad_audio_clear_done); + notifyAdAudioRuntime(); + setText(); + }); + } catch (RuntimeException e) { + runOnUiThread(() -> Notify.show(R.string.setting_ad_audio_import_failed)); + } + })) + .setNegativeButton(R.string.dialog_negative, null) + .show(); + } + + private String getProbeRuleSourceText() { + String url = AdAudioSetting.getProbeRuleUrl(); + if (url.isEmpty()) return getString(R.string.setting_ad_probe_source_off); + if (url.equals(AdAudioSetting.DEFAULT_PROBE_RULE_URL)) return getString(R.string.setting_ad_probe_source_default); + return url; + } + + private String getProbeRuleRefreshText() { + if (probeRevision <= 0L) return getString(R.string.setting_ad_probe_never); + return getString(R.string.setting_ad_probe_revision, probeRevision, probeRuleCount); + } + + private void editProbeRuleSource(View view) { + EditText input = new EditText(this); + input.setSingleLine(true); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + input.setText(AdAudioSetting.getProbeRuleUrl()); + input.setSelection(input.length()); + AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.setting_ad_probe_source) + .setMessage(R.string.setting_ad_probe_source_hint) + .setView(input) + .setNegativeButton(R.string.dialog_negative, null) + .setPositiveButton(R.string.dialog_positive, null) + .create(); + dialog.setOnShowListener(ignored -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(button -> { + String value = input.getText().toString().trim(); + // 空串表示关闭社区规则源;规则不签名,传输层是唯一保障,因此只收 https。 + if (!value.isEmpty() && !value.toLowerCase(Locale.ROOT).startsWith("https://")) { + Notify.show(R.string.setting_ad_probe_url_invalid); + return; + } + AdAudioSetting.setProbeRuleUrl(value); + setText(); + dialog.dismiss(); + })); + dialog.show(); + LightDialog.apply(dialog); + } + + private void refreshProbeRules(View view) { + mBinding.probeRuleRefreshText.setText(R.string.setting_ad_probe_refreshing); + boolean started = ProbeRuleDownloader.refreshNow(new ProbeRuleDownloader.Callback() { + @Override + public void onSuccess(AdAudioRuleSnapshot snapshot) { + if (!canSetText()) return; + Notify.show(snapshot.hasRules() + ? getString(R.string.setting_ad_probe_refreshed, snapshot.version(), snapshot.ruleSet().rules().size()) + : getString(R.string.setting_ad_probe_unchanged, 0)); + notifyAdAudioRuntime(); + loadProbeRules(); + } + + @Override + public void onFailure(Throwable error) { + if (!canSetText()) return; + Notify.show(getString(R.string.setting_ad_probe_failed, String.valueOf(error.getMessage()))); + setText(); + } + + @Override + public void onDisabled() { + if (!canSetText()) return; + Notify.show(R.string.setting_ad_probe_source_disabled); + setText(); + } + }); + // 已有刷新在跑时不会有回调,这里必须自己复位,否则文本永远停在「正在刷新」。 + if (!started) setText(); + } + + private String getSpeechAdEnabledText(SpeechAdConfig speech) { + String text = getSwitch(speech.enabled()); + if (speech.enabled() && !RealtimeSubtitleSpeechRecognitionFactory.isSelectedModelReady()) { + text += " · " + getString(R.string.speech_ad_model_not_ready); + } + return text; + } + + private void toggleSpeechAdEnabled(View view) { + SpeechAdSetting.setEnabled(!SpeechAdSetting.snapshot().enabled()); + notifyAdAudioRuntime(); + setText(); + } + + private void editSpeechAdKeywords(View view) { + SpeechAdConfig speech = SpeechAdSetting.snapshot(); + EditText input = new EditText(this); + input.setGravity(Gravity.TOP | Gravity.START); + input.setMinLines(4); + input.setMaxLines(8); + input.setSingleLine(false); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + input.setText(String.join("\n", speech.keywords().values())); + input.setSelectAllOnFocus(false); + AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.speech_ad_keywords) + .setView(input) + .setNegativeButton(R.string.dialog_negative, null) + .setPositiveButton(R.string.dialog_positive, null) + .create(); + dialog.setOnShowListener(ignored -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(button -> { + SpeechAdSetting.setKeywords(input.getText().toString()); + notifyAdAudioRuntime(); + setText(); + dialog.dismiss(); + })); + dialog.show(); + LightDialog.apply(dialog); + } + + private void editSpeechAdSkipSeconds(View view) { + EditText input = new EditText(this); + input.setInputType(InputType.TYPE_CLASS_NUMBER); + input.setSelectAllOnFocus(true); + input.setText(String.valueOf(SpeechAdSetting.snapshot().skipSeconds())); + AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.speech_ad_skip_seconds) + .setView(input) + .setNegativeButton(R.string.dialog_negative, null) + .setPositiveButton(R.string.dialog_positive, null) + .create(); + dialog.setOnShowListener(ignored -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(button -> { + String value = input.getText().toString().trim(); + try { + int seconds = Integer.parseInt(value); + if (seconds < 1 || seconds > 120) throw new NumberFormatException(); + SpeechAdSetting.setSkipSeconds(seconds); + notifyAdAudioRuntime(); + setText(); + dialog.dismiss(); + } catch (NumberFormatException error) { + input.setError(getString(R.string.speech_ad_skip_seconds_invalid)); + input.requestFocus(); + } + })); + dialog.show(); + LightDialog.apply(dialog); + } + + private void selectSpeechAdSkipMode(View view) { + SpeechAdConfig speech = SpeechAdSetting.snapshot(); + String[] modes = { + getString(R.string.speech_ad_skip_mode_prompt), + getString(R.string.speech_ad_skip_mode_auto) + }; + int checked = speech.mode() == AdSkipPolicyController.Mode.AUTO ? 1 : 0; + AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.speech_ad_skip_mode) + .setNegativeButton(R.string.dialog_negative, null) + .setSingleChoiceItems(modes, checked, (shown, which) -> { + SpeechAdSetting.setMode(which == 1 + ? AdSkipPolicyController.Mode.AUTO + : AdSkipPolicyController.Mode.PROMPT); + notifyAdAudioRuntime(); + setText(); + shown.dismiss(); + }) + .create(); + dialog.show(); + LightDialog.apply(dialog); + } + + private void notifyAdAudioRuntime() { + PlaybackService service = Server.get().getService(); + if (service == null || service.player() == null || service.player().isReleased()) return; + service.player().reloadAdAudioSettings(); + } + @Override protected void onResume() { super.onResume(); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAiActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAiActivity.java index 1d7c06b8b6..665d3f822e 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAiActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAiActivity.java @@ -14,13 +14,13 @@ import com.fongmi.android.tv.databinding.ActivitySettingAiBinding; import com.fongmi.android.tv.service.RecommendationFeedbackStore; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.subtitle.RealtimeSubtitleController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.AiConfigDialog; import com.fongmi.android.tv.ui.dialog.RecommendationFeedbackDialog; import com.fongmi.android.tv.ui.dialog.SubtitleSettingsDialog; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class SettingAiActivity extends BaseActivity { @@ -86,7 +86,7 @@ public class SettingAiActivity extends BaseActivity { setText(); return; } - new WebHtvAlertDialogBuilder(this) + new MaterialAlertDialogBuilder(this) .setTitle(R.string.personal_recommendation_confirm_title) .setMessage(R.string.personal_recommendation_confirm_message) .setNegativeButton(R.string.dialog_negative, null) diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAppearanceActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAppearanceActivity.java deleted file mode 100644 index 8383784dd8..0000000000 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAppearanceActivity.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; -import android.view.View; - -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.api.config.WallConfig; -import com.fongmi.android.tv.databinding.ActivitySettingAppearanceBinding; -import com.fongmi.android.tv.event.ConfigEvent; -import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.impl.Callback; -import com.fongmi.android.tv.setting.PlayerSetting; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.ui.base.BaseActivity; -import com.fongmi.android.tv.ui.dialog.HistoryDialog; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.ResUtil; - -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; - -public class SettingAppearanceActivity extends BaseActivity { - - private ActivitySettingAppearanceBinding mBinding; - private String[] uiScale; - private String[] language; - private String[] size; - - public static void start(Activity activity) { - activity.startActivity(new Intent(activity, SettingAppearanceActivity.class)); - } - - @Override - protected ViewBinding getBinding() { - return mBinding = ActivitySettingAppearanceBinding.inflate(getLayoutInflater()); - } - - @Override - protected void initView(Bundle savedInstanceState) { - mBinding.uiScale.requestFocus(); - setText(); - } - - private void setText() { - mBinding.uiScaleText.setText((uiScale = ResUtil.getStringArray(R.array.select_ui_scale))[Setting.getUiScaleIndex()]); - mBinding.languageText.setText((language = ResUtil.getStringArray(R.array.select_language))[Setting.getLanguageIndex()]); - mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[getSizeIndex()]); - setWallText(); - } - - @Override - protected void initEvent() { - mBinding.uiScale.setOnClickListener(this::setUiScale); - mBinding.language.setOnClickListener(this::setLanguage); - mBinding.size.setOnClickListener(this::setSize); - mBinding.wall.setOnClickListener(this::onWall); - mBinding.wallHome.setOnClickListener(this::setWallDefault); - mBinding.wallRefresh.setOnClickListener(this::setWallRefresh); - mBinding.wallHistory.setOnClickListener(this::onWallHistory); - } - - private void setUiScale(View view) { - int index = (Setting.getUiScaleIndex() + 1) % uiScale.length; - Setting.putUiScaleIndex(index); - RefreshEvent.uiScale(); - } - - private void setLanguage(View view) { - int index = (Setting.getLanguageIndex() + 1) % language.length; - Setting.putLanguageIndex(index); - RefreshEvent.language(); - } - - private void setSize(View view) { - int index = (getSizeIndex() + 1) % size.length; - PlayerSetting.putSize(index); - RefreshEvent.size(); - setText(); - } - - private int getSizeIndex() { - return Math.max(0, Math.min(PlayerSetting.getSize(), size.length - 1)); - } - - private void onWall(View view) { - HistoryDialog.create().wall().manage().show(this); - } - - private void setWallDefault(View view) { - Setting.putWall(Setting.nextDefaultWall()); - Setting.putWallType(0); - setWallText(); - ConfigEvent.wall(); - } - - private void setWallRefresh(View view) { - Setting.putWall(0); - WallConfig.get().load(getCallback()); - } - - private boolean onWallHistory(View view) { - HistoryDialog.create().wall().show(this); - return true; - } - - private Callback getCallback() { - return new Callback() { - @Override - public void start() { - Notify.progress(getActivity()); - } - - @Override - public void success() { - Notify.dismiss(); - setWallText(); - } - - @Override - public void error(String msg) { - Notify.dismiss(); - Notify.show(msg); - } - }; - } - - private void setWallText() { - mBinding.wallText.setText(Setting.getWallDesc(WallConfig.getDesc())); - } - - @Subscribe(threadMode = ThreadMode.MAIN) - public void onConfigEvent(ConfigEvent event) { - if (event.type() == ConfigEvent.Type.WALL) { - setWallText(); - } - } -} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingDanmakuActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingDanmakuActivity.java index f6e6e91417..c5237b925d 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingDanmakuActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingDanmakuActivity.java @@ -28,7 +28,6 @@ public class SettingDanmakuActivity extends BaseActivity implements DanmakuListe } private String getApiStatus() { - // 只读展示:用生效地址,本地包兜底也算「已就绪」,但这个值不会被写回 prefs。 return getString(TextUtils.isEmpty(DanmakuSetting.getEffectiveApiUrl()) ? R.string.none : R.string.yes); } @@ -68,7 +67,7 @@ public class SettingDanmakuActivity extends BaseActivity implements DanmakuListe } private void updateAutoVisibility() { - boolean show = DanmakuSetting.isLoad() && (DanmakuSetting.hasValidApiUrl() || DanmakuSetting.isBundleSource()); + boolean show = DanmakuSetting.isLoad() && !TextUtils.isEmpty(DanmakuSetting.getEffectiveApiUrl()); mBinding.danmakuAuto.setVisibility(show ? View.VISIBLE : View.GONE); updateSpiderVisibility(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingEnhanceActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingEnhanceActivity.java index 434a56ecba..fefc6d7d63 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingEnhanceActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingEnhanceActivity.java @@ -4,7 +4,6 @@ import android.app.Activity; import android.content.Intent; import android.net.Uri; import android.os.Bundle; -import android.util.TypedValue; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; @@ -17,7 +16,6 @@ import com.fongmi.android.tv.bean.ShortDramaConfig; import com.fongmi.android.tv.gitcloud.GitCloudAccountStore; import com.fongmi.android.tv.lab.LabActivity; import com.fongmi.android.tv.playback.ViewingRecordSyncStore; -import com.fongmi.android.tv.plugin.PluginSourceConfig; import com.fongmi.android.tv.remote.RemoteStore; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.databinding.ActivitySettingEnhanceBinding; @@ -25,7 +23,6 @@ import com.fongmi.android.tv.setting.CustomCspSetting; import com.fongmi.android.tv.setting.ProxySetting; import com.fongmi.android.tv.setting.SiteHealthStore; import com.fongmi.android.tv.setting.SiteNameStore; -import com.fongmi.android.tv.ui.activity.PluginManagerActivity; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.AudioSourceDialog; import com.fongmi.android.tv.ui.dialog.ShortDramaSourceDialog; @@ -108,7 +105,6 @@ public class SettingEnhanceActivity extends BaseActivity { })); mBinding.loginState.setOnClickListener(view -> LoginStateLearnDialog.show(this, this::setText)); mBinding.oneKeySync.setOnClickListener(v -> OneKeySyncDialog.create().show(this)); - mBinding.pluginManager.setOnClickListener(view -> PluginManagerActivity.start(this)); } private void reorderItems() { @@ -134,8 +130,7 @@ public class SettingEnhanceActivity extends BaseActivity { mBinding.shortDramaSource, mBinding.siteHealthSort, mBinding.debugLog, - mBinding.playbackWebhook, - mBinding.pluginManager + mBinding.playbackWebhook }; for (View view : order) parent.removeView(view); for (View view : order) parent.addView(view); @@ -161,7 +156,6 @@ public class SettingEnhanceActivity extends BaseActivity { safeSet("managePage", mBinding.managePageText, () -> getString(R.string.manage_page_web)); safeSet("remoteTrust", mBinding.remoteTrustText, () -> RemoteStore.summary(this)); safeSet("gitCloud", mBinding.gitCloudText, () -> getString(R.string.git_cloud_account_count, GitCloudAccountStore.list().size())); - safeSet("pluginManager", mBinding.pluginManagerText, () -> getString(R.string.plugin_manager_entry_count, PluginSourceConfig.get().entries().size())); setShellProxyText(); setCustomCspText(); safeSet("loginState", mBinding.loginStateText, () -> { @@ -178,8 +172,8 @@ public class SettingEnhanceActivity extends BaseActivity { private void setShellProxyText() { safeRun("shellProxy", () -> { int count = ProxySetting.count(); - setSummary(mBinding.shellProxyText, getSwitch(Setting.isShellProxy()) + " · " + getString(R.string.setting_proxy_rule_count, count)); - setSummary(mBinding.shellProxyConfigText, getString(R.string.setting_proxy_rule_count, count)); + mBinding.shellProxyText.setText(getSwitch(Setting.isShellProxy()) + " · " + getString(R.string.setting_proxy_rule_count, count)); + mBinding.shellProxyConfigText.setText(getString(R.string.setting_proxy_rule_count, count)); }, () -> { setError(mBinding.shellProxyText); setError(mBinding.shellProxyConfigText); @@ -194,55 +188,12 @@ public class SettingEnhanceActivity extends BaseActivity { return; } CustomCspSetting.Count count = status.count(); - setSummary(mBinding.customCspText, getSwitch(status.enabled()) + " · " + getString(R.string.setting_custom_csp_count, count.active(), count.enabled())); + mBinding.customCspText.setText(getSwitch(status.enabled()) + " · " + getString(R.string.setting_custom_csp_count, count.active(), count.enabled())); }, () -> setError(mBinding.customCspText)); } private void safeSet(String name, TextView view, TextSupplier supplier) { - safeRun(name, () -> setSummary(view, supplier.get()), () -> setError(view)); - } - - /** - * 写入取值摘要,并把颜色复位到常规壁纸前景色。 - * - *

复位这一步是必需的:{@link #setError} 会把同一个 TextView 染成错误色,而这些摘要 - * 全部**复用同一个控件**——不复位的话,某一行失败一次之后,即使后续刷新成功, - * 它也会一直显示为红色。 - */ - private void setSummary(TextView view, CharSequence text) { - if (view == null) return; - view.setText(text); - view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaper)); - // 撤掉错误底材:错误态是"临时"的,刷新成功必须完全回到原样(这些 TextView 原本没有背景)。 - view.setBackground(null); - } - - /** - * 内联错误:设计文档 §9.9 要求错误态有「错误色」。 - * - *

不能直接用 {@code ?attr/colorError}:那是跟随调色板的角色,浅色表的深红 - * ({@code #B3261E})压在深色壁纸行色带上实测只有 1.06:1,完全不可读。这里用与壁纸 - * 同族的 {@code ?attr/webhtvColorOnWallpaperError}(同一底材实测 4.05–4.50:1)。 - * - *

底材:错误色直接画在行色带上只有 4.05–4.50:1,略低于正文门槛 4.5:1,而继续调色无解 - * (要达标就得接近纯白,错误色随之失去语义)。因此这里同时换上一块**实心深底** - * ({@code @drawable/shape_error_value}),核算为 8.84:1,稳过门槛。 - * - *

如实记录:该取值在行色带最亮处是 4.05:1,**略低于正文门槛 4.5:1**。这是底材 - * (中亮度壁纸)决定的取舍,理由与替代方案见 {@code values/webhtv_tokens.xml} 的同名条目 - * 与 docs/TV-ERROR-STATE-20261012.md §7。 - */ - private void setError(TextView view) { - if (view == null) return; - view.setText(R.string.error_config_get); - view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaperError)); - view.setBackgroundResource(R.drawable.shape_error_value); - } - - /** 解析主题属性里的颜色值;未赋值时返回 0(透明)。 */ - private int themeColor(int attr) { - TypedValue value = new TypedValue(); - return getTheme().resolveAttribute(attr, value, true) ? value.data : 0; + safeRun(name, () -> view.setText(supplier.get()), () -> setError(view)); } private void safeRun(String name, Runnable action, Runnable fallback) { @@ -258,18 +209,22 @@ public class SettingEnhanceActivity extends BaseActivity { } } + private void setError(TextView view) { + if (view != null) view.setText(R.string.error_config_get); + } + private interface TextSupplier { CharSequence get(); } private void setDriveCheck(View view) { Setting.putDriveCheck(!Setting.isDriveCheck()); - setSummary(mBinding.driveCheckText, getSwitch(Setting.isDriveCheck())); + mBinding.driveCheckText.setText(getSwitch(Setting.isDriveCheck())); } private void setDebugLog(View view) { Setting.putDebugLog(!Setting.isDebugLog()); - setSummary(mBinding.debugLogText, getSwitch(Setting.isDebugLog())); + mBinding.debugLogText.setText(getSwitch(Setting.isDebugLog())); if (!Setting.isDebugLog()) return; DebugLogDialog.show(this); } @@ -295,12 +250,12 @@ public class SettingEnhanceActivity extends BaseActivity { private void setPlaybackArtworkWall(View view) { Setting.putPlaybackArtworkWall(!Setting.isPlaybackArtworkWall()); - setSummary(mBinding.playbackArtworkWallText, getSwitch(Setting.isPlaybackArtworkWall())); + mBinding.playbackArtworkWallText.setText(getSwitch(Setting.isPlaybackArtworkWall())); } private void setWebHomeFullscreen(View view) { Setting.putWebHomeFullscreen(!Setting.isWebHomeFullscreen()); - setSummary(mBinding.webHomeFullscreenText, getSwitch(Setting.isWebHomeFullscreen())); + mBinding.webHomeFullscreenText.setText(getSwitch(Setting.isWebHomeFullscreen())); } private void setCspWarmup(View view) { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPersonalActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPersonalActivity.java index 423311c349..1fc41e3735 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPersonalActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPersonalActivity.java @@ -10,26 +10,22 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.HomeButton; import com.fongmi.android.tv.databinding.ActivitySettingPersonalBinding; -import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; import com.fongmi.android.tv.setting.AppBranding; import com.fongmi.android.tv.setting.AutoBackupPolicy; import com.fongmi.android.tv.setting.GroupRuleConfig; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.GroupRuleDialog; import com.fongmi.android.tv.ui.dialog.HomeButtonDialog; import com.fongmi.android.tv.ui.dialog.HomeMenuKeyDialog; import com.fongmi.android.tv.ui.dialog.SpeedSettingDialog; import com.fongmi.android.tv.ui.dialog.SliderNumberDialog; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; import com.fongmi.android.tv.utils.Util; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.Locale; @@ -42,7 +38,6 @@ public class SettingPersonalActivity extends BaseActivity { private String[] searchColumn; private String[] searchResultSort; private String[] globalHistoryMode; - private String[] interfaceFailoverMode; public static void start(Activity activity) { activity.startActivity(new Intent(activity, SettingPersonalActivity.class)); @@ -66,17 +61,14 @@ public class SettingPersonalActivity extends BaseActivity { @Override protected void initEvent() { mBinding.homeVodAutoLoad.setOnClickListener(this::setHomeVodAutoLoad); - mBinding.following.setOnClickListener(this::setFollowing); mBinding.homeSiteLock.setOnClickListener(this::setHomeSiteLock); mBinding.autoBackup.setOnClickListener(this::setAutoBackup); mBinding.homeButtons.setOnClickListener(this::onHomeButtons); mBinding.fullscreenMenuKey.setOnClickListener(this::setFullscreenMenuKey); mBinding.homeMenuKey.setOnClickListener(this::setHomeMenuKey); mBinding.playBackToDetail.setOnClickListener(this::setPlayBackToDetail); - mBinding.playbackOverlay.setOnClickListener(this::setPlaybackOverlay); mBinding.episodeHistory.setOnClickListener(this::setEpisodeHistory); mBinding.globalHistory.setOnClickListener(this::setGlobalHistory); - mBinding.interfaceFailover.setOnClickListener(this::setInterfaceFailover); mBinding.playSpeed.setOnClickListener(this::setPlaySpeed); mBinding.groupRule.setOnClickListener(this::setGroupRule); mBinding.homeHistory.setOnClickListener(this::setHomeHistory); @@ -86,7 +78,6 @@ public class SettingPersonalActivity extends BaseActivity { // mBinding.searchColumn.setOnClickListener(this::setSearchColumn); // 在搜索页面切换更方便 mBinding.appBranding.setOnClickListener(this::startAppBranding); mBinding.resetApp.setOnClickListener(this::showResetAppDialog); - mBinding.touchOptimization.setOnClickListener(this::setTouchOptimization); } @Override @@ -97,17 +88,14 @@ public class SettingPersonalActivity extends BaseActivity { private void setText() { mBinding.homeVodAutoLoadText.setText(getSwitch(Setting.isHomeVodAutoLoad())); - mBinding.followingText.setText(getSwitch(FollowingSettings.isEnabled())); mBinding.homeSiteLockText.setText(getSwitch(Setting.isHomeSiteLock())); mBinding.autoBackupText.setText(getSwitch(isAutoBackupEnabled())); mBinding.homeButtonsText.setText(getString(R.string.home_buttons_selected, HomeButton.getButtons().size(), HomeButton.all().size())); mBinding.fullscreenMenuKeyText.setText((fullscreenMenuKey = getResources().getStringArray(R.array.select_fullscreen_menu_key))[Setting.getFullscreenMenuKey()]); mBinding.homeMenuKeyText.setText((homeMenuKey = getResources().getStringArray(R.array.select_home_menu_key))[Setting.getHomeMenuKey()]); mBinding.playBackToDetailText.setText(getSwitch(Setting.isPlayBackToDetail())); - mBinding.playbackOverlayText.setText(getSwitch(Setting.isPlaybackOverlayEnabled())); mBinding.episodeHistoryText.setText(getSwitch(Setting.isEpisodeHistory())); mBinding.globalHistoryText.setText((globalHistoryMode = getResources().getStringArray(R.array.select_global_history_mode))[Setting.getGlobalHistoryMode()]); - mBinding.interfaceFailoverText.setText((interfaceFailoverMode = getResources().getStringArray(R.array.select_interface_failover_mode))[Setting.getInterfaceFailoverMode()]); mBinding.playSpeedText.setText(getSpeedText(PlayerSetting.getDefaultSpeed())); mBinding.groupRuleText.setText(getString(R.string.setting_group_rule_summary, GroupRuleConfig.enabledCount(), GroupRuleConfig.totalCount())); mBinding.homeHistoryText.setText(getSwitch(Setting.isHomeHistory())); @@ -116,7 +104,6 @@ public class SettingPersonalActivity extends BaseActivity { mBinding.searchResultSortText.setText((searchResultSort = getResources().getStringArray(R.array.select_search_result_sort))[Setting.getSearchResultSort()]); // mBinding.searchColumnText.setText(getSearchColumnText()); // 在搜索页面切换更方便 mBinding.appBrandingText.setText(AppBranding.getSummary(this)); - mBinding.touchOptimizationText.setText(getSwitch(Setting.isTouchOptimized())); } private String getSearchColumnText() { @@ -137,20 +124,6 @@ public class SettingPersonalActivity extends BaseActivity { setText(); } - private void setFollowing(View view) { - boolean enabled = !FollowingSettings.isEnabled(); - FollowingSettings.setEnabled(enabled); - if (enabled) { - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); - } else { - FollowingScheduler.cancelAll(this); - } - ConfigEvent.common(); - RefreshEvent.home(); - setText(); - } - private void setHomeSiteLock(View view) { Setting.putHomeSiteLock(!Setting.isHomeSiteLock()); setText(); @@ -189,11 +162,6 @@ public class SettingPersonalActivity extends BaseActivity { HomeMenuKeyDialog.show(this, this::setText); } - private void setPlaybackOverlay(View view) { - Setting.putPlaybackOverlayEnabled(!Setting.isPlaybackOverlayEnabled()); - setText(); - } - private void setPlayBackToDetail(View view) { Setting.putPlayBackToDetail(!Setting.isPlayBackToDetail()); setText(); @@ -210,11 +178,6 @@ public class SettingPersonalActivity extends BaseActivity { setText(); } - private void setInterfaceFailover(View view) { - Setting.putInterfaceFailoverMode((Setting.getInterfaceFailoverMode() + 1) % interfaceFailoverMode.length); - setText(); - } - private void setPlaySpeed(View view) { SpeedSettingDialog.show(this, R.string.setting_play_speed, PlayerSetting.getDefaultSpeed(), 0.5f, 5f, 0.25f, value -> { PlayerSetting.putDefaultSpeed(value); @@ -250,7 +213,7 @@ public class SettingPersonalActivity extends BaseActivity { } private void showResetAppDialog(View view) { - new WebHtvAlertDialogBuilder(this) + new MaterialAlertDialogBuilder(this) .setTitle(R.string.dialog_reset_app) .setMessage(R.string.dialog_reset_app_data) .setNegativeButton(R.string.dialog_negative, null) @@ -262,13 +225,6 @@ public class SettingPersonalActivity extends BaseActivity { AppBrandingActivity.start(this); } - private void setTouchOptimization(View view) { - boolean enabled = !Setting.isTouchOptimized(); - Setting.putTouchOptimized(enabled); - mBinding.touchOptimizationText.setText(getSwitch(enabled)); - TouchOptimizationHelper.sync(getWindow().getDecorView()); - } - private void resetApp() { if (!Util.resetApp()) Notify.show(R.string.reset_app_failed); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java index ed23e3272f..04a6e842fa 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPlayerActivity.java @@ -17,7 +17,6 @@ import com.fongmi.android.tv.player.lut.LutSetting; import com.fongmi.android.tv.player.mpv.MpvConfigStore; import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; import com.fongmi.android.tv.setting.PlayerButtonSetting; -import com.fongmi.android.tv.setting.LiveSetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.PreloadSetting; import com.fongmi.android.tv.setting.Setting; @@ -53,7 +52,6 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B private String[] playCache; private String[] render; private String[] scale; - private String[] badgePosition; private String[] osd; public static void start(Activity activity) { @@ -88,8 +86,6 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B setPreloadText(); mBinding.autoPlayText.setText(getSwitch(PlayerSetting.isAutoPlay())); mBinding.autoChangeText.setText(getSwitch(PlayerSetting.isAutoChange())); - mBinding.deferFlagSwitchText.setText(getSwitch(PlayerSetting.isDeferFlagSwitchPlayback())); - mBinding.liveSourceFallbackText.setText(getSwitch(LiveSetting.isSourceFallback())); mBinding.failureFallbackText.setText((failureFallback = ResUtil.getStringArray(R.array.select_player_failure_fallback))[PlayerSetting.getFailureFallback()]); mBinding.backgroundText.setText(getSwitch(PlayerSetting.isBackgroundOn())); mBinding.musicNotificationText.setText(getSwitch(PlayerSetting.isMusicNotification())); @@ -97,14 +93,14 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.audioDecodeText.setText(getSwitch(PlayerSetting.isAudioPrefer())); mBinding.audioPassThroughText.setText(getSwitch(PlayerSetting.isAudioPassThrough())); mBinding.videoDecodeText.setText(getSwitch(PlayerSetting.isVideoPrefer())); + mBinding.ffmpegModeText.setText(getFFmpegModeText()); mBinding.osdText.setText(getOsdText(osd = ResUtil.getStringArray(R.array.select_player_osd))); - badgePosition = ResUtil.getStringArray(R.array.select_media_badge_position); - updateBadgePositionText(); mBinding.kernelText.setText((kernel = ResUtil.getStringArray(R.array.select_player_kernel))[PlayerSetting.getPlayer()]); mpvRender = ResUtil.getStringArray(R.array.select_mpv_render); mBinding.scaleText.setText((scale = ResUtil.getStringArray(R.array.select_scale))[PlayerSetting.getScale()]); mBinding.lutText.setText(LutSetting.getSummary()); setMpvRows(); + setFfmpegModeVisibility(); mBinding.renderText.setText((render = ResUtil.getStringArray(R.array.select_render))[PlayerSetting.getRender()]); mBinding.captionText.setText((caption = ResUtil.getStringArray(R.array.select_caption))[PlayerSetting.isCaption() ? 1 : 0]); hidePerformanceRows(); @@ -118,13 +114,8 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.scale.setOnClickListener(this::setScale); mBinding.lut.setOnClickListener(this::onLut); mBinding.mpvConfig.setOnClickListener(view -> MpvConfigDialog.show(this, () -> mBinding.mpvConfigText.setText(MpvConfigStore.summary()))); - mBinding.blurayMenu.setOnClickListener(view -> { - PlayerSetting.putBlurayMenu(!PlayerSetting.isBlurayMenu()); - mBinding.blurayMenuText.setText(getSwitch(PlayerSetting.isBlurayMenu())); - }); mBinding.mpvRender.setOnClickListener(this::setMpvRender); mBinding.osd.setOnClickListener(this::onOsd); - mBinding.osdMediaBadgePosition.setOnClickListener(this::onBadgePosition); mBinding.playerButtons.setOnClickListener(view -> PlayerButtonConfigDialog.show(this, this::setPlayerButtonsText)); mBinding.speed.setOnClickListener(this::onSpeed); mBinding.buffer.setOnClickListener(this::onBuffer); @@ -139,11 +130,6 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.preloadPause.setOnClickListener(this::setPreloadPause); mBinding.autoPlay.setOnClickListener(this::setAutoPlay); mBinding.autoChange.setOnClickListener(this::setAutoChange); - mBinding.deferFlagSwitch.setOnClickListener(this::setDeferFlagSwitch); - mBinding.liveSourceFallback.setOnClickListener(view -> { - LiveSetting.putSourceFallback(!LiveSetting.isSourceFallback()); - mBinding.liveSourceFallbackText.setText(getSwitch(LiveSetting.isSourceFallback())); - }); mBinding.failureFallback.setOnClickListener(this::setFailureFallback); mBinding.render.setOnClickListener(this::setRender); mBinding.tunnel.setOnClickListener(this::setTunnel); @@ -156,6 +142,7 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.audioDecode.setOnClickListener(this::setAudioDecode); mBinding.audioPassThrough.setOnClickListener(this::setAudioPassThrough); mBinding.videoDecode.setOnClickListener(this::setVideoDecode); + mBinding.ffmpegMode.setOnClickListener(this::setFfmpegMode); } private void setVisible() { @@ -184,6 +171,7 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.kernelText.setText(kernel[index]); PlayerSetting.putPlayer(index); setMpvRows(); + setFfmpegModeVisibility(); setPerformanceText(); }); } @@ -202,13 +190,16 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B private void setMpvRows() { boolean visible = PlayerSetting.getPlayer() == PlayerSetting.MPV; mBinding.mpvConfig.setVisibility(visible ? View.VISIBLE : View.GONE); - mBinding.blurayMenu.setVisibility(visible ? View.VISIBLE : View.GONE); mBinding.mpvRender.setVisibility(visible ? View.VISIBLE : View.GONE); mBinding.mpvConfigText.setText(MpvConfigStore.summary()); - mBinding.blurayMenuText.setText(getSwitch(PlayerSetting.isBlurayMenu())); mBinding.mpvRenderText.setText(getMpvRenderText()); } + private void setFfmpegModeVisibility() { + boolean visible = PlayerSetting.getPlayer() == PlayerSetting.EXO; + mBinding.ffmpegMode.setVisibility(visible ? View.VISIBLE : View.GONE); + } + private void setMpvRender(View view) { int index = (PlayerSetting.getMpvRender() + 1) % mpvRender.length; PlayerSetting.putMpvRender(index); @@ -231,18 +222,6 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B }); } - private void onBadgePosition(View view) { - ChoiceDialog.showSingle(this, R.string.osd_media_badge_position, badgePosition, PlayerSetting.getMediaBadgeChoice(), which -> { - PlayerSetting.putMediaBadgeChoice(which); - updateBadgePositionText(); - }); - } - - private void updateBadgePositionText() { - int which = PlayerSetting.getMediaBadgeChoice(); - mBinding.osdMediaBadgePositionText.setText(badgePosition != null && badgePosition.length > which ? badgePosition[which] : getString(R.string.setting_off)); - } - private void setPlayerButtonsText() { mBinding.playerButtonsText.setText(getString(R.string.player_button_config_summary, PlayerButtonSetting.getVisibleCount(), PlayerButtonSetting.getTotalCount())); } @@ -397,11 +376,6 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.autoChangeText.setText(getSwitch(PlayerSetting.isAutoChange())); } - private void setDeferFlagSwitch(View view) { - PlayerSetting.putDeferFlagSwitchPlayback(!PlayerSetting.isDeferFlagSwitchPlayback()); - mBinding.deferFlagSwitchText.setText(getSwitch(PlayerSetting.isDeferFlagSwitchPlayback())); - } - private void setFailureFallback(View view) { ChoiceDialog.showSingle(this, R.string.player_failure_fallback, failureFallback, PlayerSetting.getFailureFallback(), which -> { PlayerSetting.putFailureFallback(which); @@ -510,6 +484,21 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B setPerformanceText(); } + private void setFfmpegMode(View view) { + int mode = (PlayerSetting.getFFmpegMode() + 1) % 4; + PlayerSetting.putFFmpegMode(mode); + mBinding.ffmpegModeText.setText(getFFmpegModeText()); + } + + private String getFFmpegModeText() { + return switch (PlayerSetting.getFFmpegMode()) { + case PlayerSetting.FFMPEG_MODE_OFFICIAL -> "Official"; + case PlayerSetting.FFMPEG_MODE_SIMPLE -> "Simple"; + case PlayerSetting.FFMPEG_MODE_AUTO -> "自动"; + default -> "NextLib"; + }; + } + private void onBackground(View view) { PlayerSetting.putBackground(PlayerSetting.isBackgroundOn() ? 0 : 1); mBinding.backgroundText.setText(getSwitch(PlayerSetting.isBackgroundOn())); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingSubtitleActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingSubtitleActivity.java index e8e8ce1510..a8b501a72c 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingSubtitleActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingSubtitleActivity.java @@ -11,7 +11,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.ActivitySettingSubtitleBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.subtitle.provider.SubtitleProviderRegistry; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.SubtitleSettingsDialog; import com.fongmi.android.tv.utils.ResUtil; @@ -42,14 +41,14 @@ public class SettingSubtitleActivity extends BaseActivity { subtitleLanguageValues = ResUtil.getStringArray(R.array.select_subtitle_language_value); mBinding.subtitleAutoMatchText.setText(getSwitch(Setting.isSubtitleAutoMatchEnabled())); mBinding.subtitleLanguageText.setText(getSubtitleLanguageText()); - mBinding.subtitleAssrtTokenText.setText(getSubtitleSourceText()); + mBinding.subtitleAssrtTokenText.setText(getSubtitleAssrtTokenText()); } @Override protected void initEvent() { mBinding.subtitleAutoMatch.setOnClickListener(this::setSubtitleAutoMatch); mBinding.subtitleLanguage.setOnClickListener(this::onSubtitleLanguage); - mBinding.subtitleAssrtToken.setOnClickListener(this::onSubtitleSource); + mBinding.subtitleAssrtToken.setOnClickListener(this::onSubtitleAssrtToken); } private void setSubtitleAutoMatch(View view) { @@ -64,8 +63,11 @@ public class SettingSubtitleActivity extends BaseActivity { }); } - private void onSubtitleSource(View view) { - SubtitleSettingsDialog.showSourceSummary(this, SubtitleProviderRegistry.get().providers(), () -> mBinding.subtitleAssrtTokenText.setText(getSubtitleSourceText())); + private void onSubtitleAssrtToken(View view) { + SubtitleSettingsDialog.showAssrtToken(this, Setting.getSubtitleAssrtToken(), value -> { + Setting.putSubtitleAssrtToken(value); + mBinding.subtitleAssrtTokenText.setText(getSubtitleAssrtTokenText()); + }); } private String getSubtitleLanguageText() { @@ -79,7 +81,7 @@ public class SettingSubtitleActivity extends BaseActivity { return 0; } - private String getSubtitleSourceText() { - return getString(R.string.player_subtitle_source_count, SubtitleProviderRegistry.get().providerNames().size()); + private String getSubtitleAssrtTokenText() { + return getString(TextUtils.isEmpty(Setting.getSubtitleAssrtToken()) ? R.string.setting_unconfigured : R.string.setting_configured); } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingTmdbActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingTmdbActivity.java index eda4065e46..e8fec7eb72 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingTmdbActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingTmdbActivity.java @@ -12,10 +12,11 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.ActivitySettingTmdbBinding; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.LightDialog; import com.fongmi.android.tv.ui.dialog.TmdbSourceDialog; +import com.fongmi.android.tv.utils.Notify; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class SettingTmdbActivity extends BaseActivity { @@ -92,8 +93,17 @@ public class SettingTmdbActivity extends BaseActivity { } private void setDetailOpenMode(View view) { - AlertDialog alert = new WebHtvAlertDialogBuilder(this, R.style.Theme_WebHTV_Dialog).setTitle(R.string.setting_detail_open_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailOpenModes(), getDetailOpenModeIndex(), (dialog, which) -> { + AlertDialog alert = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog).setTitle(R.string.setting_detail_open_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailOpenModes(), getDetailOpenModeIndex(), (dialog, which) -> { int mode = DETAIL_OPEN_MODES[which]; + if (Setting.isTmdbMode(mode) && !Setting.isTmdbReady()) { + dialog.dismiss(); + Notify.show(R.string.detail_tmdb_need_key); + TmdbSourceDialog.create(this).onDismiss(() -> { + if (Setting.isTmdbReady()) Setting.putDetailOpenMode(mode); + setText(); + }).show(); + return; + } Setting.putDetailOpenMode(mode); setText(); dialog.dismiss(); @@ -109,7 +119,7 @@ public class SettingTmdbActivity extends BaseActivity { private void setDetailThemeMode(View view) { if (!Setting.isTmdbMode(Setting.getDetailOpenMode())) return; - AlertDialog alert = new WebHtvAlertDialogBuilder(this, R.style.Theme_WebHTV_Dialog).setTitle(R.string.setting_detail_theme_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailThemeModes(), getDetailThemeModeIndex(), (dialog, which) -> { + AlertDialog alert = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog).setTitle(R.string.setting_detail_theme_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailThemeModes(), getDetailThemeModeIndex(), (dialog, which) -> { Setting.putTmdbDetailStyle(DETAIL_THEME_MODES[which]); setText(); dialog.dismiss(); 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 8bf00adaab..6a41a774e9 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 @@ -2,21 +2,17 @@ package com.fongmi.android.tv.ui.activity; import android.annotation.SuppressLint; import android.app.Activity; -import android.content.Context; import android.content.Intent; import android.graphics.Rect; -import android.media.AudioManager; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; -import android.os.SystemClock; import android.text.TextUtils; import android.text.style.ClickableSpan; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; -import android.view.ViewTreeObserver; import android.widget.HorizontalScrollView; import android.widget.RelativeLayout; import android.widget.TextView; @@ -35,11 +31,9 @@ import androidx.leanback.widget.VerticalGridView; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.C; -import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; import androidx.media3.common.VideoSize; -import androidx.media3.mpvplayer.MpvPlayer; import androidx.media3.ui.PlayerView; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -52,15 +46,16 @@ 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; import com.fongmi.android.tv.bean.EpisodePositionCache; import com.fongmi.android.tv.bean.Flag; -import com.fongmi.android.tv.bean.FlagPreferenceCache; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.helper.EpisodeSeasonSnapshot; import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.bean.Keep; @@ -70,8 +65,6 @@ import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.bean.Sub; import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbSourcePayload; import com.fongmi.android.tv.bean.TmdbVideo; import com.fongmi.android.tv.bean.Track; import com.fongmi.android.tv.bean.UserAdRule; @@ -85,17 +78,11 @@ import com.fongmi.android.tv.model.SiteViewModel; import com.fongmi.android.tv.model.SearchProgress; import com.fongmi.android.tv.playback.PlaybackEventCollector; import com.fongmi.android.tv.playback.HistoryResumePayload; -import com.fongmi.android.tv.playback.SubtitleRestoreCoordinator; -import com.fongmi.android.tv.player.IntroSkipKinds; import com.fongmi.android.tv.player.IntroSkipPlayback; -import com.fongmi.android.tv.player.PlaybackAutoContext; -import com.fongmi.android.tv.player.PlaybackExperimentPolicy; -import com.fongmi.android.tv.player.PlaybackResourceClassifier; 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; @@ -103,24 +90,13 @@ import com.fongmi.android.tv.service.PersonalRecommendationService; import com.fongmi.android.tv.setting.DanmakuSetting; import com.fongmi.android.tv.setting.PlayerButtonSetting; import com.fongmi.android.tv.setting.MultiThreadProxySetting; -import com.fongmi.android.tv.setting.PlaybackExperimentSetting; -import com.fongmi.android.tv.setting.PreloadSetting; import com.fongmi.android.tv.setting.PlayerSetting; -import com.fongmi.android.tv.setting.FlagSwitchPlaybackPolicy; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.PlaybackMenuScrim; import com.fongmi.android.tv.setting.SiteHealthStore; -import com.fongmi.android.tv.setting.DetailRuntimeModePolicy; import com.fongmi.android.tv.setting.TmdbSitePolicy; -import com.fongmi.android.tv.setting.TmdbSourceState; import com.fongmi.android.tv.title.MediaTitleLearningExample; import com.fongmi.android.tv.title.MediaTitleRequest; import com.fongmi.android.tv.subtitle.SubtitlePlaybackSession; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingSource; import com.fongmi.android.tv.ui.activity.SearchActivity; import com.fongmi.android.tv.ui.adapter.ArrayAdapter; import com.fongmi.android.tv.ui.adapter.BackdropAdapter; @@ -137,7 +113,6 @@ import com.fongmi.android.tv.ui.custom.CustomSeekView; import com.fongmi.android.tv.ui.custom.PlayerOsdController; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.ui.dialog.CodecCapabilityDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.ui.dialog.ContentDialog; import com.fongmi.android.tv.ui.dialog.AdRuleEditDialog; import com.fongmi.android.tv.ui.dialog.DanmakuDialog; @@ -156,24 +131,15 @@ import com.fongmi.android.tv.ui.helper.EpisodeRangePolicy; import com.fongmi.android.tv.ui.helper.EpisodeSeasonPolicy; import com.fongmi.android.tv.ui.helper.SourceEpisodeSeasonCache; import com.fongmi.android.tv.ui.helper.PlayerControlFocusHelper; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.ui.helper.TmdbEpisodeGridPolicy; -import com.fongmi.android.tv.ui.helper.TmdbMatchPolicy; import com.fongmi.android.tv.ui.helper.TmdbNavigation; -import com.fongmi.android.tv.ui.helper.TmdbBundle; -import com.fongmi.android.tv.ui.helper.TmdbSourceAdapter; -import com.fongmi.android.tv.ui.helper.TmdbSourceAvailability; -import com.fongmi.android.tv.ui.helper.TmdbSourcePayloadParser; import com.fongmi.android.tv.ui.helper.TmdbVideoPlayback; import com.fongmi.android.tv.ui.helper.VodEventGuard; import com.fongmi.android.tv.ui.player.VodPlayerChrome; import com.fongmi.android.tv.ui.player.VodPlayerUiController; import com.fongmi.android.tv.ui.player.VodPlayerUiHost; -import com.fongmi.android.tv.ui.player.ShortDramaQueueCoordinator; -import com.fongmi.android.tv.player.engine.PlaySpec; import com.fongmi.android.tv.utils.ActivityLaunch; import com.fongmi.android.tv.utils.AudioUtil; -import com.fongmi.android.tv.utils.BrightnessPolicy; import com.fongmi.android.tv.utils.Clock; import com.fongmi.android.tv.utils.EpisodeHistoryTitleResolver; import com.fongmi.android.tv.utils.EpisodeTitleFormatter; @@ -187,7 +153,6 @@ import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Sniffer; import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.utils.TmdbDetailCache; -import com.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.fongmi.android.tv.utils.Util; import com.fongmi.android.tv.utils.VodDetailCache; import com.github.catvod.crawler.SpiderDebug; @@ -198,7 +163,6 @@ import com.google.gson.JsonObject; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.io.File; -import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -247,9 +211,7 @@ import com.fongmi.android.tv.player.lyrics.LyricsRequest; import com.fongmi.android.tv.player.lyrics.LyricsRepository; import com.fongmi.android.tv.player.lyrics.LyricsResult; import com.fongmi.android.tv.player.lut.LutSetting; -import com.fongmi.android.tv.player.mpv.MpvConfigStore; import com.fongmi.android.tv.setting.LyricsSetting; -import com.fongmi.android.tv.ui.custom.FlagSelectionListener; import com.fongmi.android.tv.ui.custom.AudioPlayerBackgroundDrawable; import com.fongmi.android.tv.ui.custom.KaraokeResultView; import com.fongmi.android.tv.ui.dialog.CastDialog; @@ -268,14 +230,6 @@ import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; public class VideoActivity extends PlaybackActivity implements CustomKeyDownVod.Listener, TrackDialog.Listener, ArrayAdapter.OnClickListener, FlagAdapter.OnClickListener, EpisodeAdapter.OnClickListener, QualityAdapter.OnClickListener, QuickAdapter.OnClickListener, ParseAdapter.OnClickListener, Clock.Callback, SubtitlePlaybackSession.Host, com.fongmi.android.tv.ui.host.TmdbDetailHost, ControlDialog.Listener, CastDialog.Listener, com.fongmi.android.tv.ui.novel.NovelReaderHost { - /** - * 用户拖拽 seek 时加载圈的最小可见时长。 - * - *

必须盖过一个完整的网速采样间隔({@code setTraffic} 每秒一跳),因为读数是由 - * 相邻两次采样的字节差算出的:窗口短于一跳就永远只能拿到没有间隔的首跳(空白)。 - * 取 1.2s 留出余量,避免收圈与首跳采样同帧竞争。 - */ - private static final long SEEK_PROGRESS_MIN_VISIBLE_MS = 1200L; private static final long LYRICS_OFFSET_MIN_MS = -5000L; private static final long LYRICS_OFFSET_MAX_MS = 5000L; private static final long LYRICS_OFFSET_STEP_MS = 500L; @@ -328,7 +282,6 @@ private String mLyricsSelectedResultKey; private String mDetailLyrics; private String mInlineLyrics; private String mPlaybackEpisodeKey; -private String mPlaybackFlagKey = ""; private String mArtworkRequestOwner; private String mAudioArtworkColorKey; private ObjectAnimator mAudioCoverAnimator; @@ -354,25 +307,15 @@ private Runnable mAudioRefreshLyricsRunnable; private Runnable mApplyAudioBackgroundRunnable; private Runnable mHideAudioFocusRunnable; private long mInitialPlaybackPosition = C.TIME_UNSET; -private int runtimeDetailMode = Setting.getDetailOpenMode(); -private boolean runtimeSourceOnly; private static final int SHORT_DRAMA_SCALE = 0; // 0=原始(适合TV), 4=裁剪(适合手机) private static final int TMDB_DETAIL_LOAD_TIMEOUT = 15000; private static final int TMDB_CACHED_DETAIL_APPLY_DELAY_MS = 16; private static final int TMDB_FAST_PLAYBACK_START_DELAY_MS = 16; private static final int TMDB_BIND_AFTER_REVEAL_DELAY_MS = 96; - private static final int INIT_VIEW_STAGE_FALLBACK_DELAY_MS = 500; - // The safety net must not outrun a slow first frame: the frame-paced chain legitimately stays silent - // until the first traversal, so only a chain that has not advanced for this long counts as stalled. - private static final int INIT_VIEW_STAGE_STALL_TIMEOUT_MS = 1_000; private static final int TMDB_OVERVIEW_ROW_GAP_DP = 12; private static final int TMDB_OVERVIEW_BOTTOM_GUARD_DP = 6; private static final int OMDB_FULL_RATING_TEXT_MAX_LENGTH = 20; - // 原生增强播放页统一焦点规范,取值与 app/src/main/res/drawable/selector_video_item.xml 一致: - // 焦点 3dp @color/tv_item_focus_ring,当前播放 2dp @color/tv_item_current_ring,常态 1dp @color/tv_item_normal_stroke。 - private static final int TV_ITEM_FOCUS_WIDTH_DP = 3; - private static final int TV_ITEM_NORMAL_WIDTH_DP = 1; private static final String EXTRA_TMDB_PLAY_FLAG = "tmdb_play_flag"; private static final String EXTRA_TMDB_PLAY_FLAG_KEY = "tmdb_play_flag_key"; private static final String EXTRA_TMDB_PLAY_EPISODE_NAME = "tmdb_play_episode_name"; @@ -383,7 +326,6 @@ private boolean runtimeSourceOnly; private static final String EXTRA_RESUME_HISTORY_CID = "resume_history_cid"; private static final String EXTRA_RESUME_HISTORY_KEY = "resume_history_key"; private static final String EXTRA_TMDB_VOD_CACHE_KEY = "tmdb_vod_cache_key"; - private static final String EXTRA_DETAIL_RUNTIME_MODE = "detail_runtime_mode"; private static final String EXTRA_IMMERSIVE_AUDIO_CACHE_KEY = "immersive_audio_cache_key"; private static final String EXTRA_SEARCH_KEYWORD = "search_keyword"; private static final java.util.concurrent.ConcurrentHashMap IMMERSIVE_AUDIO_LAUNCHES = new java.util.concurrent.ConcurrentHashMap<>(); @@ -404,31 +346,13 @@ private boolean runtimeSourceOnly; private PartAdapter mPartAdapter; private BackdropAdapter mBackdropAdapter; private Map mActionButtons; - private boolean mProgrammaticFlagSwitch; - private boolean mDeferFlagSwitchPlayback; - private final List mCustomActionViews = new ArrayList<>(); - private HorizontalScrollView mCustomPortraitButtons; - private LinearLayout mCustomPortraitButtonRow; private QuickSearchDialog mQuickSearchDialog; private VodPlayerUiController mPlayerUi; private PlayerOsdController mOsd; private final IntroSkipPlayback mIntroSkipPlayback = new IntroSkipPlayback(); private androidx.appcompat.app.AlertDialog mIntroSkipConfirmDialog; private final SubtitlePlaybackSession subtitlePlaybackSession = new SubtitlePlaybackSession(this); - private static final int TV_TOUCH_NONE = 0; - private static final int TV_TOUCH_HORIZONTAL = 1; - private static final int TV_TOUCH_VERTICAL = 2; - private static final long TV_TOUCH_SEEK_SCALE = 50L; - private CustomKeyDownVod mKeyDown; - private AudioManager mTvAudioManager; - private int mTvTouchAxis; - private boolean mTvTouchMulti; - private float mTvTouchDownX; - private float mTvTouchDownY; - private float mTvTouchBright; - private float mTvTouchVolume; - private long mTvTouchSeek; private SiteViewModel mViewModel; private List mBroken; private History mHistory; @@ -439,21 +363,13 @@ private boolean runtimeSourceOnly; private boolean quickSearchDialogClosed; private boolean useParse; private boolean detailRequested; - private final ArrayDeque mInitViewStages = new ArrayDeque<>(); - private boolean mInitViewStagesLaunched; - private long mInitViewLastPumpAt; - private boolean mFlagSelectionListenerRegistered; private boolean detailHealthRecorded; private boolean playHealthRecorded; - private int followingUiGeneration; - private boolean followingActionPending; private SpaceItemDecoration episodeGridDecoration; private boolean episodeGridMode; private Runnable mR1; private Runnable mR2; private Runnable mSeekProgressFallback; - private boolean mSeekProgressPending; - private long mSeekProgressStartedAtMs; private Runnable mTmdbDetailTimeout; private Runnable mTmdbEpisodeTimeout; private final Runnable mPendingTmdbBind = this::flushPendingTmdbBind; @@ -477,7 +393,11 @@ private boolean runtimeSourceOnly; 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; @@ -500,16 +420,6 @@ private boolean runtimeSourceOnly; private Clock mClock; private PiP mPiP; private View mFocus1; - private ViewTreeObserver.OnGlobalLayoutListener mEpisodeGridFocusListener; - private int mEpisodeGridFocusTarget = -1; - // 长按连续选集:最近一次请求的目标卡片。焦点落地前按住方向的重复按键以它为基准继续推进, - // 同时充当待定对焦回调的失效令牌(换目标即作废旧回调,避免过期坐标把列表滚回原处)。 - private int mEpisodeHoldTarget = RecyclerView.NO_POSITION; - private static final int EPISODE_HOLD_FOCUS_MAX_ATTEMPTS = 6; - // 焦点校正的尝试次数上限:只补偿「进场时焦点丢失」这一种情况,冲击几次就放手。 - private static final int EPISODE_GRID_FOCUS_MAX_ATTEMPTS = 3; - private int mEpisodeGridFocusAttempts; - private ViewTreeObserver.OnGlobalLayoutListener mArrayFocusListener; private PersonalRecommendationService.RecommendationPage mNativePersonalTmdbPage; private PersonalRecommendationService.RecommendationPage mNativePersonalDoubanPage; private PersonalRecommendationService.RecommendationPage mNativePersonalAiPage; @@ -531,28 +441,7 @@ private boolean runtimeSourceOnly; private View mDialogReturnFocus; private Result mPendingDetail; private Result mPendingPlayer; - private int playerContentGeneration; - private int playerContentRequestId; - private String playerContentKey = ""; - private String playerContentFlag = ""; - private String playerContentEpisode = ""; private Result mAppliedPlayerResult; - private boolean mPlaybackRequestActive; - private boolean mPlaybackPlayerStarted; - // 用户主动点集(手动播放模式)时临时置位:让本次起播不受「点播自动播放」关闭影响。 - private boolean mExplicitPlaybackRequest; - // 手动播放模式下是否已经由用户显式起播过;未起播前预览位显示封面。 - private boolean mManualPlaybackStarted; - private final ShortDramaQueueCoordinator mShortDramaQueue = new ShortDramaQueueCoordinator(); - private PlaySpec mShortDramaQueuedSpec; - private Result mShortDramaQueuedResult; - private Episode mShortDramaQueuedEpisode; - private boolean mShortDramaQueueSessionActive; - private boolean mShortDramaQueueFallbackUsed; - private boolean mShortDramaEndedFallbackScheduled; - private long mShortDramaTransitionPositionMs = C.TIME_UNSET; - private long mShortDramaTransitionDurationMs = C.TIME_UNSET; - private final Runnable mShortDramaEndedFallback = this::fallbackShortDramaQueued; private AudioPlaybackResolver.Resolved mImmersiveAudioResolved; private boolean mImmersiveAudioRequested; private String mContextWallUrl; @@ -564,12 +453,6 @@ private boolean runtimeSourceOnly; private boolean tmdbHistoryResumePending; private boolean pendingLutImport; private boolean playerKernelSwitchRefreshing; - private int playerKernelSwitchRequestId; - private int mPendingPlayerKernel = PlayerSetting.NONE; - private MpvPlayer mDiscMenuPlayer; - private final Runnable mDiscMenuStateListener = this::updateDiscMenuButton; - private MpvPlayer mCustomButtonPlayer; - private final Runnable mCustomButtonStateListener = this::updateCustomButtonStates; private final ActivityResultLauncher mLutDir = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null || result.getData().getData() == null) return; @@ -690,25 +573,7 @@ private boolean runtimeSourceOnly; private static boolean shouldOpenLegacyTmdbDetail(String key, String id, boolean cast) { int mode = Setting.getDetailOpenMode(); - return canOpenLegacyTmdbDetail(key, id, cast) && Setting.isTmdbDetailModeConfigured() && Setting.isStandaloneTmdbDetailMode(mode); - } - - public static void startDirectResolved(Activity activity, Vod vod) { - if (activity == null || vod == null) return; - Intent intent = new Intent(activity, VideoActivity.class); - intent.putExtra("detail_mode", Setting.DETAIL_OPEN_DIRECT); - intent.putExtra(EXTRA_DETAIL_RUNTIME_MODE, Setting.DETAIL_OPEN_DIRECT); - intent.putExtra("scan", false); - intent.putExtra("collect", false); - intent.putExtra("cast", false); - intent.putExtra("key", vod.getSiteKey()); - intent.putExtra("id", vod.getId()); - intent.putExtra("name", vod.getName()); - intent.putExtra("pic", vod.getPic()); - intent.putExtra("mark", vod.getRemarks()); - intent.putExtra("content", vod.getContent()); - putDetailVodCache(intent, vod); - activity.startActivity(intent); + return canOpenLegacyTmdbDetail(key, id, cast) && Setting.isTmdbDetailPage() && Setting.isStandaloneTmdbDetailMode(mode); } public static void start(Activity activity, String url) { @@ -816,11 +681,6 @@ private boolean runtimeSourceOnly; start(activity, key, id, name, pic, mark, false, false, tmdbItem); } - /** 追更页通过 flavor 专用入口调用,并遵循当前详情页模式。 */ - public static void startFromFollowingHistory(Activity activity, History item) { - startFromHistory(activity, item); - } - public static void startFromHistory(Activity activity, History item) { if (shouldOpenLegacyTmdbDetail(item.getSiteKey(), item.getVodId(), false)) { TmdbDetailActivity.startFromHistory(activity, item); @@ -968,7 +828,8 @@ private boolean runtimeSourceOnly; public static void startDirectTmdb(Activity activity, String key, String id, String name, String pic, String mark, ArrayList episodeTitles, TmdbItem item, Vod tmdbVod, Vod detailVod, String tmdbDetailCacheKey, String playFlag, String playEpisodeName, String playEpisodeUrl, int playSeasonNumber, int playEpisodeNumber, History resumeHistory) { startDirectTmdb(activity, key, id, name, pic, mark, episodeTitles, item, tmdbVod, detailVod, tmdbDetailCacheKey, playFlag, - "", playEpisodeName, playEpisodeUrl, playSeasonNumber, playEpisodeNumber, resumeHistory); + resumeHistory == null ? "" : resumeHistory.getSourceBindingKey(), + playEpisodeName, playEpisodeUrl, playSeasonNumber, playEpisodeNumber, resumeHistory); } public static void startDirectTmdb(Activity activity, String key, String id, String name, String pic, String mark, ArrayList episodeTitles, TmdbItem item, Vod tmdbVod, Vod detailVod, String tmdbDetailCacheKey, String playFlag, String playFlagKey, String playEpisodeName, String playEpisodeUrl, int playSeasonNumber, int playEpisodeNumber, History resumeHistory) { @@ -1178,7 +1039,6 @@ private boolean runtimeSourceOnly; ? getString(R.string.detail_episode_season_context, season) : getString(R.string.detail_episode)); boolean selectable = isTmdbSourceEnabled() - && !runtimeSourceOnly && mTmdbUIAdapter != null && mTmdbUIAdapter.getTmdbItem() != null && mTmdbUIAdapter.getTmdbItem().isTv() @@ -1252,7 +1112,6 @@ private boolean runtimeSourceOnly; ? com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, index) : mTmdbUIAdapter == null ? "" : mTmdbUIAdapter.activeFlagKey(flag); mHistory.setSourceBindingKey(flagKey); - syncHistory(); } private Flag resolveHistoryPlaybackFlag(List flags) { @@ -1260,75 +1119,11 @@ private boolean runtimeSourceOnly; String flagKey = mHistory == null ? "" : mHistory.getSourceBindingKey(); String flagName = mHistory == null ? "" : mHistory.getVodFlag(); String episodeUrl = mHistory == null ? "" : mHistory.getEpisodeUrl(); - // 用户最后一次显式切换的线路优先于 History 里的线路快照:History.vodFlag 只是 - // 「上次起播时的线路」的副产物,切线路而没重新起播时它不会更新,杀进程后若还 - // 以它为准就会退回旧线路甚至第一条。偏好同样要能定位到这次要播的集, - // 否则会劫持跨源解算出的、确实含目标集的那条线路。 - Flag preferred = findPreferredFlag(flags, episodeUrl); - if (preferred != null) return preferred; Flag selected = com.fongmi.android.tv.ui.helper.TmdbUIAdapter.selectPlaybackFlag( flags, flagKey, episodeUrl, flagName); return selected == null ? flags.get(0) : selected; } - /** - * 记住用户显式切换的线路。线路选择原本只作为播放副产物写进 History,而切线路后 - * 不一定重新起播(seamless 没切集时不会走 onRefresh),mHistory 的 vodFlag/ - * sourceBindingKey 仍指向旧线路,杀进程后就退回了旧线路。这里与详情页保持一致, - * 切一下就落盘偏好,不依赖播放进度。 - */ - private void rememberFlagSelection(Flag flag) { - if (flag == null) return; - int index = mFlagAdapter == null ? -1 : mFlagAdapter.indexOf(flag); - if (index < 0 && mVod != null) index = com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagIndex(mVod.getFlags(), flag); - // 索引未知时不写稳定键:Flag.stableKey 会把 -1 夹成 0,留下一个必然命中首条线路的假键。 - String stableKey = index >= 0 ? com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, index) : ""; - FlagPreferenceCache.get().put(getKey(), getId(), stableKey, flag.getFlag()); - Task.execute(() -> FlagPreferenceCache.get().save()); - if (mHistory == null) return; - // History 里的 sourceBindingKey 与既有 setHistoryFlag 保持一致:稳定键不可用时退化到适配器当前键。 - String historyKey = TextUtils.isEmpty(stableKey) - ? (mTmdbUIAdapter == null ? "" : mTmdbUIAdapter.activeFlagKey(flag)) - : stableKey; - mHistory.setVodFlag(flag.getFlag()); - if (!TextUtils.isEmpty(historyKey)) mHistory.setSourceBindingKey(historyKey); - // 还没起播(无进度)时不写库,避免在「最近观看」里留下一条没有进度的空记录。 - if (mHistory.canSave()) syncHistory(); - } - - /** - * 读取上次显式选中的线路。稳定键(线路名#索引)优先,源站线路增删导致索引漂移时 - * 退化用线路名匹配。episodeUrl 非空时要求该线路确实含这一集。 - */ - private Flag findPreferredFlag(List flags, String episodeUrl) { - FlagPreferenceCache.FlagPreference preference = FlagPreferenceCache.get().get(getKey(), getId()); - if (preference == null) return null; - Flag candidate = null; - String stableKey = preference.getStableKey(); - if (!TextUtils.isEmpty(stableKey)) { - for (int i = 0; i < flags.size(); i++) { - Flag flag = flags.get(i); - if (flag != null && TextUtils.equals(stableKey, com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, i))) { - candidate = flag; - break; - } - } - } - if (candidate == null) { - String flagName = preference.getFlagName(); - if (TextUtils.isEmpty(flagName)) return null; - for (Flag flag : flags) { - if (flag != null && TextUtils.equals(flagName, flag.getFlag())) { - candidate = flag; - break; - } - } - } - if (candidate == null) return null; - if (TextUtils.isEmpty(episodeUrl) || candidate.containsEpisodeUrl(Episode.create("", episodeUrl))) return candidate; - return null; - } - private Episode getEpisode() { Flag flag = getFlag(); if (flag != null && !flag.getEpisodes().isEmpty()) return flag.getEpisodes().get(getSelectedEpisodePosition(flag.getEpisodes())); @@ -1340,17 +1135,6 @@ private boolean runtimeSourceOnly; for (int i = 0; i < episodes.size(); i++) if (episodes.get(i).isSelected()) return i; if (mHistory == null) return 0; Episode historyEpisode = mHistory.getEpisode(); - // 历史集 URL 能定位到当前列表条目时(同集多版本并存)先按 URL 锁定版本, - // 否则“集号相同即同集”会把用户点过的第二版本误认成第一版本; - // 换线路/换源/源站刷新后 URL 必然失配,交回下面的集号容错,保住跨线路续播与刷新后选中。 - for (int i = 0; i < episodes.size(); i++) { - if (TextUtils.isEmpty(historyEpisode.getUrl())) break; - if (!TextUtils.equals(historyEpisode.getUrl(), episodes.get(i).getUrl())) continue; - if (episodes.get(i).getTmdbEpisode() != null && episodes.get(i).getTmdbEpisode().getNumber() > 0 - && historyEpisode.getTmdbEpisode() != null && historyEpisode.getTmdbEpisode().getNumber() > 0 - && !episodes.get(i).matchesNumber(historyEpisode)) continue; - return i; - } for (int i = 0; i < episodes.size(); i++) if (episodes.get(i).matchesPlayback(historyEpisode)) return i; return 0; } @@ -1364,43 +1148,14 @@ private boolean runtimeSourceOnly; return getIntent().getBooleanExtra("tmdbMode", false); } - private void initializeRuntimeDetailMode() { - runtimeDetailMode = getIntent().getIntExtra(EXTRA_DETAIL_RUNTIME_MODE, Setting.getDetailOpenMode()); - runtimeSourceOnly = false; - } - - private void applyRuntimeDetailMode(int mode) { - runtimeDetailMode = Setting.isTmdbMode(mode) || mode == Setting.DETAIL_OPEN_DIRECT ? mode : Setting.DETAIL_OPEN_DIRECT; - } - - private int runtimeDetailMode() { - return runtimeDetailMode; - } - - private int policyConfiguredMode() { - int override = getIntent().getIntExtra(EXTRA_DETAIL_RUNTIME_MODE, Integer.MIN_VALUE); - return override == Setting.DETAIL_OPEN_DIRECT ? Setting.DETAIL_OPEN_DIRECT : Setting.getDetailOpenMode(); - } - - private boolean isRuntimeFusionMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_FUSION || getIntent().getBooleanExtra("fusion", false); - } - - private boolean isRuntimeOriginalEnhancedMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_ORIGINAL_ENHANCED; - } - - private boolean isRuntimeDirectMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_DIRECT; - } - private boolean shouldUseUpstreamNativeEpisodeModule() { - return isRuntimeDirectMode() && !isTmdbMode(); + return Setting.isDirectDetailPage() && !isTmdbMode(); } private boolean isTmdbSourceEnabled() { if (isTmdbMode()) return true; - if (!Setting.isTmdbMode(runtimeDetailMode())) return false; + if (!Setting.isTmdbMode(Setting.getDetailOpenMode())) return false; + if (!Setting.isTmdbEnabled()) return false; return TmdbSitePolicy.isEnabled(getKey(), getId()); } @@ -1491,41 +1246,16 @@ private boolean runtimeSourceOnly; return mBinding.control.seek; } - @Override - protected long getDurationFallbackMs() { - return mHistory == null ? C.TIME_UNSET : mHistory.getDuration(); - } - - private String getTimelineDurationTime() { - // 复用 PlayerManager 的兜底,避免同屏出现第二个兜底来源。 - return player().getDurationTime(); - } - @Override protected void onServiceConnected() { SpiderDebug.log("video-flow", "service ready sinceLaunch=%dms key=%s id=%s", getLaunchCost(System.currentTimeMillis()), getKey(), getId()); player().setDanmakuController(mBinding.exo.getDanmakuController()); player().setDanmakuEnabled(DanmakuSetting.isShow()); - applyPendingPlayerKernel(); setPlayerKernel(); setDecode(); setLut(); - // C111: setViewModel() runs on the initView tick, but the adapters (mFlagAdapter/mQualityAdapter/...) - // are only created by stage 1-recycler. A retained ViewModel replays its last Result into - // mPendingDetail/mPendingPlayer the moment the service connects, and detail/player results then - // dereference a not-yet-created adapter. Queue the whole tail behind the stages instead of running it - // inline; a non-empty queue means stage 1 has not run yet, an empty one means it already has, so the - // normal path stays inline and unchanged. - if (!mInitViewStages.isEmpty()) { - deferInitViewStage("4-service-results", this::applyServiceReadyResults); - return; - } - applyServiceReadyResults(); - } - - private void applyServiceReadyResults() { if (consumeImmersiveAudioLaunch()) return; - if (!detailRequested && mViewModel != null) checkId(); + if (!detailRequested) checkId(); flushPendingFastTmdbPlayback(); if (mPendingDetail != null) { Result result = mPendingDetail; @@ -1541,7 +1271,6 @@ private boolean runtimeSourceOnly; @Override protected void onNewIntent(Intent intent) { - invalidateShortDramaQueue("new-intent"); String oldKey = getKey(); String oldId = getId(); super.onNewIntent(intent); @@ -1559,13 +1288,6 @@ private boolean runtimeSourceOnly; getIntent().removeExtra(EXTRA_RESUME_HISTORY_CID); getIntent().removeExtra(EXTRA_RESUME_HISTORY_KEY); getIntent().putExtras(intent); - resetPlaybackOwnership(); - if (mViewModel != null) mViewModel.cancelPlayerContent(); - invalidatePlayerContent(); - mAppliedPlayerResult = null; - mPlaybackRequestActive = false; - mPlaybackPlayerStarted = false; - playerKernelSwitchRequestId++; setAudioStageVisible(false); restoreImmersiveAudioRequest(); resetDetailForNewIntent(); @@ -1576,14 +1298,12 @@ private boolean runtimeSourceOnly; protected void initView(Bundle savedInstanceState) { long start = System.currentTimeMillis(); SpiderDebug.log("video-flow", "initView start sinceLaunch=%dms key=%s id=%s", getLaunchCost(start), getKey(), getId()); - initializeRuntimeDetailMode(); restoreImmersiveAudioRequest(); mTmdbDetailTimeout = this::showTmdbDetailFallback; mTmdbEpisodeTimeout = this::showTmdbEpisodeFallback; initTmdbMode(); super.initView(savedInstanceState); SpiderDebug.log("video-flow", "initView after playback cost=%dms", System.currentTimeMillis() - start); - applyPlaybackOverlay(); mFrameParams = mBinding.video.getLayoutParams(); mPlayerUi = new VodPlayerUiController(new VodPlayerUiHost() { @Override @@ -1595,18 +1315,12 @@ private boolean runtimeSourceOnly; public String osdTitle() { return getOsdTitle(); } - - @Override - public boolean isFullscreen() { - return VideoActivity.this.isFullscreen(); - } }, VodPlayerChrome.fromVideo(mBinding, mBinding.widget.clock, 14f), this); mClock = mPlayerUi.clock(); mOsd = mPlayerUi.osd(); mPiP = mPlayerUi.pip(); setupAudioStageOverlay(); mKeyDown = CustomKeyDownVod.create(this); - mTvAudioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); mObserveDetail = this::setDetail; mObservePlayer = this::setPlayer; mObserveSearch = this::setSearch; @@ -1621,53 +1335,24 @@ private boolean runtimeSourceOnly; mApplyAudioBackgroundRunnable = this::applyAudioBackground; mHideAudioFocusRunnable = this::hideAudioStageFocusHighlight; SpiderDebug.log("video-flow", "initView state ready cost=%dms", System.currentTimeMillis() - start); - prepareInitialDetailShell(); checkCast(); SpiderDebug.log("video-flow", "initView preview ready cost=%dms", System.currentTimeMillis() - start); - // C111: setViewModel() must stay synchronous. Its LiveData observers are the only receivers of the - // detail/player results, and onServiceConnected() can arrive before the deferred stages run, where the - // immersive-audio path dereferences mViewModel through playerContent()/updateActionQuality(). + setRecyclerView(); + setShortDisplay(); + SpiderDebug.log("video-flow", "initView recycler ready cost=%dms", System.currentTimeMillis() - start); + setVideoView(); + SpiderDebug.log("video-flow", "initView video view ready cost=%dms", System.currentTimeMillis() - start); setViewModel(); - // C111: the spinner requested by checkCast() needs real frames before the heavy tail runs, so the - // tail is queued in original order and pumped one stage per frame (see launchInitViewStages()). - deferInitViewStage("1-recycler", () -> { - setRecyclerView(); - registerFlagSelectionListener(); - }); - deferInitViewStage("2-chrome", () -> { - setShortDisplay(); - setVideoView(); - }); - deferInitViewStage("3-detail", () -> { - // Only the detail request is left here; setViewModel() already ran on the initView tick. - checkId(); - }); - SpiderDebug.log("video-flow", "initView stages queued=%d cost=%dms sinceLaunch=%dms", mInitViewStages.size(), System.currentTimeMillis() - start, getLaunchCost(System.currentTimeMillis())); + // 初始化:隐藏换源按钮 + mBinding.change1.setVisibility(View.GONE); + checkId(); SpiderDebug.log("video-flow", "initView end cost=%dms sinceLaunch=%dms", System.currentTimeMillis() - start, getLaunchCost(System.currentTimeMillis())); - launchInitViewStages(); - } - - private void applyPlaybackOverlay() { - if (Setting.isPlaybackOverlayEnabled()) mBinding.control.getRoot().setBackground(PlaybackMenuScrim.create(this)); - else mBinding.control.getRoot().setBackgroundResource(R.color.transparent); - } - - /** - * 在首帧揭开预览前先确定详情模式,避免原生增强异步详情回来前短暂显示普通详情按钮。 - * 未配置好 TMDB 时仍保留普通详情入口。 - */ - private void prepareInitialDetailShell() { - boolean tmdbDetail = shouldLoadTmdbDetail(); - boolean enhancedDetail = tmdbDetail && (isRuntimeOriginalEnhancedMode() || isIntentTmdbPlayback()); - setOriginalEnhancedActionVisibility(enhancedDetail); - if (enhancedDetail && shouldUseTmdbLayout()) suppressTmdbNativeTextFields(); } @Override @SuppressLint("ClickableViewAccessibility") protected void initEvent() { mBinding.keep.setOnClickListener(view -> onKeep()); - mBinding.following.setOnClickListener(view -> onFollowing()); mBinding.searchDetail.setOnClickListener(view -> onSearch()); mBinding.searchDetail.setOnLongClickListener(view -> { onGlobalSearch(); @@ -1711,10 +1396,6 @@ private boolean runtimeSourceOnly; mBinding.control.action.lut.setOnClickListener(guarded(this::onLut)); mBinding.control.action.speed.setOnClickListener(guarded(this::onSpeed)); mBinding.control.action.reset.setOnClickListener(guarded(this::onReset)); - // 错误层的重试复用控制层同一条「重做本次播放」动作:onReset() 会按用户的「重置」 - // 设置决定从头重播还是重新拉流,所以不新写播放路径,也就不会与既有的自动换源 - // (startFlow)产生第二套恢复逻辑。重试成功后的收尾由 showProgress() 负责隐藏错误层。 - mBinding.widget.retry.setOnClickListener(guarded(this::onReset)); mBinding.control.action.title.setOnClickListener(guarded(this::onTitle)); mBinding.control.action.player.setOnClickListener(guarded(this::onPlayerKernel)); mBinding.control.action.player.setOnLongClickListener(view -> onPlayerKernelLong()); @@ -1735,16 +1416,8 @@ private boolean runtimeSourceOnly; 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)); - mBinding.control.action.discMenu.setOnClickListener(view -> { - hideControl(); - openDiscMenu(); - }); - mBinding.control.action.discMenu.setOnLongClickListener(view -> { - hideControl(); - showDiscMenuControls(); - return true; - }); 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()); if (mBinding.control.action.timer != null) mBinding.control.action.timer.setOnClickListener(view -> onTimer()); @@ -1770,8 +1443,13 @@ private boolean runtimeSourceOnly; mBinding.control.action.ending.setOnLongClickListener(view -> onEndingReset()); mBinding.control.action.opening.setOnLongClickListener(view -> onOpeningReset()); setActionFocusScroll(); - mBinding.video.setOnTouchListener(this::onVideoTouch); - registerFlagSelectionListener(); + mBinding.video.setOnTouchListener((view, event) -> mKeyDown.onTouchEvent(event)); + mBinding.flag.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { + @Override + public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { + if (mFlagAdapter.getItemCount() > 0) onItemClick(mFlagAdapter.get(position)); + } + }); mBinding.episode.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { @Override public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { @@ -1791,30 +1469,17 @@ private boolean runtimeSourceOnly; private void setupIntroSkipConfirmListener() { mIntroSkipPlayback.setSkipConfirmListener((segment, action) -> { - if (mIntroSkipConfirmDialog != null && mIntroSkipConfirmDialog.isShowing()) return false; - mIntroSkipConfirmDialog = new WebHtvAlertDialogBuilder(this) + if (mIntroSkipConfirmDialog != null && mIntroSkipConfirmDialog.isShowing()) return; + int messageId = segment.isOpening() + ? (segment.getKind() == IntroSkipService.Segment.Kind.INTRO ? R.string.intro_skip_confirm_intro : R.string.intro_skip_confirm_recap) + : R.string.intro_skip_confirm_outro; + mIntroSkipConfirmDialog = new com.google.android.material.dialog.MaterialAlertDialogBuilder(this) .setTitle(R.string.intro_skip_confirm_title) - .setMessage(IntroSkipKinds.confirmMessage(segment)) + .setMessage(messageId) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> mIntroSkipPlayback.declineConfirmation(segment)) + .setNegativeButton(android.R.string.cancel, null) .show(); - mIntroSkipConfirmDialog.setOnDismissListener(dialog -> { - mIntroSkipPlayback.cancelConfirmation(segment); - if (mIntroSkipConfirmDialog == dialog) mIntroSkipConfirmDialog = null; - }); - return true; }); - mIntroSkipPlayback.setSkipNoticeListener(IntroSkipKinds::notifySkipped); - mIntroSkipPlayback.setSkipConfirmDismisser(this::dismissIntroSkipConfirm); - } - - private void dismissIntroSkipConfirm() { - if (mIntroSkipConfirmDialog == null) return; - try { - mIntroSkipConfirmDialog.dismiss(); - } catch (Throwable ignored) { - } - mIntroSkipConfirmDialog = null; } private void setActionFocusScroll() { @@ -1828,6 +1493,79 @@ private boolean runtimeSourceOnly; } } + private void setRecyclerView() { + mBinding.flag.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.flag.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.flag.setAdapter(mFlagAdapter = new FlagAdapter(this)); + mBinding.episode.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.episode.setVerticalSpacing(ResUtil.dp2px(8)); + mBinding.episode.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.episode.setAdapter(mEpisodeAdapter = new EpisodeAdapter(this, this::onEpisodeLongClick)); + mEpisodeAdapter.setColumn(1); // 横向滚动,固定1列 + int episodeGridSpan = getEpisodeGridSpanCount(); + mBinding.episodeGrid.setItemAnimator(null); + mBinding.episodeGrid.setLayoutManager(new GridLayoutManager(this, episodeGridSpan)); + mBinding.episodeGrid.setAdapter(mEpisodeGridAdapter = new EpisodeAdapter(this, this::onEpisodeLongClick)); + mEpisodeGridAdapter.setGridMode(true); + mEpisodeGridAdapter.setVerticalGridMode(true); + mEpisodeGridAdapter.setColumn(episodeGridSpan); + mBinding.quality.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.quality.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.quality.setAdapter(mQualityAdapter = new QualityAdapter(this)); + mBinding.array.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.array.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.array.setAdapter(mArrayAdapter = new ArrayAdapter(this)); + mArrayAdapter.setOnKeyListener((view, keyCode, event) -> onArrayKey(event)); + mBinding.array.setOnKeyListener((view, keyCode, event) -> onArrayKey(event)); + mBinding.part.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.part.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.part.setAdapter(mPartAdapter = new PartAdapter(item -> initSearch(item, false))); + mBinding.quick.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.quick.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.quick.setAdapter(mQuickAdapter = new QuickAdapter(this)); + mBinding.control.parse.setHorizontalSpacing(ResUtil.dp2px(8)); + mBinding.control.parse.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.control.parse.setAdapter(mParseAdapter = new ParseAdapter(this)); + mParseAdapter.addAll(VodConfig.get().getParses()); + // TMDB 相关 GridView 初始化 + setupTmdbGridViews(); + } + + private void setupTmdbGridViews() { + mBinding.tmdbCast.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbCast.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbPhotos.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbPhotos.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbCrew.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbCrew.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbRelatedVideos.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbRelatedVideos.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbPersonalTmdbRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbPersonalTmdbRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbPersonalDoubanRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbPersonalDoubanRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mBinding.tmdbPersonalAiRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbPersonalAiRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + attachRecommendationLazyLoader(mBinding.tmdbRecommendations, RecommendationRow.RECOMMENDATIONS); + attachRecommendationLazyLoader(mBinding.tmdbPersonalTmdbRecommendations, RecommendationRow.PERSONAL_TMDB); + attachRecommendationLazyLoader(mBinding.tmdbPersonalDoubanRecommendations, RecommendationRow.PERSONAL_DOUBAN); + } + + private void setVideoView() { + mBinding.control.action.danmaku.setVisibility(DanmakuSetting.isLoad() ? View.VISIBLE : View.GONE); + mBinding.control.action.adFeedback.setVisibility(isAdFeedbackEnabled() ? View.VISIBLE : View.GONE); + applyDanmakuState(); + mBinding.control.action.reset.setText(ResUtil.getStringArray(R.array.select_reset)[Setting.getReset()]); + setupActionButtons(); + setPlayer(); + } + + private void setPlayer() { + mBinding.control.action.player.setText(service() == null ? ResUtil.getStringArray(R.array.select_player)[PlayerSetting.getPlayer()] : player().getPlayerText()); + } + private void setupActionButtons() { mActionButtons = new HashMap<>(); addActionButton(PlayerButtonSetting.NEXT, mBinding.control.action.next); @@ -1861,251 +1599,17 @@ private boolean runtimeSourceOnly; addActionButton(PlayerButtonSetting.TIMER, mBinding.control.action.timer); addActionButton(PlayerButtonSetting.REPEAT, mBinding.control.action.repeat); PlayerButtonSetting.applyOrder(mBinding.control.action.container, mActionButtons); - setupCustomActionButtons(); - updatePanDiagnosticAction(); - updateDiscMenuButton(); + applyActionButtonVisibility(); } private void addActionButton(String id, View view) { mActionButtons.put(id, view); } - private void setRecyclerView() { - mBinding.flag.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.flag.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.flag.setAdapter(mFlagAdapter = new FlagAdapter(this)); - mBinding.episode.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.episode.setVerticalSpacing(ResUtil.dp2px(8)); - mBinding.episode.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.episode.setAdapter(mEpisodeAdapter = new EpisodeAdapter(this, this::onEpisodeLongClick)); - mEpisodeAdapter.setColumn(1); // 横向滚动,固定1列 - int episodeGridSpan = getEpisodeGridSpanCount(); - mBinding.episodeGrid.setItemAnimator(null); - mBinding.episodeGrid.setLayoutManager(new GridLayoutManager(this, episodeGridSpan)); - mBinding.episodeGrid.setAdapter(mEpisodeGridAdapter = new EpisodeAdapter(this, this::onEpisodeLongClick)); - mEpisodeGridAdapter.setGridMode(true); - mEpisodeGridAdapter.setVerticalGridMode(true); - mEpisodeGridAdapter.setColumn(episodeGridSpan); - mBinding.quality.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.quality.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.quality.setAdapter(mQualityAdapter = new QualityAdapter(this)); - mBinding.array.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.array.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.array.setAdapter(mArrayAdapter = new ArrayAdapter(this)); - mArrayAdapter.setOnKeyListener((view, keyCode, event) -> onArrayKey(event)); - mBinding.array.setOnKeyListener((view, keyCode, event) -> onArrayKey(event)); - mBinding.array.addOnChildViewHolderSelectedListener(new OnChildViewHolderSelectedListener() { - @Override - public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { - if (child != null) selectEpisodeSegment(position, false); - } - }); - mBinding.part.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.part.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.part.setAdapter(mPartAdapter = new PartAdapter(item -> initSearch(item, false))); - mBinding.quick.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.quick.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.quick.setAdapter(mQuickAdapter = new QuickAdapter(this)); - mBinding.control.parse.setHorizontalSpacing(ResUtil.dp2px(8)); - mBinding.control.parse.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.control.parse.setAdapter(mParseAdapter = new ParseAdapter(this)); - mParseAdapter.addAll(VodConfig.get().getParses()); - setupTmdbGridViews(); - } - - private void setupTmdbGridViews() { - mBinding.tmdbCast.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbCast.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbPhotos.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbPhotos.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - // 海报行此前漏配 rowHeight(拆分剧照/海报时新增,只加了布局没加这里的配置): - // Leanback 的 HorizontalGridView 不设 rowHeight 时行高塌陷为 0, - // 于是“海报”标签正常显示但一张卡片都看不到。 - mBinding.tmdbPosters.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbPosters.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbCrew.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbCrew.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbRelatedVideos.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbRelatedVideos.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbPersonalTmdbRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbPersonalTmdbRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbPersonalDoubanRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbPersonalDoubanRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - mBinding.tmdbPersonalAiRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); - mBinding.tmdbPersonalAiRecommendations.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - attachRecommendationLazyLoader(mBinding.tmdbRecommendations, RecommendationRow.RECOMMENDATIONS); - attachRecommendationLazyLoader(mBinding.tmdbPersonalTmdbRecommendations, RecommendationRow.PERSONAL_TMDB); - attachRecommendationLazyLoader(mBinding.tmdbPersonalDoubanRecommendations, RecommendationRow.PERSONAL_DOUBAN); - for (HorizontalGridView row : tmdbMediaRows()) installRowCardFocusLinks(row); - } - - /** TMDB 详情区块的横向行,顺序与 getEpisodeFocusOrders() 的纵向焦点链保持一致。 */ - private List tmdbMediaRows() { - return Arrays.asList( - mBinding.tmdbCast, - mBinding.tmdbPhotos, - mBinding.tmdbPosters, - mBinding.tmdbRelatedVideos, - mBinding.tmdbCrew, - mBinding.tmdbRecommendations, - mBinding.tmdbPersonalTmdbRecommendations, - mBinding.tmdbPersonalDoubanRecommendations, - mBinding.tmdbPersonalAiRecommendations); - } - - /** - * 卡片被 RecyclerView 回收后重新接入时,补写它自己的上下焦点目标。 - * 只靠容器的 nextFocusDown 不生效:焦点此刻在卡片上,Android 优先读卡片自身的声明。 - */ - private void installRowCardFocusLinks(HorizontalGridView grid) { - grid.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() { - @Override - public void onChildViewAttachedToWindow(@NonNull View child) { - applyCardFocusLinks(grid, child); - } - - @Override - public void onChildViewDetachedFromWindow(@NonNull View child) { - } - }); - } - - private void applyCardFocusLinks(HorizontalGridView grid, View card) { - card.setNextFocusUpId(grid.getNextFocusUpId()); - card.setNextFocusDownId(grid.getNextFocusDownId()); - } - - /** - * 按当前可见行重排纵向焦点链:每行只指向上下相邻的可见行,跳过已隐藏的区块。 - * 之前只给容器设 nextFocusDown,遥控在行内卡片上按向下会退回几何搜索, - * 于是出现“第一张剧照下不去、第二张可以”这类与横向位置相关的不确定行为。 - */ - private void applyTmdbRowFocusChain() { - List candidates = tmdbMediaRows(); - boolean[] present = new boolean[candidates.size()]; - for (int i = 0; i < candidates.size(); i++) { - HorizontalGridView row = candidates.get(i); - present[i] = row != null && row.getVisibility() == View.VISIBLE && row.getAdapter() != null && row.getAdapter().getItemCount() > 0; - } - // 空行(没数据或标签与行一起隐藏)被直接跳过:剧照下面没有海报就落到相关视频, - // 相关视频也没有就落到主创团队——焦点永远交给一个真实存在的行。 - int[][] links = com.fongmi.android.tv.ui.helper.TmdbRowFocusChain.link(present); - int ratingsId = 0; - ViewGroup ratings = mBinding.getRoot().findViewById(R.id.tmdbOmdbRatings); - if (ratings != null && isVisible(ratings)) ratingsId = R.id.tmdbOmdbRatings; - for (int i = 0; i < candidates.size(); i++) { - HorizontalGridView row = candidates.get(i); - if (row == null || !present[i]) continue; - int upIndex = links[i][0]; - int downIndex = links[i][1]; - int upId = upIndex < 0 ? ratingsId : candidates.get(upIndex).getId(); - int downId = downIndex < 0 ? R.id.flag : candidates.get(downIndex).getId(); - row.setNextFocusUpId(upId == 0 ? View.NO_ID : upId); - row.setNextFocusDownId(downId); - for (int child = 0; child < row.getChildCount(); child++) { - applyCardFocusLinks(row, row.getChildAt(child)); - } - } - } - - private void setVideoView() { - mBinding.control.action.danmaku.setVisibility(DanmakuSetting.isLoad() ? View.VISIBLE : View.GONE); - mBinding.control.action.adFeedback.setVisibility(isAdFeedbackEnabled() ? View.VISIBLE : View.GONE); - applyDanmakuState(); - mBinding.control.action.reset.setText(ResUtil.getStringArray(R.array.select_reset)[Setting.getReset()]); - setupActionButtons(); - setPlayerButton(); - } - - private void setupCustomActionButtons() { - ensureCustomButtonContainers(); - for (View view : mCustomActionViews) { - ViewParent parent = view.getParent(); - if (parent instanceof ViewGroup) ((ViewGroup) parent).removeView(view); - } - mCustomActionViews.clear(); - mCustomPortraitButtonRow.removeAllViews(); - List buttons = MpvConfigStore.customButtons(); - for (int index = 0; index < buttons.size(); index++) { - MpvConfigStore.CustomButton button = buttons.get(index); - if (!button.isButtonVisible()) continue; - TextView view = new TextView(this); - view.setTextSize(13); - view.setTextColor(Color.WHITE); - view.setGravity(Gravity.CENTER); - view.setMinHeight(ResUtil.dp2px(40)); - view.setMinWidth(ResUtil.dp2px(56)); - view.setPadding(ResUtil.dp2px(10), ResUtil.dp2px(4), ResUtil.dp2px(10), ResUtil.dp2px(4)); - view.setBackgroundResource(R.drawable.selector_control_sheet_button); - view.setText(button.title); - view.setSingleLine(true); - view.setMaxWidth(ResUtil.dp2px(144)); - view.setEllipsize(TextUtils.TruncateAt.END); - view.setContentDescription(button.title); - view.setTag(button.id); - view.setOnClickListener(item -> { - player().sendMpvCustomButton(button.id, false); - setR1Callback(); - }); - view.setOnLongClickListener(item -> { - player().sendMpvCustomButton(button.id, true); - setR1Callback(); - return true; - }); - view.setFocusable(true); - view.setFocusableInTouchMode(true); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ResUtil.dp2px(40)); - params.setMargins(ResUtil.dp2px(4), ResUtil.dp2px(2), ResUtil.dp2px(4), ResUtil.dp2px(2)); - mCustomPortraitButtonRow.addView(view, params); - mCustomActionViews.add(view); - } - updateCustomButtonVisibility(); - } - - private void updateCustomButtonStates() { - MpvPlayer mpv = service() != null && isOwner() - && player().getPlayer() instanceof MpvPlayer active ? active : null; - if (mCustomButtonPlayer != mpv) { - if (mCustomButtonPlayer != null) mCustomButtonPlayer.removeCustomButtonStateListener(mCustomButtonStateListener); - mCustomButtonPlayer = mpv; - if (mpv != null) mpv.addCustomButtonStateListener(mCustomButtonStateListener); - } - for (View view : mCustomActionViews) { - view.setSelected(mpv != null && mpv.isCustomButtonActive((String) view.getTag())); - } - } - - private void ensureCustomButtonContainers() { - if (mCustomPortraitButtons != null) return; - mCustomPortraitButtons = new HorizontalScrollView(this); - mCustomPortraitButtons.setHorizontalScrollBarEnabled(false); - mCustomPortraitButtons.setFillViewport(false); - mCustomPortraitButtons.setClipToPadding(false); - mCustomPortraitButtons.setOverScrollMode(View.OVER_SCROLL_NEVER); - mCustomPortraitButtonRow = new LinearLayout(this); - mCustomPortraitButtonRow.setGravity(Gravity.CENTER_VERTICAL); - mCustomPortraitButtonRow.setOrientation(LinearLayout.HORIZONTAL); - mCustomPortraitButtonRow.setClipChildren(false); - mCustomPortraitButtons.addView(mCustomPortraitButtonRow, new HorizontalScrollView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.setMargins(0, 0, 0, ResUtil.dp2px(8)); - ((ViewGroup) mBinding.control.getRoot()).addView(mCustomPortraitButtons, 0, params); - } - - private void updateCustomButtonVisibility() { - updateCustomButtonStates(); - boolean visible = service() != null && player().isMpv() && isVisible(mBinding.control.getRoot()); - if (mCustomPortraitButtons != null) mCustomPortraitButtons.setVisibility(visible ? View.VISIBLE : View.GONE); - } - private void applyActionButtonVisibility() { - updateCustomButtonVisibility(); mBinding.control.action.cast.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE); updateImmersiveAudioAction(); updatePanDiagnosticAction(); - updateDiscMenuButton(); if (mActionButtons != null) PlayerButtonSetting.applyVisibility(mActionButtons); } @@ -2114,24 +1618,6 @@ private boolean runtimeSourceOnly; mBinding.control.action.panDiagnostic.setVisibility(isFullscreen() && canRunPanDiagnostic() ? View.VISIBLE : View.GONE); } - private void setPlayerButton() { - mBinding.control.action.player.setText(service() == null ? ResUtil.getStringArray(R.array.select_player)[PlayerSetting.getActivePlayer()] : player().getPlayerText()); - } - - private void updateDiscMenuButton() { - updateCustomButtonStates(); - MpvPlayer mpv = service() != null && isOwner() - && player().getPlayer() instanceof MpvPlayer active ? active : null; - if (mDiscMenuPlayer != mpv) { - if (mDiscMenuPlayer != null) mDiscMenuPlayer.removeDiscMenuStateListener(mDiscMenuStateListener); - mDiscMenuPlayer = mpv; - if (mpv != null) mpv.addDiscMenuStateListener(mDiscMenuStateListener); - } - boolean available = mpv != null && mpv.isDiscMenuAvailable(); - if (!available && mBinding.control.action.discMenu.hasFocus()) mBinding.control.action.playParams.requestFocus(); - mBinding.control.action.discMenu.setVisibility(available ? View.VISIBLE : View.GONE); - } - private boolean canRunPanDiagnostic() { if (service() == null || player().isEmpty()) return false; try { @@ -2170,60 +1656,6 @@ private boolean runtimeSourceOnly; mBinding.control.action.player.setText(player().getPlayerText()); } - /** - * 起播前把内核切到本剧记住的选择,并返回该内核给取址用——取播放地址要按内核区分线路。 - * 没有记录时 getPlayerOrDefault 会退回设置页的全局默认。 - * 播放服务还没连上时先只记会话内核(取址在工作线程上读它),引擎由 onServiceConnected 补齐。 - */ - private int applyHistoryPlayerKernel(boolean forcePrepare) { - int kernel = mHistory == null ? PlayerSetting.getPlayer() : mHistory.getPlayerOrDefault(); - PlayerSetting.putActivePlayer(kernel); - if (service() == null) { - mPendingPlayerKernel = kernel; - return kernel; - } - mPendingPlayerKernel = PlayerSetting.NONE; - player().preparePlayer(kernel, forcePrepare); - // preparePlayer() 可能在 PlaySpec 建立前就重建引擎,此窗口内归属判假, - // PlaybackActivity.onPlayerRebuild() 整段跳过(其中含 setProgressPlayer), - // 进度条会继续读已释放的旧引擎、不再刷新进度,故这里显式跟上新引擎。 - getSeekView().setProgressPlayer(player().getPlayer()); - setPlayerKernel(); - setDecode(); - return kernel; - } - - private int applyHistoryPlayerKernel() { - return applyHistoryPlayerKernel(false); - } - - /** - * 服务连上后补齐本页在等的内核。 - * 服务可能是上一次播放留活下来的,它建 PlayerManager 时读到的还是上一部剧的内核, - * 所以本页在服务就绪前定下的选择要在这里落到引擎上;没有待办时不动引擎。 - */ - private void applyPendingPlayerKernel() { - int kernel = mPendingPlayerKernel; - mPendingPlayerKernel = PlayerSetting.NONE; - if (!PlayerSetting.isPlayer(kernel)) return; - player().preparePlayer(kernel); - // 同上:服务就绪时补的内核重建同样早于 PlaySpec,重建回调被跳过。 - getSeekView().setProgressPlayer(player().getPlayer()); - } - - /** - * 把用户刚选定的内核写回本剧历史并落盘。 - * 只在用户显式换内核时调用,且用用户选的值而不是会话/引擎状态: - * 播放页可能重叠存在(上一部剧的 Activity 还没销毁),若挂在每次存历史上, - * 上一部剧的收尾存档会用别人的会话内核覆盖本剧记住的选择; - * 而引擎类型还会被播放失败后的自动回退改掉,也不代表用户改了选择。 - */ - private void rememberPlayerKernel(int type) { - if (mHistory == null || !PlayerSetting.isPlayer(type)) return; - mHistory.setPlayer(type); - syncHistory(); - } - private void setScale(int scale) { if (mHistory != null) mHistory.setScale(scale); if (SiteApi.PUSH.equals(getKey())) PlayerSetting.putScale(scale); @@ -2251,6 +1683,7 @@ private boolean runtimeSourceOnly; private void checkCast() { if (isCast() && !isFullscreen()) enterFullscreen(); else if (mAudioStageVisible) mBinding.progressLayout.showContent(); + else if (shouldLoadTmdbDetail() && Setting.isOriginalEnhancedDetailPage()) mBinding.progressLayout.showProgress(); else if (hasInitialPreview()) showInitialPreview(); else mBinding.progressLayout.showProgress(); } @@ -2269,7 +1702,7 @@ private boolean runtimeSourceOnly; if (cached == null) return false; detailStartTime = System.currentTimeMillis(); detailHealthRecorded = true; - if (shouldRequestCachedDetailLoading()) mBinding.progressLayout.showProgress(); + mBinding.progressLayout.showProgress(); SpiderDebug.log("video-flow", "detail cache hit queued key=%s id=%s name=%s", getKey(), getId(), cached.getName()); if (tryStartFastTmdbPlayback(cached)) { return true; @@ -2291,121 +1724,6 @@ private boolean runtimeSourceOnly; SpiderDebug.log("video-flow", "fast tmdb playback pending service key=%s id=%s name=%s", getKey(), getId(), item.getName()); } - // C111: initView's heavy tail cannot run in the same tick that first requests the loading spinner, - // otherwise the spinner never gets a frame. Stages are queued in original order and pumped one per frame. - private void deferInitViewStage(String name, Runnable stage) { - mInitViewStages.addLast(() -> runInitViewStage(name, stage)); - // A stage queued after the chain was launched (the service-ready tail) has no scheduler of its own: - // the frame-paced chain may already have drained, and pumpInitViewStage() drops the queue on teardown - // without rescheduling. Re-arm the net so a late stage is still pumped instead of waiting forever. - if (mInitViewStagesLaunched) armInitViewStageSafetyNet(); - } - - private void runInitViewStage(String name, Runnable stage) { - long start = System.currentTimeMillis(); - SpiderDebug.log("video-flow", "initView stage-%s begin sinceLaunch=%dms", name, getLaunchCost(start)); - try { - stage.run(); - } finally { - SpiderDebug.log("video-flow", "initView stage-%s end cost=%dms", name, System.currentTimeMillis() - start); - } - } - - private void launchInitViewStages() { - if (mInitViewStagesLaunched || mInitViewStages.isEmpty()) return; - mInitViewStagesLaunched = true; - mInitViewLastPumpAt = System.currentTimeMillis(); - final View root = mBinding == null ? null : mBinding.getRoot(); - if (root == null) { - SpiderDebug.log("video-flow", "initView stage schedule fallback root=null left=%d", mInitViewStages.size()); - App.post(this::pumpInitViewStage); - return; - } - ViewTreeObserver observer = root.getViewTreeObserver(); - if (observer == null || !observer.isAlive()) { - SpiderDebug.log("video-flow", "initView stage schedule fallback observer dead left=%d", mInitViewStages.size()); - App.post(this::pumpInitViewStage); - return; - } - // Wait for one real traversal so the spinner has been painted, then hand the pump to the looper - // queue; running inside the frame callback would still sit in front of that frame's draw. - observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { - @Override - public boolean onPreDraw() { - ViewTreeObserver live = root.getViewTreeObserver(); - if (live.isAlive()) live.removeOnPreDrawListener(this); - SpiderDebug.log("video-flow", "initView first frame drawn sinceLaunch=%dms stages=%d", getLaunchCost(System.currentTimeMillis()), mInitViewStages.size()); - root.post(VideoActivity.this::pumpInitViewStage); - return true; - } - }); - armInitViewStageSafetyNet(); - } - - // Safety net only: the frame-paced chain owns the pacing, but it dies silently if the root detaches - // between two stages (postOnAnimation callbacks are then dropped) and the remaining stages never run, - // which would leave the detail request unissued. Re-arm while stages are left so a stalled chain still - // finishes; a drained queue means the chain already did its job and the net stops. - private void armInitViewStageSafetyNet() { - final View root = mBinding == null ? null : mBinding.getRoot(); - if (root == null) return; - root.postDelayed(() -> { - if (mInitViewStages.isEmpty()) return; - // Only take over when the frame-paced chain has really stalled; otherwise the net would add a - // second, slower pacing source and drain one stage per 500ms on a slow first frame. - if (System.currentTimeMillis() - mInitViewLastPumpAt < INIT_VIEW_STAGE_STALL_TIMEOUT_MS) { - armInitViewStageSafetyNet(); - return; - } - SpiderDebug.log("video-flow", "initView stage schedule fallback stalled left=%d", mInitViewStages.size()); - pumpInitViewStage(); - armInitViewStageSafetyNet(); - }, INIT_VIEW_STAGE_FALLBACK_DELAY_MS); - } - - private void pumpInitViewStage() { - mInitViewLastPumpAt = System.currentTimeMillis(); - Runnable stage = mInitViewStages.pollFirst(); - if (stage == null) return; - if (isFinishing() || isDestroyed()) { - int dropped = mInitViewStages.size() + 1; - mInitViewStages.clear(); - SpiderDebug.log("video-flow", "initView stages dropped finishing=%s destroyed=%s dropped=%d key=%s id=%s", isFinishing(), isDestroyed(), dropped, getKey(), getId()); - return; - } - stage.run(); - scheduleNextInitViewStage(); - } - - private void scheduleNextInitViewStage() { - if (mInitViewStages.isEmpty()) return; - final View root = mBinding == null ? null : mBinding.getRoot(); - if (root == null || !root.isAttachedToWindow()) { - SpiderDebug.log("video-flow", "initView stage schedule fallback attached=false left=%d", mInitViewStages.size()); - App.post(this::pumpInitViewStage); - return; - } - // Two frame callbacks leave at least two drawn frames between stages; the looper post then puts the - // next stage after that frame's traversal instead of before it. - root.postOnAnimation(() -> root.postOnAnimation(() -> root.post(VideoActivity.this::pumpInitViewStage))); - } - - private void registerFlagSelectionListener() { - if (mFlagSelectionListenerRegistered || mFlagAdapter == null) return; - mFlagSelectionListenerRegistered = true; - mBinding.flag.addOnChildViewHolderSelectedListener(new FlagSelectionListener(mBinding.flag, mFlagAdapter, this)); - SpiderDebug.log("video-flow", "flag selection listener registered flags=%d", mFlagAdapter.getItemCount()); - } - - // C111: request the full-page spinner only when this tick really has nothing to reveal anyway. checkCast() - // already revealed the shell/preview for those branches, and the fast playback path forces the content - // state a few statements later, so asking for progress there would only flip the visible state back. - private boolean shouldRequestCachedDetailLoading() { - if (shouldRevealShellWhileLoading()) return false; - if (hasInitialPreview() || mAudioStageVisible) return false; - return !isIntentTmdbPlayback(); - } - private void flushPendingFastTmdbPlayback() { Vod item = mPendingFastTmdbPlaybackVod; if (item == null) return; @@ -2442,7 +1760,7 @@ private boolean runtimeSourceOnly; mBinding.widget.title.setSelected(true); setText(item); hydrateFastTmdbPlaybackDetail(item); - requestInitialPlaybackFocus(); + mBinding.video.requestFocus(); showProgress(); showFastTmdbPlaybackContent(); SpiderDebug.log("video-flow", "fast tmdb playback reveal cost=%dms key=%s episode=%s", System.currentTimeMillis() - start, getKey(), getIntentPlaybackEpisodeName()); @@ -2482,8 +1800,6 @@ private boolean runtimeSourceOnly; Episode episode = findFastTmdbPlaybackEpisode(flag); if (flag == null || episode == null || TextUtils.isEmpty(episode.getUrl())) { mFastTmdbPlaybackStarted = false; - // 快速起播失败回落时从未起播,状态收圈路径全都到不了,这里必须显式收起加载圈,否则转圈永久残留。 - hideProgress(); SpiderDebug.log("video-flow", "fast tmdb playback missing playable episode key=%s id=%s", getKey(), getId()); applyCachedTmdbDetailNextFrame(item); return; @@ -2499,13 +1815,11 @@ private boolean runtimeSourceOnly; } playerStartTime = System.currentTimeMillis(); beginPlayHealth(); + // 快速 TMDB 起播绕过 getPlayer,仍需作废上一条播放的反馈状态 + resetAdFeedback(); prepareFastTmdbPlaybackHistory(item, flag, episode); SpiderDebug.log("video-flow", "fast tmdb playback start cost=%dms key=%s flag=%s episode=%s url=%s", System.currentTimeMillis() - start, getKey(), flag.getFlag(), episode.getName(), episode.getUrl()); - // 快播路径不经过 getPlayer:不在这里补发,插件第一次点击就会读到上一集的上下文。 - publishPluginEpisodeContext(episode); - beginPlayerContentRequest(getKey(), flag.getFlag(), episode.getUrl()); - mPlaybackFlagKey = playbackFlagKey(flag); - mViewModel.playerContent(getKey(), flag.getFlag(), episode.getUrl(), applyHistoryPlayerKernel()); + mViewModel.playerContent(getKey(), flag.getFlag(), episode.getUrl()); mBinding.getRoot().post(() -> hydrateFastTmdbPlaybackDetail(item)); applyFastTmdbPlaybackFullDetailNextFrame(item); } @@ -2561,7 +1875,7 @@ private boolean runtimeSourceOnly; } if (!TextUtils.isEmpty(wall)) setContextWall(wall); if (mHistory != null) { - if (!TextUtils.isEmpty(item.getName())) mHistory.setVodName(historyVodName(item)); + if (!TextUtils.isEmpty(item.getName())) mHistory.setVodName(item.getName()); enrichHistoryMeta(item); if (!TextUtils.isEmpty(wall)) mHistory.setWallPic(wall); if (!TextUtils.isEmpty(artwork)) { @@ -2584,8 +1898,7 @@ private boolean runtimeSourceOnly; if (!TextUtils.isEmpty(overview) && (TextUtils.isEmpty(item.getContent()) || overview.length() > item.getContent().length())) { item.setContent(overview); } - String title = new com.fongmi.android.tv.service.TmdbService().preferredTitle(cached.getItem(), detail, currentTmdbConfig()); - if (TextUtils.isEmpty(title)) title = firstNonEmpty(cachedTmdbString(detail, "title"), cachedTmdbString(detail, "name"), cached.getItem() == null ? "" : cached.getItem().getTitle()); + String title = firstNonEmpty(cachedTmdbString(detail, "title"), cachedTmdbString(detail, "name"), cached.getItem() == null ? "" : cached.getItem().getTitle()); if (!TextUtils.isEmpty(title) && TextUtils.isEmpty(item.getName())) item.setName(title); String artwork = firstNonEmpty(cachedFastTmdbPoster(cached), cachedFastTmdbBackdrop(cached)); if (!TextUtils.isEmpty(artwork) && TextUtils.isEmpty(item.getPic())) item.setPic(artwork); @@ -2613,7 +1926,7 @@ private boolean runtimeSourceOnly; private TmdbDetailCache.Entry takeFastTmdbDetailCache() { if (mFastTmdbDetailCacheChecked) return mFastTmdbDetailCache; mFastTmdbDetailCacheChecked = true; - mFastTmdbDetailCache = TmdbDetailCache.take(getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), getTmdbItem(), currentTmdbLanguage()); + mFastTmdbDetailCache = TmdbDetailCache.take(getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), getTmdbItem()); if (mFastTmdbDetailCache != null) { TmdbItem item = mFastTmdbDetailCache.getItem(); SpiderDebug.log("video-flow", "fast tmdb detail memory-cache hit title=%s media=%s id=%d", item == null ? "" : item.getTitle(), item == null ? "" : item.getMediaType(), item == null ? 0 : item.getTmdbId()); @@ -2622,23 +1935,56 @@ private boolean runtimeSourceOnly; } private String cachedTmdbOverview(JsonObject detail) { + String overview = cachedTmdbString(detail, "overview"); + if (!TextUtils.isEmpty(overview)) return overview.trim(); + JsonArray translations = cachedTmdbArray(cachedTmdbObject(detail, "translations"), "translations"); + String language = cachedTmdbLanguage(); + overview = cachedTmdbOverviewForLanguage(translations, language); + if (!TextUtils.isEmpty(overview)) return overview; + overview = cachedTmdbOverviewForLanguage(translations, cachedTmdbLanguageRoot(language)); + if (!TextUtils.isEmpty(overview)) return overview; + overview = cachedTmdbOverviewForLanguage(translations, "zh-CN"); + if (!TextUtils.isEmpty(overview)) return overview; + overview = cachedTmdbOverviewForLanguage(translations, "zh"); + if (!TextUtils.isEmpty(overview)) return overview; + return cachedTmdbOverviewForLanguage(translations, "en"); + } + + private String cachedTmdbOverviewForLanguage(JsonArray translations, String language) { + if (translations == null || TextUtils.isEmpty(language)) return ""; + String target = language.toLowerCase(Locale.ROOT); + for (JsonElement element : translations) { + if (element == null || !element.isJsonObject()) continue; + JsonObject object = element.getAsJsonObject(); + String iso = cachedTmdbString(object, "iso_639_1"); + String name = firstNonEmpty(cachedTmdbString(object, "name"), cachedTmdbString(object, "english_name")); + String code = cachedTmdbString(object, "iso_3166_1"); + if (!cachedTmdbLanguageMatches(target, iso, code, name)) continue; + String overview = cachedTmdbString(cachedTmdbObject(object, "data"), "overview"); + if (!TextUtils.isEmpty(overview)) return overview.trim(); + } + return ""; + } + + private boolean cachedTmdbLanguageMatches(String target, String iso, String code, String name) { + String root = cachedTmdbLanguageRoot(target); + if (target.equalsIgnoreCase(iso) || target.equalsIgnoreCase(iso + "-" + code)) return true; + if (!TextUtils.isEmpty(root) && root.equalsIgnoreCase(iso)) return true; + return target.equalsIgnoreCase(name); + } + + private String cachedTmdbLanguage() { try { - return new com.fongmi.android.tv.service.TmdbService().translatedOverview(detail, currentTmdbConfig()); + return com.fongmi.android.tv.bean.TmdbConfig.objectFrom(Setting.getTmdbConfig()).getLanguage(); } catch (Throwable e) { - return cachedTmdbString(detail, "overview"); + return ""; } } - private com.fongmi.android.tv.bean.TmdbConfig currentTmdbConfig() { - try { - return com.fongmi.android.tv.bean.TmdbConfig.effectiveCurrent(); - } catch (Throwable e) { - return null; - } - } - - private String currentTmdbLanguage() { - return TmdbLanguagePolicy.requestLanguage(currentTmdbConfig(), Setting.getLanguage()); + private String cachedTmdbLanguageRoot(String language) { + if (TextUtils.isEmpty(language)) return ""; + int separator = language.indexOf('-'); + return separator > 0 ? language.substring(0, separator) : language; } private String cachedTmdbImage(JsonObject detail, String key) { @@ -2651,7 +1997,7 @@ private boolean runtimeSourceOnly; if (path.startsWith("http://") || path.startsWith("https://")) return path; String base = ""; try { - com.fongmi.android.tv.bean.TmdbConfig config = com.fongmi.android.tv.bean.TmdbConfig.effectiveCurrent(); + com.fongmi.android.tv.bean.TmdbConfig config = com.fongmi.android.tv.bean.TmdbConfig.objectFrom(Setting.getTmdbConfig()); base = backdrop ? config.getBackdropBase() : config.getImageBase(); } catch (Throwable ignored) { } @@ -2685,14 +2031,16 @@ private boolean runtimeSourceOnly; private Flag findFastTmdbPlaybackFlag(Vod item) { if (item == null) return null; - // 不再退回第一条线路:intent 选集失配时交给正常链路按历史身份重新定位,而不是静默播列表第一条。 - return findIntentPlaybackFlag(item.getFlags(), getIntentPlaybackFlagKey(), + Flag flag = findIntentPlaybackFlag(item.getFlags(), getIntentPlaybackFlagKey(), getIntentPlaybackFlag(), getIntentPlaybackEpisodeUrl()); + if (flag != null) return flag; + return item.getFlags().isEmpty() ? null : item.getFlags().get(0); } private Episode findFastTmdbPlaybackEpisode(Flag flag) { - // 同上:定位不到就返回 null,由 startPendingFastTmdbPlayback 回落正常链路,绝不取第一集。 - return findIntentPlaybackEpisode(flag, getIntentPlaybackEpisodeName(), getIntentPlaybackEpisodeUrl()); + Episode episode = findIntentPlaybackEpisode(flag, getIntentPlaybackEpisodeName(), getIntentPlaybackEpisodeUrl()); + if (episode != null) return episode; + return flag == null || flag.getEpisodes().isEmpty() ? null : flag.getEpisodes().get(0); } private void prepareFastTmdbPlaybackHistory(Vod item, Flag flag, Episode episode) { @@ -2700,12 +2048,13 @@ private boolean runtimeSourceOnly; mHistory = mHistory == null ? createHistory(item) : mHistory; if (!TextUtils.isEmpty(getWallPic())) mHistory.setWallPic(getWallPic()); if (!TextUtils.isEmpty(getMark())) mHistory.setVodRemarks(getMark()); - mHistory.setVodName(historyVodName(item)); + mHistory.setVodName(item.getName()); if (!TextUtils.isEmpty(getInitialArtwork(item))) mHistory.setVodPic(getInitialArtwork(item)); enrichHistoryMeta(item); selectFastTmdbPlaybackEpisode(item, flag, episode); updateFastTmdbPlaybackHistory(flag, episode); - setOpeningEndingText(); + mBinding.control.action.opening.setText(mHistory.getOpening() <= 0 ? getString(R.string.play_op) : Util.timeMs(mHistory.getOpening())); + mBinding.control.action.ending.setText(mHistory.getEnding() <= 0 ? getString(R.string.play_ed) : Util.timeMs(mHistory.getEnding())); float speed = getPlaybackSpeed(); mBinding.control.action.speed.setText(player().setSpeed(speed)); PlaybackEventCollector.get().updateHistory(mHistory); @@ -2721,9 +2070,7 @@ private boolean runtimeSourceOnly; // 快速 TMDB 播放在历史续播或聚合开启时,允许同一标准季集跨线路共享进度。 boolean crossSource = mHistory.isCrossSourcePlayback(); boolean shareEpisodeProgress = crossSource || isResumeFromHistory() || Setting.isHistoryAggregationEffective(); - // 历史集 URL 能定位到当前线路条目(同集多版本并存)时才启用版本消歧;换线路/刷新保留集号容错。 - boolean versionAware = flag.containsEpisodeUrl(mHistory.getEpisode()); - boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode(), versionAware); + boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode()); boolean compatibleFlag = shareEpisodeProgress || TextUtils.equals(mHistory.getVodFlag(), flag.getFlag()); if (!sameEpisode || !compatibleFlag) mIntroSkipPlayback.reset(); if (!sameEpisode || !compatibleFlag) { @@ -2775,27 +2122,18 @@ private boolean runtimeSourceOnly; mBackdropSignature = null; mBackdropSlideshowActive = false; if (mViewModel != null) mViewModel.stopSearch(); - if (mViewModel != null) mViewModel.cancelPlayerContent(); - invalidatePlayerContent(); if (mBroken != null) mBroken.clear(); clearDetailAdapters(); clearTmdbDetailViews(); - // 换条目/重新加载:手动播放状态归零,新条目的待播封面与待办焦点重新判定。 - mManualPlaybackStarted = false; mBinding.scroll.scrollTo(0, 0); mBinding.name.setText(getName()); mBinding.widget.title.setText(getName()); - // 换条目后初始焦点照旧交给预览框:手动模式下在预览框上按确认键即完成「全屏 + 起播」。 - requestInitialPlaybackFocus(); - updateVideoCover(); + mBinding.video.requestFocus(); + updateNavigationKey(); if (service() != null) { player().reset(); player().stop(); - player().clear(); } - updateNavigationKey(); - // singleTop 切换条目时也要先按新 Intent 准备详情壳,不能沿用旧条目的操作按钮。 - prepareInitialDetailShell(); mBinding.progressLayout.showProgress(); } @@ -2907,10 +2245,7 @@ private boolean runtimeSourceOnly; finish(); return; } - // C111: the adapters are created by initView stage 1-recycler, which now runs after setViewModel() - // registered this observer. A Result that arrives before stage 1 must wait like a missing service - // instead of dereferencing mFlagAdapter inside setDetail(Vod). - if (service() == null || mFlagAdapter == null) { + if (service() == null) { mPendingDetail = result; SpiderDebug.log("video-flow", "detail pending service key=%s id=%s", getKey(), getId()); return; @@ -2921,7 +2256,7 @@ private boolean runtimeSourceOnly; } private void setEmpty(boolean finish) { - if (finish) { + if (isFromCollect() || finish) { finish(); } else if (getName().isEmpty()) { showEmpty(); @@ -2947,31 +2282,18 @@ private boolean runtimeSourceOnly; App.removeCallbacks(mTmdbEpisodeTimeout); item.checkPic(getPic()); item.checkName(getName()); + boolean loadTmdbDetail = shouldLoadTmdbDetail(); item.checkContent(getContent()); applyIntentTmdbVodRemark(item); - TmdbConfig tmdbConfig = TmdbConfig.effectiveCurrent(); - TmdbSourcePayload sourcePayload = TmdbSourcePayloadParser.parse(item.getTmdb()); - TmdbBundle sourceBundle = TmdbSourceAdapter.toBundle(sourcePayload, item, tmdbConfig); - TmdbSourceState sourceState = TmdbSourceAvailability.classify(item, sourcePayload, sourceBundle); - DetailRuntimeModePolicy.Decision decision = DetailRuntimeModePolicy.resolve(new DetailRuntimeModePolicy.Input( - policyConfiguredMode(), - tmdbConfig.isReady(), - TmdbSitePolicy.isEnabled(tmdbConfig, getKey(), getId()), - sourceState - )); - applyRuntimeDetailMode(decision.runtimeMode()); - runtimeSourceOnly = decision.sourceOnly(); - boolean loadTmdbDetail = shouldLoadTmdbDetail(); - setOriginalEnhancedActionVisibility(loadTmdbDetail && (isRuntimeOriginalEnhancedMode() || isIntentTmdbPlayback())); + setOriginalEnhancedActionVisibility(loadTmdbDetail && (Setting.isOriginalEnhancedDetailPage() || isIntentTmdbPlayback())); if (isIntentTmdbPlayback()) com.fongmi.android.tv.utils.TmdbEpisodeSorter.sort(item); applyTmdbEpisodeTitles(item); - setTmdbRematchVisible(loadTmdbDetail && !runtimeSourceOnly); + setTmdbRematchVisible(loadTmdbDetail); // 非 TMDB:立即揭开,全部内容一次性出现;TMDB:继续停在 loading,等富集完成再揭开 if (!loadTmdbDetail) mBinding.progressLayout.showContent(); mBinding.name.setText(item.getName()); mFlagAdapter.addAll(item.getFlags()); - requestInitialPlaybackFocus(); - updateVideoCover(); + mBinding.video.requestFocus(); App.removeCallbacks(mR4); if (!checkHistory(item)) return; checkFlag(item); @@ -2982,41 +2304,27 @@ private boolean runtimeSourceOnly; if (loadTmdbDetail) hideNativePersonalRecommendations(); else loadNativePersonalRecommendations(item); if (loadTmdbDetail && shouldShowTmdbLoadingOverlay()) showTmdbDetailLoading(); - else if (loadTmdbDetail) revealShellWhileTmdbLoads(); + else if (loadTmdbDetail) SpiderDebug.log("tmdb-tv", "detail loading overlay skipped during fast playback"); // TMDB 增强:自动匹配并增强 Vod - if (mTmdbUIAdapter != null && (mTmdbUIAdapter.isReady() || runtimeSourceOnly)) { + if (mTmdbUIAdapter != null && mTmdbUIAdapter.isReady()) { mTmdbUIAdapter.setActiveFlag(getFlag()); - if (sourceState == TmdbSourceState.RENDERABLE && sourceBundle != null) { - SpiderDebug.log("tmdb-tv", "source-first load vodTitle=%s tmdbId=%d media=%s sourceOnly=%s", item.getName(), sourceBundle.item().getTmdbId(), sourceBundle.item().getMediaType(), runtimeSourceOnly); - mTmdbUIAdapter.loadSource(sourceBundle, item, sourcePayload); + com.fongmi.android.tv.bean.TmdbItem tmdbItem = getTmdbItem(); + if (tmdbItem != null) { + SpiderDebug.log("tmdb-tv", "direct load vodTitle=%s tmdbTitle=%s tmdbId=%d media=%s cache=%s", item.getName(), tmdbItem.getTitle(), tmdbItem.getTmdbId(), tmdbItem.getMediaType(), mFastTmdbDetailCache != null); + mTmdbUIAdapter.load(tmdbItem, item, mFastTmdbDetailCache); } else { - com.fongmi.android.tv.bean.TmdbItem tmdbItem = getTmdbItem(); - if (tmdbItem != null) { - SpiderDebug.log("tmdb-tv", "direct load vodTitle=%s tmdbTitle=%s tmdbId=%d media=%s cache=%s", item.getName(), tmdbItem.getTitle(), tmdbItem.getTmdbId(), tmdbItem.getMediaType(), mFastTmdbDetailCache != null); - mTmdbUIAdapter.load(tmdbItem, item, mFastTmdbDetailCache); - } else { - mTmdbUIAdapter.autoMatch(item.getName(), item, getSearchKeyword()); - } + mTmdbUIAdapter.autoMatch(item.getName(), item, getSearchKeyword()); } } } private boolean shouldLoadTmdbDetail() { - return mTmdbUIAdapter != null && (mTmdbUIAdapter.isReady() || runtimeSourceOnly); + return mTmdbUIAdapter != null && mTmdbUIAdapter.isReady(); } private boolean shouldShowTmdbLoadingOverlay() { - return !mFastTmdbPlaybackStarted && !shouldRevealShellWhileLoading(); - } - - /** - * 原生增强把详情与播放放在同一页:进入即揭开页面骨架,加载态只由播放器窗口内那一层表达, - * 不再先整页转圈、揭开后再转一次,避免同一次进入出现两层「加载中」。 - */ - private boolean shouldRevealShellWhileLoading() { - // 影视原生与原生增强一样由播放器窗口表达加载态,避免进场后整页再转一次。 - return isRuntimeOriginalEnhancedMode() || isRuntimeDirectMode(); + return !mFastTmdbPlaybackStarted; } private void setOriginalEnhancedActionVisibility(boolean hide) { @@ -3024,16 +2332,14 @@ private boolean runtimeSourceOnly; mBinding.change1.setVisibility(hide ? View.VISIBLE : View.GONE); mBinding.searchDetail.setVisibility(hide ? View.GONE : View.VISIBLE); mBinding.keep.setNextFocusLeftId(hide ? R.id.change1 : R.id.searchDetail); - mBinding.following.setNextFocusLeftId(R.id.keep); } private void setTmdbRematchVisible(boolean visible) { mBinding.tmdbRematch.setVisibility(visible ? View.VISIBLE : View.GONE); mBinding.searchDetail.setNextFocusRightId(R.id.keep); mBinding.change1.setNextFocusRightId(R.id.keep); - mBinding.keep.setNextFocusRightId(R.id.following); - mBinding.following.setNextFocusRightId(visible ? R.id.tmdbRematch : View.NO_ID); - mBinding.tmdbRematch.setNextFocusLeftId(R.id.following); + mBinding.keep.setNextFocusRightId(visible ? R.id.tmdbRematch : View.NO_ID); + mBinding.tmdbRematch.setNextFocusLeftId(R.id.keep); mBinding.tmdbRematch.setNextFocusRightId(View.NO_ID); } @@ -3049,22 +2355,6 @@ private boolean runtimeSourceOnly; SpiderDebug.log("tmdb-tv", "detail loading show (full-screen progress)"); } - /** - * 不遮挡整页的加载方式:站源详情到手即揭开骨架(视频窗口、选集、线路都在位), - * TMDB 富集在原地补齐。会被 TMDB 覆盖的站源文本先压掉,避免揭开后再跳一次文本。 - */ - private void revealShellWhileTmdbLoads() { - boolean hiddenByLoading = !mBinding.progressLayout.isContent(); - if (!mBinding.progressLayout.isContent()) mBinding.progressLayout.showContent(); - if (shouldUseTmdbLayout()) suppressTmdbNativeTextFields(); - if (hiddenByLoading && !mBinding.getRoot().hasFocus()) { - mBinding.video.post(() -> { - if (!mBinding.getRoot().hasFocus()) requestInitialPlaybackFocus(); - }); - } - SpiderDebug.log("tmdb-tv", "detail shell revealed while tmdb loads (single loading layer)"); - } - // TMDB 数据成功返回:揭开内容(仅一次)并应用 TMDB 字段(每次都应用) private void finishTmdbDetail() { revealTmdbDetail(); @@ -3076,14 +2366,12 @@ private boolean runtimeSourceOnly; // 揭开全屏 loading、一次性显示全部内容,幂等(超时或数据到达都会调用,只执行一次) private void revealTmdbDetail() { if (mTmdbDetailRevealed) return; - boolean hiddenByLoading = !mBinding.progressLayout.isContent(); mTmdbDetailRevealed = true; mTmdbDetailLoading = false; App.removeCallbacks(mTmdbDetailTimeout); mBinding.progressLayout.showContent(); - // 内容从 INVISIBLE 恢复为 VISIBLE 后,焦点需要重新回到播放器。 - // 骨架已经先揭开时不能再抢焦点,否则会把用户已经移到选集上的焦点拽回播放器。 - if (hiddenByLoading) mBinding.video.post(this::requestInitialPlaybackFocus); + // 内容从 INVISIBLE 恢复为 VISIBLE 后,焦点需要重新回到播放器 + mBinding.video.post(() -> mBinding.video.requestFocus()); SpiderDebug.log("tmdb-tv", "detail loading reveal (show content)"); } @@ -3112,42 +2400,26 @@ private boolean runtimeSourceOnly; } // 简介移到站源行下方显示;内容相同时不重置 maxLines,避免播放页再次布局闪动。 - updateOverviewPreview(); + Object desc = mBinding.content.getTag(); + String overview = desc == null ? "" : desc.toString(); + if (!TextUtils.isEmpty(overview)) { + CharSequence overviewText = getString(R.string.detail_content, overview); + if (!TextUtils.equals(mBinding.tmdbOverview.getText(), overviewText)) { + mBinding.tmdbOverview.setSingleLine(false); + mBinding.tmdbOverview.setHorizontallyScrolling(false); + mBinding.tmdbOverview.setMaxLines(Integer.MAX_VALUE); + mBinding.tmdbOverview.setText(overviewText); + mBinding.tmdbOverview.post(this::updateTmdbOverviewButton); + } + mBinding.tmdbOverview.setVisibility(View.VISIBLE); + } else { + mBinding.tmdbOverview.setVisibility(View.GONE); + } // 简介按钮默认隐藏,焦点先把视频右移到简介按钮(按钮显示时)或搜索按钮 mBinding.video.setNextFocusRightId(R.id.content); } - /** - * 把详情文本(mBinding.content 的 tag)铺到简介区做「预览一部分」。 - * - * 影视原生 (DIRECT) 模式下 mTmdbUIAdapter 为 null,applyTmdbDetailFields() 永不运行, - * 此前没有任何代码写这个节点,于是简介只能靠点按钮弹窗看,用户报告「简介不显示, - * 必须点击简介才能弹窗,以前这里会显示一部分」。 - */ - private void updateOverviewPreview() { - Object desc = mBinding.content.getTag(); - String overview = desc == null ? "" : desc.toString(); - if (TextUtils.isEmpty(overview)) { - mBinding.tmdbOverview.setText(""); - mBinding.tmdbOverview.setVisibility(View.GONE); - return; - } - CharSequence overviewText = getString(R.string.detail_content, overview); - if (!TextUtils.equals(mBinding.tmdbOverview.getText(), overviewText)) { - mBinding.tmdbOverview.setSingleLine(false); - mBinding.tmdbOverview.setHorizontallyScrolling(false); - mBinding.tmdbOverview.setMaxLines(Integer.MAX_VALUE); - mBinding.tmdbOverview.setText(overviewText); - } - // 详情列可能已经被沉浸音频收起:setDetail() 里 checkFlag() 排在 setText() 之前, - // checkFlag()->onItemClick()->onRefresh()->getPlayer() 会先 setAudioStageVisible(true) - // 把整列(含本节点)收掉,这里再无条件下 show 就会把刚收起的简介重新亮在音频面板旁边。 - // 详情列的显示/隐藏以 setVideoDetailsVisible() 为唯一所有者,本写入只负责是否“有内容”。 - mBinding.tmdbOverview.setVisibility(mAudioStageVisible ? View.GONE : View.VISIBLE); - mBinding.tmdbOverview.post(this::updateTmdbOverviewButton); - } - private void updateTmdbOverviewButton() { if (isFinishing() || isDestroyed()) return; TextView view = mBinding.tmdbOverview; @@ -3161,11 +2433,8 @@ private boolean runtimeSourceOnly; } } boolean truncated = isTextTruncated(view); - // 影视原生没有 TMDB 那条「按钮由富集结果决定」的回显路径,简介按钮是看全文的唯一入口, - // 不能因为文字恰好没被截断就把它藏掉,否则简介区一旦放得下全文就再也点不开。 - boolean directMode = !isTmdbSourceEnabled(); - mBinding.content.setVisibility(truncated || directMode ? View.VISIBLE : View.GONE); - mBinding.video.setNextFocusRightId(truncated || directMode ? R.id.content : R.id.keep); + mBinding.content.setVisibility(truncated ? View.VISIBLE : View.GONE); + mBinding.video.setNextFocusRightId(truncated ? R.id.content : R.id.keep); } private int getTmdbOverviewMaxLines(TextView view, android.text.Layout layout) { @@ -3222,30 +2491,13 @@ private boolean runtimeSourceOnly; setText(mBinding.director, R.string.detail_director, item.getDirector()); setText(mBinding.actor, R.string.detail_actor, item.getActor()); setText(mBinding.remark, 0, item.getRemarks()); - // TMDB 模式下简介区由 applyTmdbDetailFields() 按富集结果接管,此处只喂影视原生, - // 否则站源简介会先上屏、TMDB 到达后再换一次,产生用户可见的文本闪动。 - if (!isTmdbSourceEnabled()) updateOverviewPreview(); updateAudioStageText(); } private void setText(TextView view, int resId, String text) { - if (TextUtils.isEmpty(text)) { - // 值缺失时必须清掉文本,不能只置 GONE:带标签的字段渲染出来的是 - // getString(resId, ""),也就是 "导演:" 这种非空字符串,随后 - // setVideoDetailsVisible() 按 !getText().isEmpty() 又把它翻回 VISIBLE, - // 于是详情页出现只有标签、没有内容的空行(用户截图所见)。 - // 保留旧文本则会在换集后泄漏上一条目的元数据,同样靠清空消掉。 - // - // resId == 0(remark)不走这条:它的文本可能由 TMDB 集数信息经另一条路径单独写入, - // 无条件清空会把那条路径的结果抹掉,因此保留原来的"空值不覆盖已有文本"语义。 - if (resId > 0) { - if (!TextUtils.isEmpty(view.getText())) view.setText(null); - view.setVisibility(View.GONE); - } - return; - } + if (TextUtils.isEmpty(text) && !TextUtils.isEmpty(view.getText())) return; String value = resId > 0 ? getString(resId, text) : text; - view.setVisibility(View.VISIBLE); + view.setVisibility(text.isEmpty() ? View.GONE : View.VISIBLE); if (TextUtils.equals(view.getText(), value)) return; view.setText(Sniffer.buildClickable(value, this::clickableSpan), TextView.BufferType.SPANNABLE); view.setLinkTextColor(MDColor.YELLOW_500); @@ -3307,27 +2559,20 @@ private boolean runtimeSourceOnly; * 含 parse=1 二次解析)。解析结果经 NovelRouter.routeReaderEngine 回传给前台阅读页。 */ @Override - public boolean labPlayEpisode(String chapterUrl) { - if (chapterUrl == null || chapterUrl.isEmpty()) return false; - // 宿主已在销毁中:解析结果不会再回到阅读器,直接报「没发出」让它立刻收尾。 - // 另外 SiteViewModel 被清理后 playerContent 会抛 RejectedExecutionException, - // 那个异常会顺着阅读器的 runOnUiThread 冒出去导致崩溃。 - if (isFinishing() || isDestroyed()) return false; + public void labPlayEpisode(String chapterUrl) { + if (chapterUrl == null || chapterUrl.isEmpty()) return; Flag flag = getFlag(); - if (flag == null || flag.getEpisodes() == null) return false; + if (flag == null || flag.getEpisodes() == null) return; for (Episode ep : flag.getEpisodes()) { if (chapterUrl.equals(ep.getUrl())) { getPlayer(flag, ep); - return true; + return; } } - // 章节不在当前线路:阅读器的章节表跨线路合并,这种情况静默返回, - // 告知调用方没有发出请求,让它立刻收尾在途标记。 - return false; } private void getPlayer(Flag flag, Episode episode) { - publishPluginEpisodeContext(episode); + resetAdFeedback(); mBinding.widget.title.setText(getPlaybackControlTitle(episode)); playerStartTime = System.currentTimeMillis(); beginPlayHealth(); @@ -3341,70 +2586,16 @@ private boolean runtimeSourceOnly; clearLyrics(); clearKaraokeState(); if (shouldUseImmersiveAudio()) setAudioStageVisible(true); - beginPlayerContentRequest(getKey(), playFlag, episode.getUrl()); - mPlaybackFlagKey = playbackFlagKey(flag); - mViewModel.playerContent(getKey(), playFlag, episode.getUrl(), applyHistoryPlayerKernel()); + mViewModel.playerContent(getKey(), playFlag, episode.getUrl()); mBinding.widget.title.setSelected(true); updateHistory(episode); showProgress(); } - /** - * 把当前集的剧集身份交给插件通道。 - * - *

挂在起播的唯一收口而不是各个点击处:初次进入、点集、自动连播、切线路与阅读器 - * 换章最后都汇到这一个方法,只有在这里注入才不会有某条路径漏发。上下文是可选增强, - * 任何异常都不许影响播放,所以整段吞掉。 - */ - private void publishPluginEpisodeContext(Episode episode) { - try { - com.fongmi.android.tv.bean.TmdbItem item = getMatchedTmdbItem(); - if (item == null) item = getTmdbItem(); - com.google.gson.JsonObject context = com.fongmi.android.tv.plugin.PluginEpisodePayload.of(item, mVod, episode); - com.fongmi.android.tv.plugin.PluginEpisodeContext.publish(getClass().getSimpleName(), context); - // episode.changed 与上下文槽共用同一个 of() 结果:插件若从事件读到 A、点击时从槽里读到 B, - // 表现就是"评论串台",而这类错在日志里没有任何痕迹。这里传的是 JsonObject, - // 序列化发生在 helper 内部的 hasSubscribers 短路之后,因此无订阅者时这一行确实零行为。 - com.fongmi.android.tv.plugin.PluginHostEvents.episodeChanged(this, context); - } catch (Throwable ignored) { - // 插件上下文是可选增强:拿不到就当没有,绝不影响播放。 - } - } - - /** - * 回到本页时重新认领剧集上下文。 - * - *

{@code PluginEpisodeContext} 是全局单槽:播放页发布后本页的快照就被覆盖了,而 - * 返回本页不会触发任何起播路径。不在这里补发,用户走过一次「本页 → 别的页 → 返回」 - * 之后,插件 chip 读到的永远是 {@code null}(名字不匹配即 null)——这是永久缺口, - * 不是一次竞态。 - * - *

拿不到当前集时直接返回,不发布:{@code publish(name, null)} 的语义是「清除」, - * 一次没有数据的回归发布会把原本正确的快照擦掉,比不发布更糟。 - */ - private void republishPluginEpisodeContext() { - try { - Episode episode = getEpisode(); - if (episode == null) return; - publishPluginEpisodeContext(episode); - } catch (Throwable ignored) { - // 插件上下文是可选增强:任何异常都不许影响播放页回归。 - } - } - private void setPlayer(Result result) { - // null 结果表示这次取流请求已被作废(SiteViewModel.cancelPlayerContent 会向 PLAYER - // LiveData 写入 null),不会再有结果回调来收圈。守卫若留在这里,onStateChanged 与 - // hidePlaybackProgressIfStale 两条清除路径会永久被挡下,圈再无任何清除路径。 - if (result == null) { - mPlaybackRequestActive = false; - mPlaybackPlayerStarted = false; - return; - } if (isFinishing() || isDestroyed()) return; SpiderDebug.log("video-flow", "player finish cost=%dms useParse=%s multi=%s msg=%s", System.currentTimeMillis() - playerStartTime, result.shouldUseParse(), result.getUrl().isMulti(), result.getMsg()); - // C111: same gate as setDetail(Result) — setPlayer() writes mQualityAdapter before any other guard. - if (service() == null || mQualityAdapter == null) { + if (service() == null) { mPendingPlayer = result; SpiderDebug.log("video-flow", "player pending service key=%s id=%s", getKey(), getId()); return; @@ -3414,23 +2605,10 @@ private boolean runtimeSourceOnly; return; } if (!canApplyPlayerResult()) { - // 详情尚未绑定就丢弃这个结果,本次不会再走 startPlayer,也就不会有新的 READY 回调来收圈。 - // 守卫若留在这里,onStateChanged 与 hidePlaybackProgressIfStale 都会被它挡下,圈从此没有 - // 任何清除路径——与下面「同一结果已在播」分支同源,一并释放。 - mPlaybackRequestActive = false; - mPlaybackPlayerStarted = false; SpiderDebug.log("video-flow", "drop player result before detail ready key=%s id=%s", getKey(), getId()); return; } - if (result == mAppliedPlayerResult && !player().isEmpty()) { - // 同一个结果已经在播,本次不会再走 startPlayer,也就不会有新的 READY 回调来收圈。 - // 守卫若留在这里,onStateChanged 与 hidePlaybackProgressIfStale 都会被它挡下, - // 圈再没有任何清除路径——正是那个兜底方法要防的「画面在动、圈不走」。 - // 此时播放器已在播同一结果,加载态本身是陈旧的,直接释放。 - mPlaybackRequestActive = false; - mPlaybackPlayerStarted = false; - return; - } + if (result == mAppliedPlayerResult && !player().isEmpty()) return; mAppliedPlayerResult = result; mQualityAdapter.addAll(result); mQualityAdapter.setPosition(mQualityAdapter.getPosition()); @@ -3445,17 +2623,11 @@ private boolean runtimeSourceOnly; applyAudioQueueMetadata(getPlaybackEpisode()); if (result.hasPosition()) mHistory.setPosition(result.getPosition()); mBinding.control.parse.setVisibility(isUseParse() && PlayerButtonSetting.isVisible(PlayerButtonSetting.PARSE) ? View.VISIBLE : View.GONE); - if (redirectToContentHandler(result)) { - mPlaybackRequestActive = false; - mPlaybackPlayerStarted = false; - return; - } + if (redirectToContentHandler(result)) return; List siteDanmakus = result.getDanmaku(); mInitialPlaybackPosition = resolveInitialPlaybackPosition(); SpiderDebug.log("video-flow", "startPlayer dispatch initialPosition=%d music=%s ijk=%s", mInitialPlaybackPosition, isMusicLike(), service() != null && player().isIjk()); long start = System.currentTimeMillis(); - if (SubtitleRestoreCoordinator.restore(mHistory, player(), result)) syncHistory(); - mPlaybackPlayerStarted = true; startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata(), mInitialPlaybackPosition); SpiderDebug.log("video-flow", "startPlayer return cost=%dms sincePlayerStart=%dms", System.currentTimeMillis() - start, System.currentTimeMillis() - playerStartTime); subtitlePlaybackSession.onPlaybackStarted(this, result); @@ -3465,8 +2637,6 @@ private boolean runtimeSourceOnly; .rawTitle(mHistory.getVodName()) .rawRemarks(mHistory.getVodRemarks()) .episodeName(getEpisode().getName()) - .tmdbId(danmakuTmdbId()) - .tmdbSeasonNumber(danmakuTmdbSeasonNumber()) .source(MediaTitleLearningExample.SOURCE_DANMAKU_AUTO) .allowAi(true) .build(), danmaku -> { @@ -3485,383 +2655,11 @@ private boolean runtimeSourceOnly; return handled; } - private int danmakuTmdbId() { - TmdbItem item = getMatchedTmdbItem(); - return item == null ? 0 : item.getTmdbId(); - } - - private int danmakuTmdbSeasonNumber() { - TmdbItem item = getMatchedTmdbItem(); - if (item == null || !item.isTv()) return 0; - Episode episode = getEpisode(); - TmdbEpisode tmdbEpisode = episode == null ? null : episode.getTmdbEpisode(); - return tmdbEpisode == null ? 0 : tmdbEpisode.getSeasonNumber(); - } - private boolean canApplyPlayerResult() { if (mFlagAdapter != null && mFlagAdapter.getItemCount() > 0 && mEpisodeAdapter != null && mEpisodeAdapter.getItemCount() > 0) return true; return mFastPlaybackFlag != null && mFastPlaybackEpisode != null && mHistory != null; } - private void beginPlayerContentRequest(String key, String flag, String episode) { - if (mViewModel != null) mViewModel.cancelPlayerContent(); - mPlaybackRequestActive = true; - mPlaybackPlayerStarted = false; - mPendingPlayer = null; - invalidatePlayerContent(); - playerContentKey = key; - playerContentFlag = flag; - playerContentEpisode = episode; - } - - private boolean isShortDramaSource() { - Site site = getSite(); - return Setting.isShortDramaSiteEnabled(site == null ? getKey() : site.getKey(), - site == null ? "" : site.getName()); - } - - private boolean isShortDramaQueueEligible(Result result) { - if (!PlaybackExperimentSetting.isAllowed(PlaybackExperimentPolicy.Action.EXO_SHORT_DRAMA_QUEUE)) return false; - if (!isShortDramaSource() || service() == null || player() == null || !player().isExo() - || !player().supportsPlaylistQueue() || !PreloadSetting.isPreload(PlayerSetting.EXO)) return false; - if (MultiThreadProxySetting.get().enabled() || player().isRepeatOne() || mHistory == null - || mHistory.getOpening() > 0 || mHistory.getEnding() > 0 - || pendingResumeSeekMs != C.TIME_UNSET || tmdbHistoryResumePending - || isAudioOnly() || shouldUseImmersiveAudio()) return false; - Flag flag = getFlag(); - if (flag == null || flag.getEpisodes().size() < 2 || result == null || result.hasMsg() - || result.getRealUrl().isEmpty() || result.getDrm() != null - || result.needParse() || result.shouldUseParse()) return false; - String url = result.getRealUrl(); - if (com.fongmi.android.tv.player.exo.MediaSourceFactory.isConcatenatingUrl(url)) return false; - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest( - url, null, result.getFormat()); - if (classification.streamKind() == PlaybackAutoContext.StreamKind.LIVE - || classification.streamKind() == PlaybackAutoContext.StreamKind.LOW_LATENCY_LIVE) return false; - return classification.protocol() == PlaybackAutoContext.Protocol.HLS - || classification.protocol() == PlaybackAutoContext.Protocol.PROGRESSIVE_HTTP; - } - - private ShortDramaQueueCoordinator.Episode shortDramaEpisode(Episode episode, int index) { - return new ShortDramaQueueCoordinator.Episode("", episode == null ? "" : episode.getName(), index); - } - - private ShortDramaQueueCoordinator.MediaKind shortDramaMediaKind(String url, String format) { - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest(url, null, format); - return classification.protocol() == PlaybackAutoContext.Protocol.HLS - ? ShortDramaQueueCoordinator.MediaKind.HLS - : ShortDramaQueueCoordinator.MediaKind.PROGRESSIVE_MP4; - } - - private boolean hasShortDramaStoredPosition(Episode episode) { - if (episode == null || skipEpisodePositionCache()) return false; - EpisodePositionCache.EpisodePosition cached = EpisodePositionCache.get().get( - getKey(), getId(), getFlag().getFlag(), episodePositionCacheName(episode, currentSourceSeasonNumber())); - return cached != null && cached.position > 0; - } - - private MediaMetadata buildShortDramaMetadata(Episode episode) { - String title = mHistory == null ? getName() : mHistory.getVodName(); - String name = episode == null ? "" : episode.getName(); - return PlayerManager.buildMetadata(title, name.isEmpty() || title.equals(name) ? "" : name, - mHistory == null ? "" : mHistory.getVodPic()); - } - - private void beginShortDramaQueue() { - Flag flag = getFlag(); - List items = flag == null ? List.of() : flag.getEpisodes(); - Episode current = getEpisode(); - if (flag == null || current == null || items.size() < 2 || !isShortDramaQueueEligible(mAppliedPlayerResult)) return; - int currentIndex = getSelectedEpisodePosition(items); - List descriptors = new ArrayList<>(items.size()); - for (int i = 0; i < items.size(); i++) descriptors.add(shortDramaEpisode(items.get(i), i)); - ShortDramaQueueCoordinator.Session session = new ShortDramaQueueCoordinator.Session( - getId(), getKey(), flag.getFlag(), getEpisodePlayFlag(flag, current), descriptors, - currentIndex, mHistory.isRevPlay()); - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.begin(session); - mShortDramaQueueFallbackUsed = false; - mShortDramaQueueSessionActive = true; - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - resolveShortDramaNext(snapshot); - } - - private void resolveShortDramaNext(ShortDramaQueueCoordinator.Snapshot snapshot) { - if (snapshot == null || snapshot.session() == null || snapshot.queued() != null || mViewModel == null) return; - ShortDramaQueueCoordinator.ResolveRequest request = snapshot.nextRequest(); - if (request == null) return; - int index = request.episode().index(); - List items = getFlag() == null ? List.of() : getFlag().getEpisodes(); - if (index < 0 || index >= items.size()) return; - Episode target = items.get(index); - if (hasShortDramaStoredPosition(target)) return; - String key = snapshot.session().siteKey(); - String playFlag = snapshot.session().playFlag(); - long generation = request.generation(); - mViewModel.playerContentIsolated(key, playFlag, target.getUrl(), player().getPlayerType(), - result -> enqueueShortDramaResult(generation, request.episode(), target, result), - error -> { - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "resolve failed generation=%d type=%s", generation, error == null ? "unknown" : error.getClass().getSimpleName()); - }); - } - - private void enqueueShortDramaResult(long generation, - ShortDramaQueueCoordinator.Episode descriptor, - Episode target, - Result result) { - if (isFinishing() || isDestroyed() || target == null || !isShortDramaQueueEligible(mAppliedPlayerResult)) return; - if (result == null || result.hasMsg() || result.getRealUrl().isEmpty() || result.getDrm() != null - || result.needParse() || result.shouldUseParse()) return; - String url = result.getRealUrl(); - boolean concatenated = com.fongmi.android.tv.player.exo.MediaSourceFactory.isConcatenatingUrl(url); - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest( - url, null, result.getFormat()); - boolean live = classification.streamKind() == PlaybackAutoContext.StreamKind.LIVE - || classification.streamKind() == PlaybackAutoContext.StreamKind.LOW_LATENCY_LIVE; - boolean hls = classification.protocol() == PlaybackAutoContext.Protocol.HLS; - boolean progressive = classification.protocol() == PlaybackAutoContext.Protocol.PROGRESSIVE_HTTP; - ShortDramaQueueCoordinator.ResolvedItem resolved = new ShortDramaQueueCoordinator.ResolvedItem( - url, new HashMap<>(result.getHeader()), result.getFormat(), - hls ? ShortDramaQueueCoordinator.MediaKind.HLS : progressive ? ShortDramaQueueCoordinator.MediaKind.PROGRESSIVE_MP4 : null, - false, result.needParse() || result.shouldUseParse(), live, false, concatenated, false); - ShortDramaQueueCoordinator.QueueItem item = mShortDramaQueue.acceptResolved(generation, descriptor, resolved); - if (item == null || !resolved.isEligible()) return; - PlaySpec spec = PlaySpec.from(result, activePlaybackKey(), buildShortDramaMetadata(target)).checkUa(); - spec.setHeaders(new HashMap<>(result.getHeader())); - if (!player().appendPlaylistItem(spec, item.mediaId())) { - invalidateShortDramaQueue("append-failed"); - return; - } - mShortDramaQueuedSpec = spec; - mShortDramaQueuedResult = result; - mShortDramaQueuedEpisode = target; - player().setPlaylistPreloadDurationMs(5_000L); - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "append generation=%d index=%d kind=%s", generation, target.getIndex(), resolved.kind()); - } - - private void saveShortDramaEpisodeProgress(Episode episode) { - saveShortDramaEpisodeProgress(episode, - mHistory == null ? C.TIME_UNSET : mHistory.getPosition(), - mHistory == null ? C.TIME_UNSET : mHistory.getDuration()); - } - - private void saveShortDramaEpisodeProgress(Episode episode, long position, long duration) { - if (episode == null || mHistory == null || service() == null || !isOwner() || tmdbHistoryResumePending) return; - String cacheName = episodePositionCacheName(episode, currentSourceSeasonNumber()); - if (!TextUtils.isEmpty(cacheName) && !skipEpisodePositionCache()) { - long savedPosition = position > 0 ? position : mHistory.getPosition(); - long savedDuration = duration > 0 ? duration : mHistory.getDuration(); - EpisodePositionCache.get().put(getKey(), getId(), getFlag().getFlag(), cacheName, - savedPosition, savedDuration); - } - } - - private void updateHistoryAfterShortDramaTransition(Episode item) { - if (mHistory == null || item == null || getFlag() == null) return; - Episode historyEpisode = withSourceSeasonEpisodeIdentity(item); - EpisodePositionCache.EpisodePosition cached = skipEpisodePositionCache() ? null : EpisodePositionCache.get().get( - getKey(), getId(), getFlag().getFlag(), episodePositionCacheName(item, currentSourceSeasonNumber())); - if (cached != null) { - mHistory.setPosition(cached.position); - mHistory.setDuration(cached.duration); - } else { - mHistory.setPosition(C.TIME_UNSET); - mHistory.setDuration(C.TIME_UNSET); - } - setHistoryFlag(getFlag()); - mHistory.setVodRemarks(getHistoryEpisodeName(item)); - mHistory.setEpisodeUrl(item.getUrl()); - if (historyEpisode.getTmdbEpisode() != null) mHistory.setTmdbEpisodePosition(historyEpisode); - PlaybackEventCollector.get().updateHistory(mHistory); - } - - private void commitShortDramaTransition(ShortDramaQueueCoordinator.Transition transition, - Episode current, - Result result, - PlaySpec spec) { - Episode previous = transition.previous() == null ? null : getEpisodeByIndex(transition.previous().index()); - if (previous != null) saveShortDramaEpisodeProgress(previous, - mShortDramaTransitionPositionMs, mShortDramaTransitionDurationMs); - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - Flag flag = getFlag(); - if (flag == null || current == null || result == null || spec == null) { - fallbackShortDramaQueued(); - return; - } - mFlagAdapter.toggle(current); - setEpisodeAdapter(flag.getEpisodes()); - applyAudioQueueMetadata(current); - mBinding.widget.title.setText(getPlaybackControlTitle(current)); - mBinding.widget.title.setSelected(true); - mInlineLyrics = getEpisodeInlineLyrics(current); - applyPlaybackArtwork(current); - clearLyrics(); - clearKaraokeState(); - if (result.hasDesc()) { - setText(mBinding.content, 0, result.getDesc()); - setPlaybackLyrics(result.getDesc()); - } - mAppliedPlayerResult = result; - mQualityAdapter.addAll(result); - mQualityAdapter.setPosition(mQualityAdapter.getPosition()); - setUseParse(false); - setQualityVisible(result.getUrl().isMulti()); - updateHistoryAfterShortDramaTransition(current); - player().setMetadata(buildShortDramaMetadata(current)); - List siteDanmakus = result.getDanmaku(); - player().setDanmaku(siteDanmakus.isEmpty() ? Danmaku.empty() : siteDanmakus.get(0)); - subtitlePlaybackSession.onPlaybackStarted(this, result); - DanmakuApi.cancel(); - if (DanmakuApi.canAutoSearch(siteDanmakus)) searchShortDramaDanmaku(current, siteDanmakus); - loadTmdbRelatedVideosForCurrentEpisode(); - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaEndedFallbackScheduled = false; - App.removeCallbacks(mShortDramaEndedFallback); - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.snapshot(); - resolveShortDramaNext(snapshot); - } - - private void searchShortDramaDanmaku(Episode episode, List siteDanmakus) { - String episodeUrl = episode == null ? "" : episode.getUrl(); - DanmakuApi.search(MediaTitleRequest.builder() - .siteKey(getKey()).vodId(getId()).rawTitle(mHistory.getVodName()) - .rawRemarks(mHistory.getVodRemarks()).episodeName(episode == null ? "" : episode.getName()) - .tmdbId(danmakuTmdbId()).tmdbSeasonNumber(danmakuTmdbSeasonNumber()) - .source(MediaTitleLearningExample.SOURCE_DANMAKU_AUTO).allowAi(true).build(), danmaku -> { - Episode active = getEpisode(); - if (player() == null || active == null || !TextUtils.equals(active.getUrl(), episodeUrl)) return; - if (DanmakuSetting.isSpiderFirst() && !siteDanmakus.isEmpty()) player().addDanmaku(danmaku); - else player().setDanmaku(danmaku); - }); - } - - private Episode getEpisodeByIndex(int index) { - Flag flag = getFlag(); - if (flag == null || index < 0 || index >= flag.getEpisodes().size()) return null; - return flag.getEpisodes().get(index); - } - - private void onShortDramaMediaItemTransition(androidx.media3.common.MediaItem mediaItem, int reason) { - if (mediaItem == null || reason != Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) return; - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.snapshot(); - ShortDramaQueueCoordinator.Transition transition = mShortDramaQueue.onMediaItemTransition( - snapshot.generation(), mediaItem.mediaId, ShortDramaQueueCoordinator.TransitionReason.AUTO); - if (transition == null) return; - PlaySpec spec = mShortDramaQueuedSpec; - Result result = mShortDramaQueuedResult; - Episode current = mShortDramaQueuedEpisode; - // Commit changes the manager's current PlaySpec, so close the previous - // episode's playback event before switching to the queued item. - if (service() != null) PlaybackEventCollector.get().onStop(player()); - if (spec == null || result == null || current == null || !player().commitPlaylistTransition(spec)) { - fallbackShortDramaQueued(); - return; - } - commitShortDramaTransition(transition, current, result, spec); - } - - private void invalidateShortDramaQueue(String reason) { - App.removeCallbacks(mShortDramaEndedFallback); - mShortDramaEndedFallbackScheduled = false; - if (mViewModel != null) mViewModel.cancelPlayerContentIsolated(); - if (player() != null && player().isExo()) player().removePlaylistItemsAfterCurrent(); - mShortDramaQueue.invalidate(); - mShortDramaQueueSessionActive = false; - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "invalidate reason=%s", reason); - } - - private void fallbackShortDramaQueued() { - if (mShortDramaQueueFallbackUsed || mShortDramaQueuedEpisode == null) return; - mShortDramaQueueFallbackUsed = true; - Episode target = mShortDramaQueuedEpisode; - saveShortDramaEpisodeProgress(getEpisode()); - invalidateShortDramaQueue("queue-fallback"); - if (!isFinishing() && !isDestroyed() && target != null) onItemClick(target); - } - - private void scheduleShortDramaEndedFallback() { - if (mShortDramaEndedFallbackScheduled) return; - mShortDramaEndedFallbackScheduled = true; - App.post(mShortDramaEndedFallback, 1_500L); - } - - - private void invalidatePlayerContent() { - playerContentGeneration++; - playerContentRequestId++; - playerKernelSwitchRequestId++; - playerContentKey = ""; - playerContentFlag = ""; - playerContentEpisode = ""; - mPlaybackFlagKey = ""; - playerKernelSwitchRefreshing = false; - } - - private boolean isCurrentPlayerContentContext(String key, String flag, String episode) { - Flag currentFlag = getFlag(); - Episode currentEpisode = getEpisode(); - return TextUtils.equals(key, getKey()) - && currentFlag != null - && TextUtils.equals(flag, currentFlag.getFlag()) - && currentEpisode != null - && TextUtils.equals(episode, currentEpisode.getUrl()); - } - - private String playbackFlagKey(Flag flag) { - if (flag == null) return ""; - int index = mVod == null ? -1 : com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagIndex(mVod.getFlags(), flag); - if (index < 0 && mFlagAdapter != null) index = mFlagAdapter.indexOf(flag); - if (index < 0) return ""; - return com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, index); - } - - private boolean isPlaybackFlag(Flag flag) { - String key = playbackFlagKey(flag); - return !TextUtils.isEmpty(key) && TextUtils.equals(mPlaybackFlagKey, key); - } - - private boolean isPlayingContent(Episode episode) { - return episode != null && episode.isSelected() && isPlaybackFlag(getFlag()); - } - - private boolean isCurrentPlayerContentRequest(int requestId, int generation, - String key, String flag, String episode) { - return !isFinishing() && !isDestroyed() - && requestId == playerContentRequestId - && generation == playerContentGeneration - && isCurrentPlayerContentContext(key, flag, episode); - } - - private int beginPlayerContentSwitch(int requestId, String key, String flag, String episode) { - playerContentGeneration++; - playerContentRequestId = requestId; - playerContentKey = key; - playerContentFlag = flag; - playerContentEpisode = episode; - return playerContentGeneration; - } - - private boolean canApplyPlayerContentRequest(int requestId, int generation, - String key, String flag, String episode) { - return isCurrentPlayerContentRequest(requestId, generation, key, flag, episode) - && service() != null - && player() != null - && !player().isReleased() - && !player().isEmpty() - && isOwner(); - } - private void recordDetailHealth(Result result, long cost) { if (detailHealthRecorded) return; detailHealthRecorded = true; @@ -3872,7 +2670,6 @@ private boolean runtimeSourceOnly; private void beginPlayHealth() { playHealthKey = getKey(); - SiteHealthStore.recordPlayAttempt(playHealthKey); playHealthRecorded = false; } @@ -3884,44 +2681,31 @@ private boolean runtimeSourceOnly; @Override public void onItemClick(Flag item) { - if (isFinishing() || isDestroyed()) return; - invalidateShortDramaQueue("line-switch"); if (mFlagAdapter.getItemCount() == 0 || item == null) return; int position = mFlagAdapter.indexOf(item); Flag resolved = mFlagAdapter.get(position < 0 ? 0 : position); boolean initialBinding = mEpisodeAdapter.getItemCount() == 0; - if (resolved.isSelected() && isPlaybackFlag(resolved) && !initialBinding) return; + if (resolved.isSelected() && !initialBinding) return; Flag previous = getFlag(); SpiderDebug.log("playback-action", "flag switch ui=leanback site=%s from=%s to=%s fullscreen=%s", getKey(), previous == null ? "" : previous.getFlag(), resolved.getFlag(), isFullscreen()); int oldPosition = mFlagAdapter.getSelectedPosition(); mFlagAdapter.setSelected(resolved); if (mTmdbUIAdapter != null) mTmdbUIAdapter.setActiveFlag(resolved); - rememberFlagSelection(resolved); int newPosition = mFlagAdapter.getSelectedPosition(); if (newPosition != RecyclerView.NO_POSITION) mBinding.flag.setSelectedPosition(newPosition); notifyItemsChanged(mBinding.flag, mFlagAdapter, oldPosition, newPosition); setEpisodeAdapter(resolved.getEpisodes()); setQualityVisible(false); - boolean deferFlagSwitch = FlagSwitchPlaybackPolicy.shouldDeferPlayback(PlayerSetting.isDeferFlagSwitchPlayback(), mProgrammaticFlagSwitch); - mDeferFlagSwitchPlayback = deferFlagSwitch && !initialBinding; boolean episodeChanged = seamless(resolved); if (initialBinding && !episodeChanged) onRefresh(); - else if (deferFlagSwitch && !episodeChanged) onRefresh(); loadTmdbRelatedVideosForCurrentEpisode(); - mDeferFlagSwitchPlayback = false; } private void setEpisodeAdapter(List items) { - setEpisodeAdapter(items, true, true); + setEpisodeAdapter(items, true); } private void setEpisodeAdapter(List items, boolean scrollToCurrent) { - mBinding.control.action.episodes.setVisibility(items.size() < 2 ? View.GONE : View.VISIBLE); - applyActionButtonVisibility(); - setEpisodeAdapter(items, scrollToCurrent, true); - } - - private void setEpisodeAdapter(List items, boolean scrollToCurrent, boolean updateEpisodeChrome) { updateEpisodeSeasonContext(); boolean isEmpty = items.isEmpty(); boolean hasMultiple = items.size() > 1; @@ -3953,12 +2737,10 @@ private boolean runtimeSourceOnly; // useTmdbCards=false 被隐藏,用户无法切回列表。 if (useTmdbCards && hasMultiple) episodeGridMode = Setting.getTmdbEpisodeGridMode(); if (!useTmdbCards || !hasMultiple) episodeGridMode = false; - if (updateEpisodeChrome) { - mBinding.episodeHeader.setVisibility(showTmdbEpisodeChrome && !isEmpty ? View.VISIBLE : View.GONE); - mBinding.episodeReverse.setVisibility(showTmdbEpisodeChrome && hasMultiple ? View.VISIBLE : View.GONE); - mBinding.episodeViewMode.setVisibility(showTmdbEpisodeChrome && hasMultiple && useTmdbCards ? View.VISIBLE : View.GONE); - mBinding.episodeFileName.setVisibility(showTmdbEpisodeChrome && hasMultiple ? View.VISIBLE : View.GONE); - } + mBinding.episodeHeader.setVisibility(showTmdbEpisodeChrome && !isEmpty ? View.VISIBLE : View.GONE); + mBinding.episodeReverse.setVisibility(showTmdbEpisodeChrome && hasMultiple ? View.VISIBLE : View.GONE); + mBinding.episodeViewMode.setVisibility(showTmdbEpisodeChrome && hasMultiple && useTmdbCards ? View.VISIBLE : View.GONE); + mBinding.episodeFileName.setVisibility(showTmdbEpisodeChrome && hasMultiple ? View.VISIBLE : View.GONE); updateEpisodeFallbackStillUrl(); mEpisodeAdapter.setUseTmdbCard(useTmdbCards); mEpisodeGridAdapter.setUseTmdbCard(useTmdbCards); @@ -3989,7 +2771,6 @@ private boolean runtimeSourceOnly; updateFocus(); if (scrollToCurrent) scrollToCurrentEpisode(); setR2Callback(); - flushPendingManualEpisodeFocus(); } private void setUpstreamNativeEpisodeItems(List items, boolean scrollToCurrent) { @@ -4029,7 +2810,6 @@ private boolean runtimeSourceOnly; updateFocus(); if (scrollToCurrent) scrollToCurrentEpisode(); setR2Callback(); - flushPendingManualEpisodeFocus(); } // 兜底:占位符只在站源一集都没返回时出现(有集数就直接上屏了)。这里负责在 TMDB @@ -4064,51 +2844,17 @@ private boolean runtimeSourceOnly; } private boolean seamless(Flag flag) { - // 选集定位以历史身份(URL 优先、集号容错)为主,刮削标题(mark)只作兜底: - // 只有走 find(Episode) 才命中 Flag.find 的 URL 优先分支,否则同集多版本会被集名打分选成第一版本。 - Episode episode = mHistory == null ? null : flag.find(mHistory.getEpisode(), true); - if (episode == null && !getMark().isEmpty()) episode = flag.find(getMark(), false); + Episode episode = getMark().isEmpty() ? flag.find(mHistory.getEpisode(), true) : flag.find(mHistory.getVodRemarks(), false); setQualityVisible(episode != null && episode.isSelected() && mQualityAdapter.getItemCount() > 1); - if (episode == null || (isPlaybackFlag(flag) && episode.isSelected())) return false; + if (episode == null || episode.isSelected()) return false; selectEpisode(episode, false); return true; } @Override public void onItemClick(Episode item) { - if (shouldEnterFullscreen(item)) { - // 手动播放模式:点「当前已选中集」时 shouldEnterFullscreen 只负责进全屏, - // 而自动播放已关闭意味着取流不会自动起播 —— 这里补一次显式起播, - // 否则用户会停在全屏的暂停画面上,仍要多按一次 OK。 - if (isManualPlaybackMode() && !player().isPlaying()) { - mManualPlaybackStarted = true; - updateVideoCover(); - onPlay(); - } - return; - } - invalidateShortDramaQueue("manual-episode"); - // 手动播放模式:本次点集即起播,并紧接全屏,省掉「先全屏、再按一次 OK」。 - // selectEpisode 内部已经触发 onRefresh() 取流,这里只负责让那次取流必定起播。 - if (isManualPlaybackMode()) { - // 起播是异步的(取流结果经 LiveData 回来才调 startPlayer),所以这里只置位, - // 由 shouldAutoPlay() 在真正起播那一刻一次性消费,不能在此处立即复位。 - mExplicitPlaybackRequest = true; - mManualPlaybackStarted = true; - updateVideoCover(); - } + if (shouldEnterFullscreen(item)) return; selectEpisode(item, true); - if (isManualPlaybackMode() && !isFullscreen()) enterFullscreen(); - } - - @Override - protected boolean shouldAutoPlay() { - // 用户主动点集时必定起播;标志在这里一次性消费(起播是异步的,不能在点集处复位)。 - if (mExplicitPlaybackRequest) { - mExplicitPlaybackRequest = false; - return true; - } - return super.shouldAutoPlay(); } private void onEpisodeLongClick(Episode item) { @@ -4161,7 +2907,7 @@ private boolean runtimeSourceOnly; private void onQuality() { Result result = mViewModel.getPlayer().getValue(); if (result == null || !result.getUrl().isMulti()) return; - new WebHtvAlertDialogBuilder(this) + new com.google.android.material.dialog.MaterialAlertDialogBuilder(this) .setTitle(R.string.detail_quality) .setSingleChoiceItems(getQualityItems(result), result.getUrl().getPosition(), (dialog, which) -> { dialog.dismiss(); @@ -4176,6 +2922,8 @@ private boolean runtimeSourceOnly; updateActionQuality(result); return; } + // 换画质同集换 URL:切片结构与在途归因都属于上一画质 + resetAdFeedback(); mQualityAdapter.setPosition(position); updateActionQuality(result); onItemClick(result); @@ -4183,20 +2931,17 @@ private boolean runtimeSourceOnly; @Override public void onItemClick(Result result) { - invalidateShortDramaQueue("quality"); + // 画质列表直点也会换 URL,且不经 changeQuality + resetAdFeedback(); updateActionQuality(result); beginPlayHealth(); - // 切清晰度也会重建 spec,字幕列表跟着重置,所以这里同样要恢复一次。 - if (SubtitleRestoreCoordinator.restore(mHistory, player(), result)) syncHistory(); startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata()); subtitlePlaybackSession.onPlaybackStarted(this, result); } private void reverseEpisode(boolean scroll) { - invalidateShortDramaQueue("reverse-sort"); mFlagAdapter.reverse(); - // 倒序只改变列表顺序;保留已经显示的工具栏,避免一次重绑期间 TMDB 状态短暂未就绪时闪退。 - setEpisodeAdapter(getFlag().getEpisodes(), scroll, false); + setEpisodeAdapter(getFlag().getEpisodes(), scroll); if (scroll) scrollToCurrentEpisode(); else scrollToFirstEpisode(); } @@ -4212,8 +2957,7 @@ private boolean runtimeSourceOnly; if (mBinding.episodeFileName.getVisibility() != View.VISIBLE) return; boolean showScraped = !Setting.getTmdbEpisodeShowScrapedName(); Setting.putTmdbEpisodeShowScrapedName(showScraped); - // 原文件名只改变列表标题;工具栏可见性不应随这次重绑重新计算。 - setEpisodeAdapter(getFlag().getEpisodes(), true, false); + setEpisodeAdapter(getFlag().getEpisodes(), true); } private void applyEpisodeViewMode(boolean scrollToCurrent) { @@ -4334,7 +3078,13 @@ private boolean runtimeSourceOnly; } else { mBinding.episodeGrid.scrollToPosition(position); } - if (requestFocus) requestEpisodeGridFocus(position); + if (!requestFocus) return; + mBinding.episodeGrid.post(() -> { + if (isFinishing() || isDestroyed()) return; + RecyclerView.ViewHolder holder = mBinding.episodeGrid.findViewHolderForAdapterPosition(position); + if (holder != null) holder.itemView.requestFocus(); + else mBinding.episodeGrid.requestFocus(); + }); }); } else { mBinding.episode.post(() -> { @@ -4403,7 +3153,6 @@ private boolean runtimeSourceOnly; @Override public void onItemClick(Parse item) { - invalidateShortDramaQueue("parse"); setParse(item); onRefresh(); } @@ -4451,23 +3200,7 @@ private boolean runtimeSourceOnly; } private boolean isEpisodeFocusTarget(View view) { - return view != null && isEffectivelyVisible(view) && (view.isFocusable() || view.hasFocusable()); - } - - /** - * 自身与全部祖先都必须是 VISIBLE。 - * 只看自身会误判:upstream-native 选集模式会把整条 episodeHeader(选集·第 N 季 / 倒序 / 列表 / 原文件名) - * 置为 GONE,而内部子项自身仍是 VISIBLE,于是 findFocusUp/findFocusDown 把它当成有效邻居, - * 声明式 nextFocusDown 与 requestFocus 全部指向不可达目标 —— 表现为切线路后分段行“向下/向左选不到、焦点丢失”。 - */ - private boolean isEffectivelyVisible(View view) { - View current = view; - while (current != null) { - if (current.getVisibility() != View.VISIBLE) return false; - ViewParent parent = current.getParent(); - current = parent instanceof View ? (View) parent : null; - } - return true; + return view != null && isVisible(view) && (view.isFocusable() || view.hasFocusable()); } private List getEpisodeFocusOrders() { @@ -4481,7 +3214,6 @@ private boolean runtimeSourceOnly; R.id.episodeFileName, R.id.episode, R.id.episodeGrid, - R.id.tmdbOmdbRatings, R.id.tmdbCast, R.id.tmdbPhotos, R.id.tmdbPosters, @@ -4512,28 +3244,9 @@ private boolean runtimeSourceOnly; updateEpisodeHeaderFocus(); mPartAdapter.setNextFocus(findFocusUp(part), findFocusDown(part)); mQuickAdapter.setNextFocus(findFocusUp(quick), findFocusDown(quick)); - updateRatingChipFocus(); setDetailButtonsNextFocus(findFocusDown(-1)); } - /** - * “评分与数据”卡片纳入纵向焦点链:上接选集网格/列表,下接演员行。 - * 卡片此前不是 focusable,遥控上下键会整行跳过,视觉上凭空少了一段。 - */ - private void updateRatingChipFocus() { - ViewGroup container = mBinding.getRoot().findViewById(R.id.tmdbOmdbRatings); - if (container == null || !isVisible(container)) return; - int ratings = episodeFocusIndex(R.id.tmdbOmdbRatings); - int up = findFocusUp(ratings); - int down = findFocusDown(ratings); - for (int i = 0; i < container.getChildCount(); i++) { - View child = container.getChildAt(i); - if (!child.isFocusable()) continue; - child.setNextFocusUpId(up == 0 ? View.NO_ID : up); - child.setNextFocusDownId(down == 0 ? View.NO_ID : down); - } - } - private void updateEpisodeHeaderFocus() { int title = episodeFocusIndex(R.id.episodeTitle); int reverse = episodeFocusIndex(R.id.episodeReverse); @@ -4626,159 +3339,34 @@ private boolean runtimeSourceOnly; return false; } - /** - * 分段行(倒序 / 分段按钮)的左右移动必须显式接管:切线路会整体重建分段行 - * ({@code ArrayAdapter.addAll} -> notifyDataSetChanged),重建后 HorizontalGridView 的 - * 焦点搜索不再在子项之间移动,遥控在分段行按左右没有反应(用户报告"切线路后分组按钮选不到")。 - * 注意不能用 {@code getSelectedPosition()} 当当前焦点:重建后它会弹回旧值 - * (实测已移动到位置 1,松开按键又回到 2),因此以真正持有焦点的子项为基准。 - */ - private boolean moveArraySelection(int delta) { - if (delta == 0) return false; - int count = mArrayAdapter.getItemCount(); - if (count <= 0) return false; - int current = focusedArrayPosition(); - if (current < 0) current = mBinding.array.getSelectedPosition(); - int target = Math.max(0, Math.min(count - 1, current + delta)); - if (target == current) return false; - requestArrayFocus(target); - return true; - } - - /** - * 分段行中真正持有焦点的子项位置(按子视图下标),焦点不在分段行时返回 -1。 - */ - private int focusedArrayPosition() { - View focus = getCurrentFocus(); - while (focus != null) { - ViewParent parent = focus.getParent(); - if (parent == mBinding.array) return mBinding.array.indexOfChild(focus); - focus = parent instanceof View ? (View) parent : null; - } - return -1; - } - - /** - * 把焦点移到分段行的指定位置。分段行重建后自身会把选中位置弹回旧值,因此布局结束后再校正一次。 - */ - private void requestArrayFocus(int position) { - if (isFinishing() || isDestroyed()) return; - cancelPendingArrayFocus(); - applyArrayFocus(position); - ViewTreeObserver observer = mBinding.array.getViewTreeObserver(); - if (!observer.isAlive()) return; - mArrayFocusListener = new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - cancelPendingArrayFocus(); - if (isFinishing() || isDestroyed()) return; - if (focusedArrayPosition() == position) return; - // 焦点已经离开分段行(用户继续向下/向上)时不要抢回来。 - if (!isFocusInside(mBinding.array)) return; - applyArrayFocus(position); - } - }; - observer.addOnGlobalLayoutListener(mArrayFocusListener); - } - - private void applyArrayFocus(int position) { - mArrayAdapter.setSelectedPosition(position); - mBinding.array.setSelectedPosition(position); - View child = mBinding.array.getChildAt(position); - if (child != null && child.requestFocus()) return; - mBinding.array.post(() -> { - if (isFinishing() || isDestroyed()) return; - View next = mBinding.array.getChildAt(position); - if (next != null) next.requestFocus(); - }); - } - - private void cancelPendingArrayFocus() { - if (mArrayFocusListener == null) return; - ViewTreeObserver observer = mBinding.array.getViewTreeObserver(); - if (observer.isAlive()) observer.removeOnGlobalLayoutListener(mArrayFocusListener); - mArrayFocusListener = null; - } - private boolean onArrayKey(KeyEvent event) { - if (KeyUtil.isActionDown(event) && (KeyUtil.isLeftKey(event) || KeyUtil.isRightKey(event))) { - return moveArraySelection(KeyUtil.isLeftKey(event) ? -1 : 1); - } if (!KeyUtil.isActionDown(event) || !KeyUtil.isDownKey(event)) return false; int position = mBinding.array.getSelectedPosition(); if (position <= 1) return false; - // 只装载分段,不直接抢焦点:声明式的 nextFocusDown 目标是集数表头(“选集 · 第 N 季”), - // 之前这里直接 scrollToEpisode(..., true) 把焦点推进选集网格,遥控在分段行按向下 - // 会跳过整行集数表头,用户报告“往下到不了选季度的按钮”。 - selectEpisodeSegment(position, false); - if (focusEpisodeHeaderTool(View.FOCUS_DOWN)) return true; - scrollToEpisode(getSelectedEpisodePosition(mEpisodeAdapter.getItems()), true); + selectEpisodeSegment(position, true); return true; } - /** 集数表头(选集 · 第 N 季 / 倒序 / 列表 / 原文件名)是分段行的声明式下方目标。 */ - private boolean focusEpisodeHeaderTool(int direction) { - if (!isEpisodeFocusTarget(mBinding.episodeTitle)) return false; - mBinding.episodeTitle.requestFocus(direction); - return true; - } - - /** - * “评分与数据”行是选集列表声明式的下方邻居(见 getEpisodeFocusOrders)。 - * 集数卡片由 adapter 动态构建,不会把 nextFocusDown 写回卡片项,所以末行向下需要显式接管。 - */ - private boolean focusRatingRow() { - ViewGroup container = mBinding.getRoot().findViewById(R.id.tmdbOmdbRatings); - if (container == null || !isVisible(container)) return false; - for (int i = 0; i < container.getChildCount(); i++) { - View child = container.getChildAt(i); - if (child.isFocusable() && child.getVisibility() == View.VISIBLE) { - child.requestFocus(View.FOCUS_DOWN); - return true; - } - } - return false; - } - private boolean onEpisodeKey(KeyEvent event) { - if (!KeyUtil.isActionUp(event) && !KeyUtil.isActionDown(event)) return false; - if (KeyUtil.isActionUp(event)) { - mEpisodeHoldTarget = RecyclerView.NO_POSITION; - return false; - } + if (!KeyUtil.isActionDown(event)) return false; RecyclerView episodeView = episodeGridMode ? mBinding.episodeGrid : mBinding.episode; View focus = getCurrentFocus(); if (focus == null) return false; RecyclerView.ViewHolder holder = episodeView.findContainingViewHolder(focus); if (holder == null) return false; - int focusedPosition = holder.getBindingAdapterPosition(); - if (focusedPosition == RecyclerView.NO_POSITION) return false; - if (event.getRepeatCount() == 0) mEpisodeHoldTarget = RecyclerView.NO_POSITION; - int position = episodeGridMode ? TmdbEpisodeGridPolicy.holdBasePosition(focusedPosition, mEpisodeHoldTarget) : focusedPosition; + int position = holder.getBindingAdapterPosition(); + if (position == RecyclerView.NO_POSITION) return false; if (episodeGridMode) { RecyclerView.LayoutManager layoutManager = mBinding.episodeGrid.getLayoutManager(); int spanCount = layoutManager instanceof GridLayoutManager gridLayoutManager ? gridLayoutManager.getSpanCount() : getEpisodeGridSpanCount(); if (KeyUtil.isDownKey(event)) { int target = TmdbEpisodeGridPolicy.verticalFocusTarget(position, spanCount, mEpisodeGridAdapter.getItemCount(), true); - if (target != TmdbEpisodeGridPolicy.NO_FOCUS_TARGET) return focusEpisodeGridPosition(target); - // 长按撞到最后一排时停在原地,别让连续按键把焦点甩出网格。 - if (event.getRepeatCount() > 0) return true; - // 单次按下到网格最后一行:必须落到“评分与数据”,否则焦点链在选集底部断掉, - // 用户报告“评分与数据下的卡片无法选中容易遥控跳过”。 - return focusRatingRow(); + return target != TmdbEpisodeGridPolicy.NO_FOCUS_TARGET && focusEpisodeGridPosition(target); } if (KeyUtil.isUpKey(event)) { int target = TmdbEpisodeGridPolicy.verticalFocusTarget(position, spanCount, mEpisodeGridAdapter.getItemCount(), false); if (target != TmdbEpisodeGridPolicy.NO_FOCUS_TARGET) return focusEpisodeGridPosition(target); - if (event.getRepeatCount() > 0) return true; - } else if (KeyUtil.isLeftKey(event) || KeyUtil.isRightKey(event)) { - // 集数卡片根视图不可聚焦、adapter 也没写水平 nextFocus,交回系统 FocusFinder 时 - // 向左找不到同排邻居(按键无效)、向右直接跳出选集网格落到上方「简介」按钮 - // (用户报告“再左遥控无效右遥控就跑到了简介按钮”)。这里在本排内显式移动。 - int target = TmdbEpisodeGridPolicy.horizontalFocusTarget(position, spanCount, mEpisodeGridAdapter.getItemCount(), KeyUtil.isRightKey(event)); - if (target == TmdbEpisodeGridPolicy.NO_FOCUS_TARGET) return true; - return focusEpisodeGridPosition(target); } else { return false; } @@ -4790,102 +3378,22 @@ private boolean runtimeSourceOnly; int target = findFocusUp(episodeFocusIndex(episodeGridMode ? R.id.episodeGrid : R.id.episode)); if (target == 0) return false; View view = findViewById(target); - if (!isEpisodeFocusTarget(view)) return false; + if (view == null || view.getVisibility() != View.VISIBLE) return false; view.requestFocus(); return true; } private boolean focusEpisodeGridPosition(int position) { - return focusEpisodeGridPosition(position, 0); - } - - /** - * 把焦点送到集数网格的某一格,直到真正落地为止。 - * - *

原来只排一次 {@code post}:焦点还没绑到新卡片时那段回调就跑到,取不到 holder 就悄悄放弃, - * 于是按住方向键的后续重复事件仍以旧卡片为基准算出同一个目标(只走一格), - * 而且同一时刻还挂着多条互不知情的回调,彼此用过期坐标重新对焦、把列表拽回去(用户报告松手后闪回)。 - * 这里改成「按帧重试 + 目标即令牌」:目标换了就作废旧回调,成功落地就停。 - */ - private boolean focusEpisodeGridPosition(int position, int attempt) { - if (isFinishing() || isDestroyed()) return false; - int count = mEpisodeGridAdapter == null ? 0 : mEpisodeGridAdapter.getItemCount(); - if (count == 0) return false; - int target = Math.max(0, Math.min(position, count - 1)); - mEpisodeHoldTarget = target; - RecyclerView.ViewHolder holder = mBinding.episodeGrid.findViewHolderForAdapterPosition(target); - if (holder != null) { - mBinding.episodeGrid.stopScroll(); - if (holder.itemView.requestFocus()) return true; - } else if (attempt == 0) { - mBinding.episodeGrid.scrollToPosition(target); - } - if (attempt >= EPISODE_HOLD_FOCUS_MAX_ATTEMPTS) return true; - mBinding.episodeGrid.postOnAnimation(() -> { - if (mEpisodeHoldTarget != target) return; - focusEpisodeGridPosition(target, attempt + 1); + RecyclerView.ViewHolder holder = mBinding.episodeGrid.findViewHolderForAdapterPosition(position); + if (holder != null && holder.itemView.requestFocus()) return true; + mBinding.episodeGrid.scrollToPosition(position); + mBinding.episodeGrid.post(() -> { + RecyclerView.ViewHolder next = mBinding.episodeGrid.findViewHolderForAdapterPosition(position); + if (next != null) next.itemView.requestFocus(); }); return true; } - /** - * 选集网格会随切线路/切分段整体重建,RecyclerView 在布局过程中回收仍持有焦点的子项, - * 焦点被 rootViewRequestFocus() 重新搜索并落到最上方的预览框,之后遥控无法再离开 - * (用户报告"分组按钮向下/向左选不到")。这里在布局结束后再校正一次。 - */ - private void requestEpisodeGridFocus(int position) { - if (isFinishing() || isDestroyed()) return; - mEpisodeGridFocusTarget = position; - mEpisodeGridFocusAttempts = 0; - ensureEpisodeGridFocusListener(); - focusEpisodeGridPosition(position); - } - - /** - * 焦点校正监听。只在「焦点真的丢了」(getCurrentFocus() == null)时补偿, - * 一旦焦点落在任何控件(包括预览框)上就立刻注销 —— 校正的目的是把 - * 进场初始焦点送到当前集,不是长期看门狗。之前允许「焦点在预览框就抢回选集」, - * 与 RecyclerView 回收后 rootViewRequestFocus 把焦点交回预览框互相拉锯, - * 表现为焦点在播放器和集数按钮之间来回跳(用户报告)。 - */ - private void ensureEpisodeGridFocusListener() { - if (mEpisodeGridFocusListener != null) return; - ViewTreeObserver observer = mBinding.episodeGrid.getViewTreeObserver(); - if (!observer.isAlive()) return; - mEpisodeGridFocusListener = () -> { - int target = mEpisodeGridFocusTarget; - if (target < 0 || isFinishing() || isDestroyed() || isFullscreen()) { - cancelPendingEpisodeGridFocus(); - return; - } - if (isFocusInside(mBinding.episodeGrid)) { - cancelPendingEpisodeGridFocus(); - return; - } - // 焦点已经在任意控件上,说明用户或系统已经安排好焦点:交还控制权, - // 绝不把它从预览框或别的行拽回选集。 - if (getCurrentFocus() != null) { - cancelPendingEpisodeGridFocus(); - return; - } - if (++mEpisodeGridFocusAttempts > EPISODE_GRID_FOCUS_MAX_ATTEMPTS) { - cancelPendingEpisodeGridFocus(); - return; - } - focusEpisodeGridPosition(target); - }; - observer.addOnGlobalLayoutListener(mEpisodeGridFocusListener); - } - - private void cancelPendingEpisodeGridFocus() { - mEpisodeGridFocusTarget = -1; - mEpisodeGridFocusAttempts = 0; - if (mEpisodeGridFocusListener == null) return; - ViewTreeObserver observer = mBinding.episodeGrid.getViewTreeObserver(); - if (observer.isAlive()) observer.removeOnGlobalLayoutListener(mEpisodeGridFocusListener); - mEpisodeGridFocusListener = null; - } - @Override public void onRevSort() { mHistory.setRevSort(!mHistory.isRevSort()); @@ -4911,35 +3419,13 @@ private boolean runtimeSourceOnly; private void selectEpisodeSegment(int position, boolean requestEpisodeFocus) { if (position <= 1) return; - // 分段位与内容都没变时必须是纯 no-op:selectEpisodeSegmentPosition 里的 - // BaseGridView.setSelectedPosition / scrollToPosition 会再触发一次布局与选中回调, - // 布局里派发的 selectEpisodeSegment(position) 又会回到这里,形成持续自激循环 - // (实测约 20Hz,日志里 onSegmentFocus / onChildViewHolderSelected / - // FlagSelectionListener 三路交替),把分段行刚被左右键移动的选中位冲回原分段。 - if (!requestEpisodeFocus && position == mBinding.array.getSelectedPosition() && isSegmentContentDisplayed(position)) return; selectEpisodeSegmentPosition(position); showEpisodeSegment(position); if (requestEpisodeFocus) scrollToEpisode(getSelectedEpisodePosition(mEpisodeAdapter.getItems()), true); } - // 该分段位置对应的集数是否已经在选集适配器里展示 - private boolean isSegmentContentDisplayed(int position) { - if (mArrayAdapter == null || getFlag() == null) return false; - List episodes = getFlag().getEpisodes(); - if (episodes.isEmpty()) return false; - int start = mArrayAdapter.getStart(position); - if (start < 0 || start >= episodes.size()) return false; - int end = Math.min(start + getEpisodeSegmentSize(episodes.size()), episodes.size()); - return isSegmentDisplayed(episodes.subList(start, end)); - } - private void selectEpisodeSegmentPosition(int position) { if (position <= 1 || position >= mArrayAdapter.getItemCount()) return; - // 用户正在分段行上移动时,焦点位就是权威选中位;此时不要再被线路/选集重建回调 - // 默认段位拽回去,否则刚按下的左右键会在下一帧被 BaseGridView 的 - // focusToViewInLayout 按选中位重新回焦,表现为「分段按钮左右不动」。 - int focused = focusedArrayPosition(); - if (focused >= 0 && focused != position && isFocusInside(mBinding.array)) return; mArrayAdapter.setSelectedPosition(position); mBinding.array.setSelectedPosition(position); mBinding.array.scrollToPosition(position); @@ -4953,32 +3439,13 @@ private boolean runtimeSourceOnly; int selectedPosition = getSelectedEpisodePosition(episodes); int positionInSegment = selectedPosition >= start && selectedPosition < end ? selectedPosition - start : 0; List items = episodes.subList(start, end); - // 分段内容没变时绝不能再 addAll:分段行在布局期间会被 BaseGridView 的 - // focusToViewInLayout 反复回焦,触发 onSegmentFocus -> selectEpisodeSegment -> - // showEpisodeSegment -> addAll(notifyDataSetChanged) -> 再次布局,形成自激循环 - // (实测约 20Hz,且 flag 网格随之重排会再经 FlagSelectionListener 触发一次 - // onItemClick(Flag) -> setEpisodeAdapter -> selectEpisodeSegmentPosition)。 - // 循环里刚用左右键移动的选中位会在 25~35ms 内被弹回原分段, - // 表现为「切线路后分段按钮左右选不到」。内容一致就直接返回,从源头断开循环。 - if (isSegmentDisplayed(items)) return; mEpisodeAdapter.addAll(items); mEpisodeGridAdapter.addAll(items); scrollToEpisode(positionInSegment); } - /** - * 当前选集适配器是否已经在展示同一段集数(按首尾集的同一实例比较: - * 这些 Episode 都是同一 Flag 里 subList 出来的对象,倒序 / 换线路 / 换详情都会让首尾不同)。 - */ - private boolean isSegmentDisplayed(List items) { - if (items.isEmpty()) return false; - List shown = mEpisodeAdapter.getItems(); - if (shown == null || shown.size() != items.size()) return false; - return shown.get(0) == items.get(0) && shown.get(shown.size() - 1) == items.get(items.size() - 1); - } - private boolean shouldEnterFullscreen(Episode item) { - boolean enter = !isFullscreen() && isPlayingContent(item); + boolean enter = !isFullscreen() && item.isSelected(); if (enter) enterFullscreen(); return enter; } @@ -5091,20 +3558,7 @@ private boolean runtimeSourceOnly; } private void onVideo() { - if (isFullscreen()) return; - // 手动播放模式(关闭「点播自动播放」):播放器上的确认键一次完成「全屏 + 起播」, - // 与详情页“继续播放”按钮的语义一致 —— 用户不必先全屏、再按一次 OK 才看到画面。 - // 自动播放开启时保持原行为:只进全屏,起播由播放器自身负责。 - if (isManualPlaybackMode()) { - mExplicitPlaybackRequest = true; - mManualPlaybackStarted = true; - updateVideoCover(); - } - enterFullscreen(); - if (isManualPlaybackMode() && player() != null) { - if (player().isEmpty()) onRefresh(); - else if (!player().isPlaying()) onPlay(); - } + if (!isFullscreen()) enterFullscreen(); } private void onChange() { @@ -5128,17 +3582,6 @@ private boolean runtimeSourceOnly; .seasons(getEpisodeDialogSeasons(), getEpisodeDialogSeasonCounts()).show(this); } - /** - * 全屏菜单键=下一集:与底栏「下一集」按钮、片尾自动续播共用 checkNext(), - * 倒序播放时由 checkNext() 自动改取上一集。无详情/无分集时静默忽略, - * 避免 getFlag() 为空或空分集被当成真实剧集切走。 - */ - private void onMenuNextEpisode() { - Flag flag = getFlag(); - if (flag == null || flag.getEpisodes().isEmpty()) return; - checkNext(); - } - private java.util.List getEpisodeDialogSeasons() { return new java.util.ArrayList<>(getEpisodeDialogSeasonCounts().keySet()); } @@ -5149,7 +3592,6 @@ private boolean runtimeSourceOnly; } private void onRepeat() { - invalidateShortDramaQueue("repeat"); player().setRepeatOne(!player().isRepeatOne()); mBinding.control.action.repeat.setSelected(player().isRepeatOne()); } @@ -5159,7 +3601,6 @@ private boolean runtimeSourceOnly; } private void onMultiThreadProxySaved(boolean applyNow) { - invalidateShortDramaQueue("multi-thread-proxy"); setPlayParamsState(); if (applyNow && player() != null && !player().isEmpty()) player().reloadCurrentMediaItem(); } @@ -5199,34 +3640,21 @@ private boolean runtimeSourceOnly; else onNext(notify); } - /** @return 是否真的切走了。末集/倒序首集切不动,调用方据此决定要不要提示「进入下一集」。 */ - private boolean advanceEpisode(boolean notify) { - return mHistory.isRevPlay() ? onPrev(notify) : onNext(notify); - } - private void checkPrev() { if (mHistory.isRevPlay()) onNext(true); else onPrev(true); } - private boolean onNext(boolean notify) { + private void onNext(boolean notify) { Episode item = getAdjacentEpisode(1); - if (!item.isSelected()) { - onItemClick(item); - return true; - } - if (notify) Notify.show(mHistory.isRevPlay() ? R.string.error_play_prev : R.string.error_play_next); - return false; + if (!item.isSelected()) onItemClick(item); + else if (notify) Notify.show(mHistory.isRevPlay() ? R.string.error_play_prev : R.string.error_play_next); } - private boolean onPrev(boolean notify) { + private void onPrev(boolean notify) { Episode item = getAdjacentEpisode(-1); - if (!item.isSelected()) { - onItemClick(item); - return true; - } - if (notify) Notify.show(mHistory.isRevPlay() ? R.string.error_play_next : R.string.error_play_prev); - return false; + if (!item.isSelected()) onItemClick(item); + else if (notify) Notify.show(mHistory.isRevPlay() ? R.string.error_play_next : R.string.error_play_prev); } private Episode getAdjacentEpisode(int offset) { @@ -5365,12 +3793,9 @@ private boolean runtimeSourceOnly; } private void onSpeed() { - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || player().isEmpty()) return; - mBinding.control.action.speed.setText(player().setSpeed(speed)); - saveUserSpeed(); - setR1Callback(); - }); + mBinding.control.action.speed.setText(player().addSpeed()); + saveUserSpeed(); + setR1Callback(); } private void onSpeedAdd() { @@ -5409,23 +3834,10 @@ private boolean runtimeSourceOnly; private void onRefresh() { saveHistory(); - // 「切换线路不自动播放」:线路点击只换线路/选集列表,旧流继续播。 - // 抑制必须先于拆流,否则用户还没点具体集数,正在看的视频就先被停掉了; - // 但仍在途的旧线路取流结果必须就地作废,否则它稍后返回会替用户自动起播旧线路。 - if (mDeferFlagSwitchPlayback) { - mDeferFlagSwitchPlayback = false; - if (mViewModel != null) mViewModel.cancelPlayerContent(); - invalidatePlayerContent(); - return; - } - invalidateShortDramaQueue("refresh"); - if (mViewModel != null) mViewModel.cancelPlayerContent(); - invalidatePlayerContent(); subtitlePlaybackSession.stop(this); player().stop(); player().clear(); mClock.setCallback(null); - updateVideoCover(); if (mFlagAdapter.getItemCount() == 0) return; if (mEpisodeAdapter.getItemCount() == 0) return; getPlayer(getFlag(), getEpisode()); @@ -5452,47 +3864,16 @@ private boolean runtimeSourceOnly; } private boolean onOpeningReset() { - // 长按清空是「这里不要有值」,别紧接着又把探测值渲染上去,看起来像没清掉 - mIntroSkipPlayback.suppressDetected(true); setOpening(0); return true; } private void setOpening(long opening) { mHistory.setOpening(opening); - setOpeningEndingText(); + mBinding.control.action.opening.setText(opening <= 0 ? getString(R.string.play_op) : Util.timeMs(mHistory.getOpening())); syncHistory(); } - /** - * 刷新片头/片尾按钮文案。手设值优先,其次显示自动探测值(带 ~ 前缀区分,不落库)。 - */ - private void setOpeningEndingText() { - if (mBinding == null || mHistory == null) return; - mBinding.control.action.opening.setText(openingLabel()); - mBinding.control.action.ending.setText(endingLabel()); - } - - private String openingLabel() { - if (mHistory.getOpening() > 0) return Util.timeMs(mHistory.getOpening()); - long detected = detectedIntroSkipValue(true); - return detected > 0 ? getString(R.string.intro_skip_detected_value, Util.timeMs(detected)) : getString(R.string.play_op); - } - - private String endingLabel() { - if (mHistory.getEnding() > 0) return Util.timeMs(mHistory.getEnding()); - long detected = detectedIntroSkipValue(false); - return detected > 0 ? getString(R.string.intro_skip_detected_value, Util.timeMs(detected)) : getString(R.string.play_ed); - } - - /** 关掉自动跳过时不显示探测值——那种情况下这个数字不会导致任何动作,显示出来是误导。 */ - private long detectedIntroSkipValue(boolean opening) { - // isReleased 必查:服务已 release 但 Activity 还握着 mService 的窗口里, - // PlayerManager.getDuration() 会直接对空的 player 取值抛 NPE - if (!Setting.isIntroSkipEnabled() || player() == null || player().isReleased()) return -1; - return opening ? mIntroSkipPlayback.getDetectedOpeningMs() : mIntroSkipPlayback.getDetectedEndingMs(player().getDuration()); - } - private void onEnding() { long position = player().getPosition(); long duration = player().getDuration(); @@ -5508,19 +3889,17 @@ private boolean runtimeSourceOnly; } private boolean onEndingReset() { - mIntroSkipPlayback.suppressDetected(false); setEnding(0); return true; } private void setEnding(long ending) { mHistory.setEnding(ending); - setOpeningEndingText(); + mBinding.control.action.ending.setText(ending <= 0 ? getString(R.string.play_ed) : Util.timeMs(mHistory.getEnding())); syncHistory(); } private void onPlayerKernel() { - invalidateShortDramaQueue("kernel"); if (playerKernelSwitchRefreshing) return; PlayerKernelDialog.show(this, player().getPlayerType(), this::switchPlayerKernel, this::onExternalPlayer); } @@ -5537,7 +3916,6 @@ private boolean runtimeSourceOnly; player().switchPlayer(type); setPlayerKernel(); setDecode(); - rememberPlayerKernel(type); } private boolean onPlayerKernelLong() { @@ -5558,8 +3936,6 @@ private boolean runtimeSourceOnly; String flag = currentFlag.getFlag(); String episode = currentEpisode.getUrl(); MediaMetadata metadata = buildMetadata(); - int requestId = ++playerKernelSwitchRequestId; - int generation = beginPlayerContentSwitch(requestId, key, flag, episode); playerKernelSwitchRefreshing = true; mClock.setCallback(null); clearLyrics(); @@ -5567,10 +3943,9 @@ private boolean runtimeSourceOnly; Task.execute(() -> { try { Result result = SiteApi.playerContent(key, flag, episode, nextType); - App.post(() -> switchPlayerKernelWithResult(requestId, generation, key, flag, episode, nextType, result, position, speed, repeat, metadata)); + App.post(() -> switchPlayerKernelWithResult(nextType, result, position, speed, repeat, metadata)); } catch (Throwable e) { App.post(() -> { - if (!isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return; playerKernelSwitchRefreshing = false; setPlayerKernel(); setDecode(); @@ -5581,24 +3956,20 @@ private boolean runtimeSourceOnly; return true; } - private void switchPlayerKernelWithResult(int requestId, int generation, String key, String flag, String episode, - int type, Result result, long position, float speed, boolean repeat, MediaMetadata metadata) { - if (requestId != playerKernelSwitchRequestId - || !isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return; + private void switchPlayerKernelWithResult(int type, Result result, long position, float speed, boolean repeat, MediaMetadata metadata) { playerKernelSwitchRefreshing = false; - if (!canApplyPlayerContentRequest(requestId, generation, key, flag, episode)) return; + // 换内核会重新解析出可能不同的 URL,与换画质同理 + resetAdFeedback(); if (result == null || result.hasMsg() || result.getRealUrl().isEmpty()) { Notify.show(result != null && result.hasMsg() ? result.getMsg() : getString(R.string.error_play_url)); } else { - player().switchPlayer(type, result, activePlaybackKey(), metadata, isUseParse(), position, speed, repeat); - rememberPlayerKernel(type); + player().switchPlayer(type, result, getHistoryKey(), metadata, isUseParse(), position, speed, repeat); } setPlayerKernel(); setDecode(); } private void onDecode() { - invalidateShortDramaQueue("decode"); mClock.setCallback(null); player().toggleDecode(); setDecode(); @@ -5620,7 +3991,7 @@ private boolean runtimeSourceOnly; } private void onDanmaku() { - DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).tmdb(danmakuTmdbId(), danmakuTmdbSeasonNumber()).show(this); + DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).show(this); hideControl(); } @@ -5630,7 +4001,7 @@ private boolean runtimeSourceOnly; return; } if (!isAdFeedbackEnabled()) { - Notify.show(R.string.ad_feedback_ai_disabled); + Notify.show(R.string.ad_interval_invalid); return; } hideControl(); @@ -5638,15 +4009,10 @@ private boolean runtimeSourceOnly; } 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 PlaybackResourceClassifier.isHlsUrl(url); + // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。 + // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。 + if (player() == null || TextUtils.isEmpty(player().getUrl())) return false; + return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0; } private void setAdFeedbackVisible() { @@ -5654,76 +4020,173 @@ private boolean runtimeSourceOnly; applyActionButtonVisibility(); } + private AdFeedbackController adFeedback() { + if (mAdFeedback == null) { + mAdFeedbackHost = new AdFeedbackHostAdapter( + new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi()); + mAdFeedback = new AdFeedbackController(mAdFeedbackHost); + } + return mAdFeedback; + } + + /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */ + private void resetAdFeedback() { + mAdMarkStartMs = -1; + if (mAdFeedback != null) mAdFeedback.invalidate(); + if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence(); + // 作废后归因回调不再刷新界面,对话框会永久停在「分析中」 + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); + mAdFeedbackDialog = null; + } + + /** + * 起播后记录本站域名,供域名信誉通道建立基线。 + * + *

必须强制初始化适配器:它原本只在用户首次按下「有广告」时懒创建, + * 若这里跳过未初始化的情况,首次反馈时基线仍为空,域名通道会因 + * 「无基线不断言」而弃权 —— 整条通道等于没接。 + */ + private void recordAdFeedbackHost() { + // 关掉去广告或播直播时不建基线:这些域名会挤掉 LRU 里真正有用的条目 + if (!isAdFeedbackEnabled()) return; + adFeedback(); + mAdFeedbackHost.recordPlaybackHost(); + } + + /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */ private void submitAdFeedback() { - AdDetectionRequest request = buildAdDetectionRequest(); - if (request == null) { - Notify.show(R.string.ad_feedback_no_url); + AdFeedbackController controller = adFeedback(); + 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, end); + } else { + session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence()); + } + if (session == null) { + Notify.show(R.string.ad_interval_invalid); return; } - // 记录 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); - }); - }); + // 统计放在成交之后,未成立的反馈不计入 + AdBlockStatsStore.recordFeedback(getKey()); } - 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(); @@ -5750,9 +4213,7 @@ private boolean runtimeSourceOnly; return; } - // seek 窗口开着时不能摘掉它的最小可见计时器:那次 showProgress 属于 seek 自身 - // (BUFFERING 分支),计时器是窗口唯一的收尾路径。 - if (!mSeekProgressPending && mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); + if (mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); mBinding.progress.getRoot().setVisibility(View.VISIBLE); App.post(mR3, 0); hideCenter(); @@ -5760,9 +4221,6 @@ private boolean runtimeSourceOnly; } private void hideProgress() { - // 任何显式收圈都同时关闭 seek 窗口,避免窗口比圈活得更久。 - mSeekProgressPending = false; - mSeekProgressStartedAtMs = 0; if (mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); mBinding.progress.getRoot().setVisibility(View.GONE); App.removeCallbacks(mR3); @@ -5777,23 +4235,18 @@ private boolean runtimeSourceOnly; private void showError(String text) { mBinding.widget.error.setVisibility(View.VISIBLE); mBinding.widget.text.setText(text); - // 把焦点交给重试按钮:错误态下用户唯一能做的事就是重试,而播放页平时把焦点放在 - // 视频面上(mBinding.video),浮层里的按钮靠方向键不一定走得过去。 - mBinding.widget.retry.requestFocus(); hideProgress(); } private void hideError() { mBinding.widget.error.setVisibility(View.GONE); mBinding.widget.text.setText(""); - // 只有焦点确实在重试按钮上时才交还视频面:否则会抢走别的控件(例如剧集网格)的焦点。 - if (mBinding.widget.retry.hasFocus()) mBinding.video.requestFocus(); } private void showInfo() { showTopInfo(); mBinding.widget.center.setVisibility(View.VISIBLE); - mBinding.widget.duration.setText(getTimelineDurationTime()); + mBinding.widget.duration.setText(player().getDurationTime()); mBinding.widget.position.setText(player().getPositionTime(0)); } @@ -5812,7 +4265,6 @@ private boolean runtimeSourceOnly; showTopInfo(); setPlayParamsState(); mBinding.control.getRoot().setVisibility(View.VISIBLE); - updateCustomButtonVisibility(); if (mOsd != null) mOsd.setControlsVisible(true); PlayerControlFocusHelper.ensureFocus(mBinding.control.getRoot(), view); setR1Callback(); @@ -5820,7 +4272,6 @@ private boolean runtimeSourceOnly; private void hideControl() { mBinding.control.getRoot().setVisibility(View.GONE); - updateCustomButtonVisibility(); if (mOsd != null) mOsd.setControlsVisible(false); if (player().isPlaying()) mBinding.widget.top.setVisibility(View.GONE); App.removeCallbacks(mR1); @@ -5976,9 +4427,7 @@ private boolean runtimeSourceOnly; setQualityVisible(false); if (mHistory.isRevSort()) reverseEpisode(true); } else { - mProgrammaticFlagSwitch = true; onItemClick(resolveHistoryPlaybackFlag(item.getFlags())); - mProgrammaticFlagSwitch = false; if (mHistory.isRevSort()) reverseEpisode(true); } } @@ -5999,11 +4448,12 @@ private boolean runtimeSourceOnly; if (!TextUtils.isEmpty(getMark())) mHistory.setVodRemarks(getMark()); applyIntentPlaybackSelection(item); if (resumeHistory == null && Setting.isIncognito() && mHistory.getKey().equals(getHistoryKey())) mHistory.delete(); - setOpeningEndingText(); + mBinding.control.action.opening.setText(mHistory.getOpening() <= 0 ? getString(R.string.play_op) : Util.timeMs(mHistory.getOpening())); + mBinding.control.action.ending.setText(mHistory.getEnding() <= 0 ? getString(R.string.play_ed) : Util.timeMs(mHistory.getEnding())); // 如果历史记录中已有有效倍速,使用历史倍速;否则使用默认播放倍速 float speed = getPlaybackSpeed(); mBinding.control.action.speed.setText(player().setSpeed(speed)); - mHistory.setVodName(historyVodName(item)); + mHistory.setVodName(item.getName()); mHistory.setVodPic(getInitialArtwork(item)); enrichHistoryMeta(item); PlaybackEventCollector.get().updateHistory(mHistory); @@ -6072,72 +4522,6 @@ private boolean runtimeSourceOnly; return ResUtil.getScreenWidth(this) >= ResUtil.getScreenHeight(this); } - /** - * 手动播放模式:关闭「点播自动播放」后,播放页收起自动起播,预览位改显示影片封面, - * 点选集直接全屏起播。初始焦点仍留在预览窗口(见 {@link #requestInitialPlaybackFocus()})。 - *

- * 仅 TV 端生效;返回值同时受 leanback 与设置项约束,手机端行为保持不变。 - */ - private boolean isManualPlaybackMode() { - return Util.isLeanback() && !PlayerSetting.isAutoPlay(); - } - - /** - * 进入播放页时的初始焦点:始终交给预览窗口(与自动播放开启时一致)。 - *

- * 曾经在手动播放模式下把初始焦点送到选集,但那会让用户按 UP 到播放器后又被拽回去、 - * 并在切线路后与 RecyclerView 的焦点回收互相拉锯(焦点来回跳)。用户确认的预期是: - * 进来仍然是播放器,关闭自动播放时在播放器上按确认键一次性完成「全屏 + 起播」。 - */ - private void requestInitialPlaybackFocus() { - mBinding.video.requestFocus(); - } - - /** - * 选集绑定完成钩子。初始焦点已固定交给预览窗口(见 {@link #requestInitialPlaybackFocus()}), - * 这里保持空实现:手动播放模式不再需要在选集重建后补一次「回焦选集」的动作, - * 那正是焦点在播放器与集数按钮之间来回跳的来源。 - */ - private void flushPendingManualEpisodeFocus() { - } - - /** - * 手动播放模式下、播放器尚无画面时,在预览位盖一层影片封面,避免露出没有内容的黑框。 - */ - private void updateVideoCover() { - if (mBinding == null) return; - // 用「用户是否已显式起播过」判断,而不是 player().isEmpty(): - // 手动模式下详情页会先 prepare(播放器非空但没播),用 isEmpty 会把封面误判成该隐藏。 - boolean show = isManualPlaybackMode() - && !mAudioStageVisible - && !mManualPlaybackStarted; - mBinding.videoCover.setVisibility(show ? View.VISIBLE : View.GONE); - if (!show) return; - String url = videoCoverUrl(); - if (TextUtils.isEmpty(url)) { - mBinding.videoCover.setVisibility(View.GONE); - return; - } - ImgUtil.load(this, url, ResUtil.dp2px(480), ResUtil.dp2px(270), new CustomTarget<>() { - @Override - public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { - mBinding.videoCover.setImageDrawable(resource); - } - - @Override - public void onLoadFailed(@Nullable Drawable errorDrawable) { - mBinding.videoCover.setImageDrawable(null); - } - }); - } - - private String videoCoverUrl() { - if (!TextUtils.isEmpty(getTmdbVodPic())) return getTmdbVodPic(); - if (!TextUtils.isEmpty(getPic())) return getPic(); - if (mVod != null && !TextUtils.isEmpty(mVod.getPic())) return mVod.getPic(); - return mHistory == null ? "" : mHistory.getVodPic(); - } - private boolean hasInitialPreview() { return !getName().isEmpty() || !getPic().isEmpty() || !getWallPic().isEmpty(); } @@ -6148,27 +4532,14 @@ private boolean runtimeSourceOnly; if (!getContent().isEmpty()) mBinding.content.setTag(getContent()); if (!getPic().isEmpty()) setArtwork(getPic()); else if (!getWallPic().isEmpty()) setContextWall(getWallPic()); - requestInitialPlaybackFocus(); - updateVideoCover(); - } - - /** - * 推送片名:真实片名在 Intent 里,源站 Vod 常常只有裸地址,直接用它弹幕必然搜不到。 - */ - private String historyVodName(Vod item) { - String name = item == null ? "" : item.getName(); - if (!SiteApi.PUSH.equals(getKey())) return name; - PushParser.Parsed push = PushParser.fromId(getId()); - if (push.hasExplicitTitle()) return push.getTitle(); - String intentName = getName(); - return TmdbMatchPolicy.shouldAutoMatchPushTitle(intentName) ? intentName : name; + mBinding.video.requestFocus(); } private History createHistory(Vod item) { History history = new History(); history.setKey(getHistoryKey()); history.setCid(VodConfig.getCid()); - history.setVodName(historyVodName(item)); + history.setVodName(item.getName()); history.setVodPic(getInitialArtwork(item)); history.setWallPic(getWallPic()); history.findEpisode(item.getFlags()); @@ -6204,10 +4575,8 @@ private boolean runtimeSourceOnly; boolean crossSource = mHistory.isCrossSourcePlayback(); boolean shareEpisodeProgress = crossSource || isResumeFromHistory() || Setting.isHistoryAggregationEffective(); boolean compatibleFlag = shareEpisodeProgress || TextUtils.equals(mHistory.getVodFlag(), flag.getFlag()); - // 历史集 URL 能定位到当前线路条目(同集多版本并存)时才启用版本消歧;换线路/刷新保留集号容错。 - boolean versionAware = flag.containsEpisodeUrl(mHistory.getEpisode()); boolean sameEpisode = episode != null && (shareEpisodeProgress - ? historyEpisode.matchesPlayback(mHistory.getEpisode(), versionAware) + ? historyEpisode.matchesPlayback(mHistory.getEpisode()) : episode.matches(mHistory.getEpisode())); if (!compatibleFlag || (episode != null && !sameEpisode)) { mHistory.setPosition(C.TIME_UNSET); @@ -6230,15 +4599,7 @@ private boolean runtimeSourceOnly; if (!TextUtils.isEmpty(playUrl)) { for (Episode episode : flag.getEpisodes()) if (TextUtils.equals(playUrl, episode.getUrl())) return episode; } - if (!TextUtils.isEmpty(playName)) { - Episode episode = flag.find(playName, true); - // 名称通道只采纳真实命中:Flag.find 在打分全为 0 时可能用 position 槽位兜底,返回与该集名无关的条目; - // strict 只挡首集兜底,不挡位置兜底。命中不了就继续走历史集身份兜底。 - if (episode != null && (flag.getEpisodes().size() == 1 - || episode.getScore(playName, Util.getEpisodeNumber(playName)) > 0)) return episode; - } - // 集名在换源/刮削后常失真,名称通道又可能被 position 兜底伪命中,最后用历史集身份(URL 优先 + 集号容错)兜底。 - return mHistory == null ? null : flag.find(mHistory.getEpisode(), true); + return TextUtils.isEmpty(playName) ? null : flag.find(playName, true); } private void saveHistory() { @@ -6269,7 +4630,7 @@ private boolean runtimeSourceOnly; mHistory.setCreateTime(System.currentTimeMillis()); } if (exit && service() != null) PlaybackEventCollector.get().onStop(player()); - if (!canSavePlaybackHistory(mHistory)) return; + if (!mHistory.canSave()) return; History history = mHistory.copy(); Task.execute(() -> { if (history.getDuration() > 0) history.merge().save(); @@ -6290,10 +4651,7 @@ private boolean runtimeSourceOnly; private void updateHistory(Episode item) { // 换线路或源站刷新时同一集的 URL、集名格式可能变化,统一按播放恢复规则识别。 Episode historyEpisode = withSourceSeasonEpisodeIdentity(item); - // 历史里那集的 URL 仍能定位到当前线路条目时(同集多版本并存)才启用版本消歧; - // 换线路/换源/源站刷新后 URL 必然失配,必须保留集号容错,否则跨线路续播会丢失进度。 - boolean versionAware = getFlag().containsEpisodeUrl(mHistory.getEpisode()); - boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode(), versionAware); + boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode()); boolean sameFlag = TextUtils.equals(mHistory.getVodFlag(), getFlag().getFlag()); if (!sameEpisode || !sameFlag) mIntroSkipPlayback.reset(); if ((!sameEpisode || !sameFlag) && service() != null) { @@ -6316,8 +4674,7 @@ private boolean runtimeSourceOnly; } if (!sameEpisode && !tmdbHistoryResumePending) { - // updatePlaybackHistoryPosition() 刚把旧集进度写回 History;切换新集前必须先覆盖, - // 否则新集没有独立缓存时会继承旧集进度并从错误位置开始播放。 + // 从缓存中恢复新集的播放位置 EpisodePositionCache.EpisodePosition cached = skipEpisodePositionCache() ? null : EpisodePositionCache.get().get( getKey(), getId(), @@ -6363,81 +4720,6 @@ private boolean runtimeSourceOnly; keep.setVodPic(mHistory.getVodPic()); keep.save(); } - updateFollowingState(); - } - - private void onFollowing() { - if (!FollowingSettings.isEnabled()) { - Notify.show(R.string.following_enabled_hint); - return; - } - if (mHistory == null || followingActionPending || !FollowingPlaybackBridge.isEligible(mHistory)) return; - String identityKey = FollowingPlaybackBridge.identityKey(mHistory, currentSourceSeasonNumber()); - if (TextUtils.isEmpty(identityKey)) return; - followingActionPending = true; - mBinding.following.setEnabled(false); - FollowingPlaybackBridge.findAsync(identityKey, existing -> { - if (isFinishing() || isDestroyed()) return; - if (existing != null) { - // 播放页保持原地:已追更时再次点击即刻取消,绝不跳转追更页打断播放。 - cancelFollowing(identityKey); - return; - } - Following item = FollowingPlaybackBridge.build(mHistory, currentSourceSeasonNumber()); - FollowingSource source = FollowingPlaybackBridge.source(item, mHistory); - FollowingPlaybackBridge.addAsync(item, source, (saved, error) -> { - followingActionPending = false; - if (isFinishing() || isDestroyed()) return; - if (error != null) { - mBinding.following.setEnabled(true); - Notify.show(error.getMessage()); - return; - } - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); - updateFollowingState(); - Notify.show(R.string.following_added); - }); - }); - } - - /** 播放页取消追更:写墓碑后立即刷新按钮状态,不离开当前播放页。 */ - private void cancelFollowing(String identityKey) { - FollowingScheduler.cancelNext(this, identityKey); - FollowingPlaybackBridge.deleteAsync(identityKey, error -> { - followingActionPending = false; - if (isFinishing() || isDestroyed()) return; - if (error != null) { - mBinding.following.setEnabled(true); - Notify.show(error.getMessage()); - return; - } - updateFollowingState(); - FollowingPlaybackBridge.refreshUnreadCountAsync(null); - Notify.show(R.string.following_canceled); - }); - } - - private void updateFollowingState() { - boolean eligible = FollowingSettings.isEnabled() && FollowingPlaybackBridge.isEligible(mHistory); - int generation = ++followingUiGeneration; - if (!eligible) { - applyFollowingButtonState(false, false); - return; - } - String identityKey = FollowingPlaybackBridge.identityKey(mHistory, currentSourceSeasonNumber()); - applyFollowingButtonState(true, false); - FollowingPlaybackBridge.findAsync(identityKey, item -> { - if (generation != followingUiGeneration || isFinishing() || isDestroyed()) return; - applyFollowingButtonState(true, item != null); - }); - } - - private void applyFollowingButtonState(boolean eligible, boolean followed) { - mBinding.following.setVisibility(eligible ? View.VISIBLE : View.GONE); - mBinding.following.setEnabled(true); - mBinding.following.setSelected(followed); - mBinding.following.setText(followed ? R.string.following_added : R.string.following_add); } private void updateVod(Vod item) { @@ -6456,7 +4738,7 @@ private boolean runtimeSourceOnly; } } boolean historyReloaded = reloadHistoryAfterTmdbMatch(); - if (name) mHistory.setVodName(historyVodName(item)); + if (name) mHistory.setVodName(item.getName()); if (name && !TextUtils.equals(mBinding.name.getText(), item.getName())) { mBinding.name.setText(item.getName()); } @@ -6509,9 +4791,7 @@ private boolean runtimeSourceOnly; tmdbHistoryResumePending = true; } if (!targetFlag.isSelected() || !targetEpisode.isSelected()) { - mProgrammaticFlagSwitch = true; onItemClick(targetFlag); - mProgrammaticFlagSwitch = false; return; } @@ -6669,15 +4949,6 @@ private boolean runtimeSourceOnly; return getHistoryKey(); } - // C101: creating PlayerManager + ExoPlayer inside PlaybackService.onCreate is a - // multi-hundred-millisecond main-thread stall that freezes the loading spinner. - // PlaybackActivity already ships this gate; waiting for the first frame lets the - // spinner keep animating while the service is built. - @Override - protected boolean deferPlaybackServiceBinding() { - return true; - } - @Override protected void onPrepare() { android.util.Log.d("VideoActivity", "onPrepare: setting Clock callback"); @@ -6714,11 +4985,6 @@ private boolean runtimeSourceOnly; refreshControlDialog(); } - @Override - protected void onSubtitleSelected(Sub sub) { - if (SubtitleRestoreCoordinator.remember(mHistory, sub)) syncHistory(); - } - @Override protected void onTitlesChanged() { setTitleVisible(); @@ -6727,33 +4993,21 @@ private boolean runtimeSourceOnly; @Override protected void onError(String msg) { - invalidateShortDramaQueue("error"); recordPlayHealth(false, msg); subtitlePlaybackSession.stop(this); Track.delete(player().getKey()); mClock.setCallback(null); clearLyrics(); clearKaraokeState(); - mPlaybackRequestActive = false; - mPlaybackPlayerStarted = false; - // 起播失败:清掉一次性起播意图与已起播标记,否则之后每次取流都会被当成「用户主动起播」。 - mExplicitPlaybackRequest = false; - mManualPlaybackStarted = false; - updateVideoCover(); player().resetTrack(); player().reset(); player().stop(); - // Automatic line fallback continues in the same failed playback session. - // Keep the remembered kernel, but recreate its engine so the next line cannot - // inherit a decoder/Surface failure that audio-only playback can survive. - applyHistoryPlayerKernel(true); showError(msg); startFlow(); } @Override protected void onReload(String msg) { - recordPlayHealth(false, msg); if (PlayerManager.RELOAD_LUT_WARMUP.equals(msg)) { if (SpiderDebug.isEnabled()) SpiderDebug.log("lut-ui", "auto refresh after lut warmup playback failure key=%s episode=%s", getKey(), getEpisode() == null ? null : getEpisode().getName()); onRefresh(); @@ -6769,11 +5023,6 @@ private boolean runtimeSourceOnly; if (result != null) setPlayer(result); } - @Override - protected void onFirstFrameRendered() { - recordPlayHealth(true, ""); - } - @Override protected void onStateChanged(int state) { switch (state) { @@ -6781,14 +5030,9 @@ private boolean runtimeSourceOnly; showProgress(); break; case Player.STATE_READY: - if (mPlaybackRequestActive && !mPlaybackPlayerStarted) break; - mPlaybackRequestActive = false; - mPlaybackPlayerStarted = false; mKaraokeResultShown = false; - // seek 窗口未关时不能因为一次 READY 读数就收圈:引擎在 seek 真正生效前 - // 仍可能宣称 READY,那正是「拖拽后先卡画面」的那一帧。圈已由 onSeekStarted - // 亮起,这里不收它就仍在屏上,由最小可见计时器与网速 ticker 收尾。 - if (canHideSeekProgress()) showPlaybackContent(); + recordPlayHealth(true, ""); + showPlaybackContent(); boolean pendingResumeSeekApplied = applyPendingResumeSeek(); refreshLyrics(); player().reset(); @@ -6796,38 +5040,14 @@ private boolean runtimeSourceOnly; requestIntroSkipPlan(); if (!pendingResumeSeekApplied) applyAutoIntroSkip(); setAdFeedbackVisible(); // 播放地址确定后按格式刷新"有广告"按钮 - if (!mShortDramaQueueSessionActive) beginShortDramaQueue(); - updateVideoCover(); + recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线 break; case Player.STATE_ENDED: - if (mShortDramaQueue.shouldSuppressLegacyAutoAdvance()) { - scheduleShortDramaEndedFallback(); - break; - } checkEnded(true); break; } } - @Override - public void onMediaItemTransition(@Nullable MediaItem mediaItem, int reason) { - super.onMediaItemTransition(mediaItem, reason); - onShortDramaMediaItemTransition(mediaItem, reason); - } - - @Override - protected void onPlayerPositionDiscontinuity(Player.PositionInfo oldPosition, - Player.PositionInfo newPosition, - int reason) { - if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION - && mShortDramaQueue.shouldSuppressLegacyAutoAdvance()) { - mShortDramaTransitionPositionMs = oldPosition == null - ? C.TIME_UNSET : Math.max(0L, oldPosition.positionMs); - mShortDramaTransitionDurationMs = player() == null - ? C.TIME_UNSET : player().getDuration(); - } - } - @Override protected void onPlayingChanged(boolean isPlaying) { syncKaraokePosition(); @@ -6851,69 +5071,16 @@ private boolean runtimeSourceOnly; applyResizeMode(getScale()); } - /** - * 判断当前能否收掉 seek 期间挂起的加载圈。 - * - *

拖拽进度时 {@code onSeekStarted()} 先亮圈,随后才把 seek 交给控制器。这中间引擎仍 - * 报告 READY,于是挂在网速 ticker 上的兜底收口会在 seek 真正生效前把圈收掉——现象就是 - * 「拖拽后大概率看不到加载中/网速」。窗口打开期间必须先满过最小可见时长,且播放器确实已 - * READY 且不在真实加载阻塞中,才允许收口。窗口没关时圈就一直留着,由每秒一跳的网速 - * ticker 继续重判,不会自己冒出重试循环。 - */ - private boolean canHideSeekProgress() { - if (!mSeekProgressPending) return true; - if (SystemClock.elapsedRealtime() - mSeekProgressStartedAtMs < SEEK_PROGRESS_MIN_VISIBLE_MS) return false; - return service() != null - && player() != null - && !player().isReleased() - && !player().isEmpty() - && player().getPlaybackState() == Player.STATE_READY - && (!player().isLoading() || player().isPlaying()); - } - private void hideSeekProgressIfReady() { - if (!mSeekProgressPending) return; - if (service() == null || player() == null || player().isReleased() || player().isEmpty()) return; - if (!isOwner()) return; - if (!canHideSeekProgress()) return; - mSeekProgressPending = false; + if (service() == null || player() == null || player().getPlaybackState() != Player.STATE_READY) return; showPlaybackContent(); } - /** - * 加载圈的兜底收口。 - * - *

正常路径在 {@code STATE_READY} 分支收圈;这里保留 owner 校验,避免旧会话的播放器 - * 状态收掉当前条目的加载态。控制器晚绑定导致正常 READY 回调已经错过时,由 - * {@link #onControllerReadyReconciled()} 直接补发收口。要求 {@code !isEmpty()},避免详情 - * 尚未加载完(播放器还空着)时把详情页自己的加载态误收。 - * - *

挂在 mR3(网速刷新,圈可见时每秒一跳)上,圈不可见时该循环本就已停,无额外开销。 - */ - private void hidePlaybackProgressIfStale() { - if (mBinding.progress.getRoot().getVisibility() != View.VISIBLE) return; - if (mPlaybackRequestActive && !mPlaybackPlayerStarted) return; - if (service() == null || player() == null || player().isReleased() || player().isEmpty()) return; - if (!isOwner()) return; - if (!canHideSeekProgress()) return; - if (player().getPlaybackState() != Player.STATE_READY) return; - showPlaybackContent(); - } - - @Override - protected void onControllerReadyReconciled() { - if (canHideSeekProgress()) showPlaybackContent(); - } - @Override protected void onSeekStarted() { - App.removeCallbacks(mSeekProgressFallback); - // 窗口必须在 showProgress() 之前打开:它内部会立刻投递网速 ticker, - // 那个 ticker 的兜底收口会读到尚未开始 seek 的 READY。 - mSeekProgressPending = true; - mSeekProgressStartedAtMs = SystemClock.elapsedRealtime(); showProgress(); - App.post(mSeekProgressFallback, SEEK_PROGRESS_MIN_VISIBLE_MS); + App.removeCallbacks(mSeekProgressFallback); + App.post(mSeekProgressFallback, 500); } @Override @@ -6932,10 +5099,6 @@ private boolean runtimeSourceOnly; if (!isOwner() || mHistory == null) return; long position, duration; mHistory.setCreateTime(time); - if (hasDiscNavigationTimeline()) { - if (canSavePlaybackHistory(mHistory) && mHistory.canSync()) syncHistory(); - return; - } updatePlaybackHistoryPosition(); syncKaraokePosition(); if (mLyrics != null) mLyrics.update(player()); @@ -6953,7 +5116,6 @@ private boolean runtimeSourceOnly; private void updatePlaybackHistoryPosition() { if (mHistory == null || tmdbHistoryResumePending) return; - if (hasDiscNavigationTimeline()) return; long position = player().getPosition(); long duration = player().getDuration(); if (position > 0) mHistory.setPosition(position); @@ -7197,7 +5359,6 @@ private boolean runtimeSourceOnly; mBinding.tmdbPersonalTmdbRecommendations.setNextFocusDownId(hasDouban ? R.id.tmdbPersonalDoubanRecommendations : hasAi ? R.id.tmdbPersonalAiRecommendations : R.id.flag); mBinding.tmdbPersonalDoubanRecommendations.setNextFocusDownId(hasAi ? R.id.tmdbPersonalAiRecommendations : R.id.flag); mBinding.tmdbPersonalAiRecommendations.setNextFocusDownId(R.id.flag); - applyTmdbRowFocusChain(); } private void attachRecommendationLazyLoader(HorizontalGridView grid, RecommendationRow row) { @@ -7302,7 +5463,6 @@ private boolean runtimeSourceOnly; if (!changed) return; bindRecommendationGrid(mBinding.tmdbPersonalTmdbRecommendations, mBinding.tmdbPersonalTmdbRecommendationsLabel, mTmdbUIAdapter.getPersonalTmdbRecommendations(), RecommendationRow.PERSONAL_TMDB); bindRecommendationGrid(mBinding.tmdbPersonalDoubanRecommendations, mBinding.tmdbPersonalDoubanRecommendationsLabel, mTmdbUIAdapter.getPersonalDoubanRecommendations(), RecommendationRow.PERSONAL_DOUBAN); - applyTmdbRowFocusChain(); }); } else { loadNativePersonalRecommendations(mVod); @@ -7318,7 +5478,6 @@ private boolean runtimeSourceOnly; mBinding.tmdbPersonalTmdbRecommendations.setNextFocusDownId(hasAi ? R.id.tmdbPersonalAiRecommendations : R.id.flag); } mBinding.tmdbPersonalAiRecommendations.setNextFocusDownId(R.id.flag); - applyTmdbRowFocusChain(); } // 细粒度刷新:相关推荐(“猜你喜欢”)异步到达时只重绑该列表,不触碰详情头部与集数, @@ -7326,14 +5485,12 @@ private boolean runtimeSourceOnly; private void refreshTmdbRecommendations() { if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isLoaded() || mTmdbDetailLoading) return; bindRecommendationGrid(mBinding.tmdbRecommendations, mBinding.tmdbRecommendationsLabel, mTmdbUIAdapter.getRecommendations(), RecommendationRow.RECOMMENDATIONS); - applyTmdbRowFocusChain(); } // 细粒度刷新:个性化推荐(TMDB / 豆瓣)异步到达时只重绑这两个列表。 private void refreshTmdbRelatedVideos() { if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isLoaded() || mTmdbDetailLoading) return; bindTmdbVideoGrid(mBinding.tmdbRelatedVideos, mBinding.tmdbRelatedVideosLabel, mTmdbUIAdapter.getRelatedVideos()); - applyTmdbRowFocusChain(); updateFocus(); } @@ -7353,7 +5510,6 @@ private boolean runtimeSourceOnly; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isLoaded() || mTmdbDetailLoading) return; bindRecommendationGrid(mBinding.tmdbPersonalTmdbRecommendations, mBinding.tmdbPersonalTmdbRecommendationsLabel, mTmdbUIAdapter.getPersonalTmdbRecommendations(), RecommendationRow.PERSONAL_TMDB); bindRecommendationGrid(mBinding.tmdbPersonalDoubanRecommendations, mBinding.tmdbPersonalDoubanRecommendationsLabel, mTmdbUIAdapter.getPersonalDoubanRecommendations(), RecommendationRow.PERSONAL_DOUBAN); - applyTmdbRowFocusChain(); } // 细粒度刷新:集数标题异步补全。useTmdbCard 由 shouldUseTmdbEpisodeCards(hasTmdbEpisodeData) @@ -7421,7 +5577,6 @@ private boolean runtimeSourceOnly; mBinding.shortDisplay.setNextFocusDownId(target); mBinding.searchDetail.setNextFocusDownId(target); mBinding.keep.setNextFocusDownId(target); - mBinding.following.setNextFocusDownId(target); mBinding.change1.setNextFocusDownId(target); mBinding.tmdbRematch.setNextFocusDownId(target); } @@ -7587,7 +5742,6 @@ private boolean runtimeSourceOnly; } SpiderDebug.log("tmdb-tv", "绑定完成: 演员=%d 剧照=%d 海报=%d 主创=%d 推荐=%d 个性TMDB=%d 个性豆瓣=%d 个性智能=%d", cast.size(), photos.size(), posters.size(), creators.size(), recommendations.size(), personalTmdbRecommendations.size(), personalDoubanRecommendations.size(), personalAiRecommendations.size()); - applyTmdbRowFocusChain(); updateFocus(); // TMDB / OMDB 多来源评分(TMDB / IMDb / 烂番茄 / Metacritic 等) @@ -7666,10 +5820,6 @@ private boolean runtimeSourceOnly; hideTmdbRatingChips(label, container); return; } - if (runtimeSourceOnly) { - renderTmdbRatingChips(label, container, buildTmdbRatingChips()); - return; - } TmdbItem ratingItem = mTmdbUIAdapter.getTmdbItem(); String ratingContextKey = ratingItem == null ? "" : ratingItem.getMediaType() + "|" + ratingItem.getTmdbId(); @@ -7750,7 +5900,6 @@ private boolean runtimeSourceOnly; } private void showManualTmdbSeasonDialog() { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isLoaded() || mTmdbUIAdapter.getTmdbItem() == null || !mTmdbUIAdapter.getTmdbItem().isTv()) { Notify.show(R.string.detail_tmdb_empty); return; @@ -7906,7 +6055,6 @@ private boolean runtimeSourceOnly; } } private void showManualTmdbMatchDialog() { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isReady()) { Notify.show(R.string.detail_tmdb_need_key); return; @@ -7949,7 +6097,6 @@ private boolean runtimeSourceOnly; } private void searchTmdb(String keyword, TmdbSearchDialog dialog) { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isReady()) return; dialog.loading(); int generation = ++mTmdbDialogGeneration; @@ -7971,7 +6118,6 @@ private boolean runtimeSourceOnly; } private void applyManualTmdb(TmdbItem item) { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || mVod == null || item == null) return; mTmdbDialogGeneration++; // 手动重新匹配是从已揭示的详情页触发的,细粒度事件(VOD_CORE/推荐/个性/集数标题) @@ -8030,10 +6176,6 @@ private boolean runtimeSourceOnly; container.setVisibility(View.GONE); container.setTag(null); container.removeAllViews(); - updateRatingChipFocus(); - // 评分行是本行的邻居,它的显隐变化必须重算行链: - // 否则第一行 TMDB 区块的 up 目标会停留在已隐藏的评分行上。 - applyTmdbRowFocusChain(); } private String omdbRatingCacheKey(String imdbId, String omdbApiKey) { @@ -8046,8 +6188,6 @@ private boolean runtimeSourceOnly; if (chips == null || chips.isEmpty()) { if (label != null) label.setVisibility(View.GONE); container.setVisibility(View.GONE); - updateRatingChipFocus(); - applyTmdbRowFocusChain(); return; } for (String[] chip : chips) { @@ -8055,10 +6195,6 @@ private boolean runtimeSourceOnly; } if (label != null) label.setVisibility(View.VISIBLE); container.setVisibility(View.VISIBLE); - // 卡片是异步到达的(OMDB 回包),到达后必须重算焦点链, - // 否则选集下方仍然指向旧的下一行。 - updateRatingChipFocus(); - applyTmdbRowFocusChain(); } private java.util.List buildTmdbRatingChips() { @@ -8259,12 +6395,12 @@ private boolean runtimeSourceOnly; chip.setGravity(android.view.Gravity.CENTER); chip.setMinimumWidth(ResUtil.dp2px(120)); chip.setPadding(ResUtil.dp2px(16), ResUtil.dp2px(10), ResUtil.dp2px(16), ResUtil.dp2px(10)); - // 评分卡片必须可被遥控选中:此前是纯装饰 LinearLayout,上下键会直接跳过整行 - // “评分与数据”,视觉上像凭空跳了一段。 - chip.setFocusable(true); - chip.setFocusableInTouchMode(false); - chip.setClickable(true); - chip.setTag(R.id.tmdbOmdbRatings, platform); + + android.graphics.drawable.GradientDrawable background = new android.graphics.drawable.GradientDrawable(); + background.setColor(0x6610141A); + background.setCornerRadius(ResUtil.dp2px(8)); + background.setStroke(ResUtil.dp2px(1), 0x33FFFFFF); + chip.setBackground(background); TextView platformView = new TextView(this); platformView.setText(platform); @@ -8296,33 +6432,9 @@ private boolean runtimeSourceOnly; ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.setMarginEnd(ResUtil.dp2px(12)); chip.setLayoutParams(params); - styleRatingChipBackground(chip, false); - chip.setOnFocusChangeListener((view, focused) -> styleRatingChipBackground(view, focused)); return chip; } - /** - * 评分卡片背景。常态保留原有深色玻璃底(0x6610141A)+ 1dp 半透明描边, - * 让白/黄文字在明亮剧照上依旧可读;只在获得焦点时换成统一的 - * 3dp @color/tv_item_focus_ring 圆环,与选集/线路/推荐卡保持同一种“停下”视觉。 - */ - private void styleRatingChipBackground(View view, boolean focused) { - if (view == null) return; - android.graphics.drawable.GradientDrawable background = new android.graphics.drawable.GradientDrawable(); - background.setColor(0x6610141A); - background.setCornerRadius(ResUtil.dp2px(8)); - if (focused) { - background.setStroke(ResUtil.dp2px(TV_ITEM_FOCUS_WIDTH_DP), getColor(R.color.tv_item_focus_ring)); - } else { - background.setStroke(ResUtil.dp2px(1), 0x33FFFFFF); - } - view.setBackground(background); - View label = mBinding.getRoot().findViewById(R.id.tmdbOmdbRatingsLabel); - if (label instanceof TextView text) { - text.setTextColor(focused ? getColor(R.color.tv_item_focus_ring) : getColor(android.R.color.white)); - } - } - private void setupBackdropSlideshow(java.util.List photos) { if (photos == null || photos.isEmpty()) { // 无可用 TMDB 背景图:清除幻灯片激活标志并隐藏 pager,恢复 contextWall 作为后备背景。 @@ -8434,31 +6546,16 @@ private boolean runtimeSourceOnly; private void requestIntroSkipPlan() { if (!Setting.isIntroSkipEnabled() || player() == null) { mIntroSkipPlayback.reset(); - setOpeningEndingText(); return; } IntroSkipService.Query query = buildIntroSkipQuery(); - // 切集后 plan 已被 reset,这里先刷一次,避免 query 拿不到时残留上一集的探测值 - setOpeningEndingText(); if (query == null) return; - mIntroSkipPlayback.request(query, this::onIntroSkipPlanLoaded); - } - - private void onIntroSkipPlanLoaded() { - if (isFinishing() || isDestroyed() || player() == null || player().isReleased() || !isOwner()) return; - // 查询可能在 EXO 首次 prepare/恢复定位期间提前返回。此时 seek 会与起播定位、 - // 去广告或播放器内部准备竞争,造成重复加载,严重时停在黑屏。统一等 READY 后再应用。 - if (player().getPlaybackState() != Player.STATE_READY) return; - setOpeningEndingText(); - applyAutoIntroSkip(); - preloadAdjacentIntroSkipPlans(); + mIntroSkipPlayback.request(query, this::applyAutoIntroSkip); } private boolean applyAutoIntroSkip() { - if (!Setting.isIntroSkipEnabled() || isFinishing() || isDestroyed() - || player() == null || player().isReleased() || !isOwner()) return false; - // notify=true:片尾无处可跳(末集/电影)时至少要有提示,不能静默无反应 - return mIntroSkipPlayback.apply(player(), () -> advanceEpisode(true)); + if (!Setting.isIntroSkipEnabled() || player() == null) return false; + return mIntroSkipPlayback.apply(player(), () -> checkEnded(false)); } private IntroSkipService.Query buildIntroSkipQuery() { @@ -8477,30 +6574,6 @@ private boolean runtimeSourceOnly; return new IntroSkipService.Query(item.getTmdbId(), getIntroSkipImdbId(), item.getMediaType(), season, number, duration); } - /** - * 预热前后各一集。查询不需要时长(IntroDB 不收,TheIntroDB 可选),所以这里传 0 即可; - * 缓存只按剧集身份存原始段,等那一集真开播时按其实际时长折算,不会再走网络。 - */ - private void preloadAdjacentIntroSkipPlans() { - if (!Setting.isIntroSkipEnabled()) return; - TmdbItem item = getIntroSkipTmdbItem(); - if (item == null || item.getTmdbId() <= 0 || !item.isTv()) return; - String imdbId = getIntroSkipImdbId(); - Episode current = getEpisode(); - for (int offset : new int[]{1, -1}) { - Episode neighbour = getAdjacentEpisode(offset); - // getAdjacentEpisode 越界时会夹回当前集,那样预热就是给本集重发一次请求 - if (neighbour == null || neighbour == current) continue; - TmdbEpisode tmdbEpisode = neighbour.getTmdbEpisode(); - if (tmdbEpisode == null) continue; - int season = tmdbEpisode.getSeasonNumber(); - int number = tmdbEpisode.getNumber(); - if (season < 0 || number <= 0) continue; - IntroSkipService.Query query = new IntroSkipService.Query(item.getTmdbId(), imdbId, item.getMediaType(), season, number, 0); - mIntroSkipPlayback.preload(query); - } - } - private TmdbItem getIntroSkipTmdbItem() { TmdbItem item = mTmdbUIAdapter == null ? null : mTmdbUIAdapter.getTmdbItem(); return item == null ? getTmdbItem() : item; @@ -8516,10 +6589,8 @@ private boolean runtimeSourceOnly; @Subscribe(threadMode = ThreadMode.MAIN) public void onConfigEvent(ConfigEvent event) { - if (isRedirect() || !event.isVod()) return; - if (mTmdbUIAdapter != null) mTmdbUIAdapter.invalidateSubscription(); - if (mParseAdapter != null) mParseAdapter.addAll(VodConfig.get().getParses()); - if (player() != null) player().dropStaleBundleDanmaku(); + if (isRedirect() || !event.isVod() || mParseAdapter == null) return; + mParseAdapter.addAll(VodConfig.get().getParses()); } /** @@ -8546,14 +6617,13 @@ private boolean runtimeSourceOnly; } private void setPosition() { - if (mHistory == null || hasDiscMenu()) return; - long position = resolveInitialPlaybackPosition(); - if (position <= 0) return; - if (mInitialPlaybackPosition == position) { - SpiderDebug.log("video-flow", "skip duplicate restore seek position=%d key=%s", position, getHistoryKey()); + pendingResumeSeekMs = C.TIME_UNSET; + if (mHistory == null) { + tmdbHistoryResumePending = false; mInitialPlaybackPosition = C.TIME_UNSET; return; } + long position = resolveInitialPlaybackPosition(); if (position == C.TIME_UNSET || position <= 0) { tmdbHistoryResumePending = false; mInitialPlaybackPosition = C.TIME_UNSET; @@ -8741,9 +6811,7 @@ private boolean runtimeSourceOnly; private void nextFlag(int position) { Flag flag = mFlagAdapter.get(position + 1); Notify.show(getString(R.string.play_switch_flag, flag.getFlag())); - mProgrammaticFlagSwitch = true; onItemClick(flag); - mProgrammaticFlagSwitch = false; } private void nextSite() { @@ -8795,7 +6863,8 @@ private boolean runtimeSourceOnly; mTmdbUIAdapter = new com.fongmi.android.tv.ui.helper.TmdbUIAdapter(this); if (!mTmdbUIAdapter.isReady()) { - SpiderDebug.log("TMDB 增强等待源内嵌数据或 API Key"); + SpiderDebug.log("TMDB 增强已启用,但配置未就绪(需要 API Key)"); + return; } mTmdbUIAdapter.setPersonalAiUpdateListener(this::refreshTmdbPersonalAiRecommendations); com.fongmi.android.tv.bean.TmdbItem tmdbItem = getTmdbItem(); @@ -8825,10 +6894,6 @@ private boolean runtimeSourceOnly; @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); - if (!hasFocus) { - cancelTvTouch(); - mKeyDown.releaseSpeed(); - } if (!hasFocus || mDialogReturnFocus == null) return; View target = mDialogReturnFocus; mDialogReturnFocus = null; @@ -8871,14 +6936,8 @@ private boolean runtimeSourceOnly; return mFocus1 == null || mFocus1.getVisibility() != View.VISIBLE ? mBinding.video : mFocus1; } - private boolean hasRememberedFocus() { - return mFocus2 != null && mFocus2.getVisibility() == View.VISIBLE - && PlayerControlFocusHelper.isDescendant(mBinding.control.getRoot(), mFocus2) - && mFocus2 != mBinding.control.action.opening && mFocus2 != mBinding.control.action.ending; - } - private View getFocus2() { - return hasRememberedFocus() ? mFocus2 : mBinding.control.action.next; + return mFocus2 == null || mFocus2.getVisibility() != View.VISIBLE || !PlayerControlFocusHelper.isDescendant(mBinding.control.getRoot(), mFocus2) || mFocus2 == mBinding.control.action.opening || mFocus2 == mBinding.control.action.ending ? mBinding.control.action.next : mFocus2; } private boolean dispatchOpeningEndingAdjust(KeyEvent event) { @@ -8911,55 +6970,17 @@ private boolean runtimeSourceOnly; return false; } - /** - * 「倍速」按钮的上下微调。 - * - *

控制栏样式只给了 DOWN 方向的邻居({@code Control.nextFocusDown=timeBar}),UP 方向在 - * 控制栏内没有可聚焦目标,{@link PlayerControlFocusHelper#handleKey} 因此会把 UP 判成 - * 「会把焦点甩出控制栏」并吞掉,事件根本到不了 {@code speed.setUpListener()}。 - * 这里在焦点恰为倍速按钮时先接管,与片头/片尾的写法保持一致。 - */ - private boolean dispatchSpeedAdjust(KeyEvent event) { - if (!KeyUtil.isActionDown(event) || !isVisible(mBinding.control.getRoot())) return false; - if (getCurrentFocus() != mBinding.control.action.speed) return false; - if (KeyUtil.isUpKey(event)) { - onSpeedAdd(); - return true; - } else if (KeyUtil.isDownKey(event)) { - onSpeedSub(); - return true; - } - return false; - } - - @Override - protected boolean dispatchDiscMenuKey(KeyEvent event) { - // Let TV controls own DPAD/ENTER while visible, including the shared - // PlaybackActivity dispatch performed by super.dispatchKeyEvent(). - if (isVisible(mBinding.control.getRoot()) || isVisible(mBinding.lutQuick)) return false; - return super.dispatchDiscMenuKey(event); - } - @Override public boolean dispatchKeyEvent(KeyEvent event) { - if (dispatchDiscMenuKey(event)) return true; if (KeyUtil.isActionUp(event) && KeyUtil.isBackKey(event) && mBinding.lutQuick.hideIfVisible()) return true; - if (mKeyDown.isChangingSpeed() && KeyUtil.isActionUp(event) && KeyUtil.isUpKey(event)) { - mKeyDown.releaseSpeed(); - if (isVisible(mBinding.lutQuick)) return dispatchLutQuickKey(event); - return true; - } if (isVisible(mBinding.lutQuick)) return dispatchLutQuickKey(event); if (isFullscreen() && KeyUtil.isMenuKey(event)) { - int menuKey = Setting.getFullscreenMenuKey(); - if (menuKey == Setting.FULLSCREEN_MENU_KEY_EPISODES) onEpisodes(); - else if (menuKey == Setting.FULLSCREEN_MENU_KEY_NEXT_EPISODE) onMenuNextEpisode(); + if (Setting.getFullscreenMenuKey() == 1) onEpisodes(); else onToggle(); } if (isVisible(mBinding.control.getRoot())) { setR1Callback(); if (dispatchOpeningEndingAdjust(event)) return true; - if (dispatchSpeedAdjust(event)) return true; if (PlayerControlFocusHelper.handleKey(mBinding.control.getRoot(), getFocus2(), event)) return true; if (PlayerControlFocusHelper.containsFocus(mBinding.control.getRoot())) mFocus2 = getCurrentFocus(); } @@ -9106,7 +7127,7 @@ private boolean runtimeSourceOnly; @Override public void onSeeking(long time) { mBinding.widget.center.setVisibility(View.VISIBLE); - mBinding.widget.duration.setText(getTimelineDurationTime()); + mBinding.widget.duration.setText(player().getDurationTime()); mBinding.widget.position.setText(player().getPositionTime(time)); mBinding.widget.action.setImageResource(time > 0 ? R.drawable.ic_widget_forward : R.drawable.ic_widget_rewind); hideProgress(); @@ -9137,37 +7158,13 @@ private boolean runtimeSourceOnly; public void onKeyUp() { long position = player().getPosition(); long duration = player().getDuration(); - if (isEndingShortcutDue(position, duration)) { + if (player().canSetOpening(position, duration)) { + showControl(mBinding.control.action.opening); + } else if (player().canSetEnding(position, duration)) { showControl(mBinding.control.action.ending); - return; + } else { + showControl(getFocus2()); } - if (!hasRememberedFocus()) { - if (player().canSetOpening(position, duration)) { - showControl(mBinding.control.action.opening); - return; - } - if (player().canSetEnding(position, duration)) { - showControl(mBinding.control.action.ending); - return; - } - } - showControl(getFocus2()); - } - - /** - * 「快播完」时按上键的目标就是设片尾,这个入口必须优先于「恢复上次控件」。 - * - *

09-29 的 c7fba76a46 为修「片头区间唤醒被抢焦点」把片头/片尾两个分支一起收进 - * {@link #hasRememberedFocus()};而记忆焦点只在进出全屏时清空,用户只要碰过一次控制栏 - * (按过下键、点过选集/字幕/弹幕等),此后整场播放的片尾快捷入口就永久失效,片头分支 - * 却仍会在片头区间生效,表现为「能跳片头、跳不了片尾」。片头区间保留该收敛语义, - * 片尾区间恢复上游的快捷聚焦。 - * - *

限定「已过中点」才抢焦点:短剧/短片整段都落在两侧可设窗口内,开场阶段按上键 - * 仍应给片头。该条件对长内容恒成立(片尾窗口必定在后半程),不改变原本能命中的场景。 - */ - private boolean isEndingShortcutDue(long position, long duration) { - return position > 0 && duration > 0 && position >= duration / 2 && player().canSetEnding(position, duration); } @Override @@ -9204,138 +7201,6 @@ private boolean runtimeSourceOnly; return false; } - private boolean onVideoTouch(View view, MotionEvent event) { - if (dispatchDiscMenuTouch(event)) return true; - if (!isFullscreen()) return false; - if (!Setting.isTouchOptimized()) return mKeyDown.onTouchEvent(event); - boolean handled = mKeyDown.onTouchEvent(event); - handleTvTouch(event); - return handled || event.getActionMasked() != MotionEvent.ACTION_CANCEL; - } - - private void handleTvTouch(MotionEvent event) { - if (event == null) return; - int action = event.getActionMasked(); - if (action == MotionEvent.ACTION_DOWN) { - mTvTouchAxis = TV_TOUCH_NONE; - mTvTouchMulti = false; - mTvTouchDownX = event.getX(); - mTvTouchDownY = event.getY(); - mTvTouchBright = Util.getBrightness(this); - mTvTouchVolume = mTvAudioManager == null ? 0 : mTvAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC); - mTvTouchSeek = 0; - return; - } - if (action == MotionEvent.ACTION_POINTER_DOWN) { - mTvTouchMulti = true; - mTvTouchAxis = TV_TOUCH_NONE; - hideTvTouchWidgets(); - return; - } - if (mTvTouchMulti) { - if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) resetTvTouch(); - return; - } - if (action == MotionEvent.ACTION_MOVE) { - if (event.getPointerCount() != 1) { - mTvTouchMulti = true; - mTvTouchAxis = TV_TOUCH_NONE; - hideTvTouchWidgets(); - return; - } - float dx = event.getX() - mTvTouchDownX; - float dy = mTvTouchDownY - event.getY(); - if (mTvTouchAxis == TV_TOUCH_NONE) { - float slop = ResUtil.dp2px(20); - if (Math.hypot(dx, dy) < slop) return; - mTvTouchAxis = Math.abs(dx) >= Math.abs(dy) ? TV_TOUCH_HORIZONTAL : TV_TOUCH_VERTICAL; - } - if (mTvTouchAxis == TV_TOUCH_HORIZONTAL) { - mKeyDown.releaseSpeed(); - if (player() == null) return; - mTvTouchSeek = (long) (dx * TV_TOUCH_SEEK_SCALE); - onSeeking(mTvTouchSeek); - } else if (mTvTouchDownX <= Math.max(1, mBinding.video.getWidth()) / 2f) { - mKeyDown.releaseSpeed(); - onBright((int) (applyTvBrightness(dy) * 100)); - } else { - onVolume(applyTvVolume(dy)); - } - return; - } - if (action == MotionEvent.ACTION_UP) { - boolean seeking = mTvTouchAxis == TV_TOUCH_HORIZONTAL; - if (seeking && player() != null) onSeekEnd(mTvTouchSeek); - mKeyDown.releaseSpeed(); - hideTvTouchWidgets(); - if (seeking) hideCenter(); - resetTvTouch(); - } else if (action == MotionEvent.ACTION_CANCEL) { - boolean seeking = mTvTouchAxis == TV_TOUCH_HORIZONTAL; - mKeyDown.releaseSpeed(); - hideTvTouchWidgets(); - if (seeking) hideCenter(); - resetTvTouch(); - } - } - - private float applyTvBrightness(float deltaY) { - int height = Math.max(mBinding.video.getMeasuredHeight(), 1); - float brightness = BrightnessPolicy.scroll(mTvTouchBright, deltaY, height); - WindowManager.LayoutParams attributes = getWindow().getAttributes(); - if (attributes.screenBrightness != brightness) { - attributes.screenBrightness = brightness; - getWindow().setAttributes(attributes); - } - return brightness; - } - - private int applyTvVolume(float deltaY) { - if (mTvAudioManager == null) return 0; - int max = mTvAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC); - if (max <= 0) return 0; - float value = Math.max(0, Math.min(max, mTvTouchVolume + deltaY * 2.0f / Math.max(mBinding.video.getMeasuredHeight(), 1) * max)); - int volume = (int) value; - mTvAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0); - return (int) (volume * 100f / max); - } - - private void resetTvTouch() { - mTvTouchAxis = TV_TOUCH_NONE; - mTvTouchMulti = false; - mTvTouchSeek = 0; - } - - private void cancelTvTouch() { - boolean seeking = mTvTouchAxis == TV_TOUCH_HORIZONTAL; - mKeyDown.releaseSpeed(); - hideTvTouchWidgets(); - if (seeking) hideCenter(); - resetTvTouch(); - } - - private void hideTvTouchWidgets() { - mBinding.widget.bright.setVisibility(View.GONE); - mBinding.widget.volume.setVisibility(View.GONE); - if (isVisible(mBinding.widget.center)) hideCenter(); - } - - public void onBright(int progress) { - mBinding.widget.bright.setVisibility(View.VISIBLE); - mBinding.widget.brightProgress.setProgress(progress); - if (progress < 35) mBinding.widget.brightIcon.setImageResource(R.drawable.ic_widget_bright_low); - else if (progress < 70) mBinding.widget.brightIcon.setImageResource(R.drawable.ic_widget_bright_medium); - else mBinding.widget.brightIcon.setImageResource(R.drawable.ic_widget_bright_high); - } - - public void onVolume(int progress) { - mBinding.widget.volume.setVisibility(View.VISIBLE); - mBinding.widget.volumeProgress.setProgress(progress); - if (progress < 35) mBinding.widget.volumeIcon.setImageResource(R.drawable.ic_widget_volume_low); - else if (progress < 70) mBinding.widget.volumeIcon.setImageResource(R.drawable.ic_widget_volume_medium); - else mBinding.widget.volumeIcon.setImageResource(R.drawable.ic_widget_volume_high); - } - @Override public void onSingleTap() { if (isFullscreen()) onToggle(); @@ -9355,7 +7220,6 @@ private boolean runtimeSourceOnly; @Override protected void onStart() { super.onStart(); - republishPluginEpisodeContext(); mClock.stop().start(); mPlayerUi.onStart(); if (service() != null) refreshLyrics(); @@ -9364,9 +7228,7 @@ private boolean runtimeSourceOnly; @Override protected void onStop() { - cancelTvTouch(); super.onStop(); - mKeyDown.releaseSpeed(); mPlayerUi.onStop(); if (mKaraoke != null) mKaraoke.clear(); stopAudioCoverRotation(); @@ -9404,7 +7266,6 @@ private boolean runtimeSourceOnly; protected void markPlaybackExiting() { if (isPlaybackExiting()) return; super.markPlaybackExiting(); - mIntroSkipPlayback.reset(); cancelAiSeasonAnalysis(false); mPersonalRecommendationGeneration++; mPersonalRecommendationTasks.close(); @@ -9414,20 +7275,8 @@ private boolean runtimeSourceOnly; } @Override protected void onDestroy() { - invalidateShortDramaQueue("destroy"); - cancelPendingEpisodeGridFocus(); - cancelPendingArrayFocus(); - cancelTvTouch(); - mIntroSkipPlayback.reset(); + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); cancelAiSeasonAnalysis(false); - if (mCustomButtonPlayer != null) { - mCustomButtonPlayer.removeCustomButtonStateListener(mCustomButtonStateListener); - mCustomButtonPlayer = null; - } - if (mDiscMenuPlayer != null) { - mDiscMenuPlayer.removeDiscMenuStateListener(mDiscMenuStateListener); - mDiscMenuPlayer = null; - } mLyricsSearchSeq++; mLyricsRefreshSeq++; dismissLyricsResultDialog(); @@ -9450,12 +7299,10 @@ private boolean runtimeSourceOnly; App.removeCallbacks(mTmdbDetailTimeout); App.removeCallbacks(mTmdbEpisodeTimeout); resetPendingTmdbBind(); - if (mViewModel != null) { - mViewModel.getResult().removeObserver(mObserveDetail); - mViewModel.getPlayer().removeObserver(mObservePlayer); - mViewModel.getSearch().removeObserver(mObserveSearch); - mViewModel.getSearchProgress().removeObserver(mObserveSearchProgress); - } + mViewModel.getResult().removeObserver(mObserveDetail); + mViewModel.getPlayer().removeObserver(mObservePlayer); + mViewModel.getSearch().removeObserver(mObserveSearch); + mViewModel.getSearchProgress().removeObserver(mObserveSearchProgress); SiteHealthStore.flush(); super.onDestroy(); } @@ -10999,7 +8846,7 @@ private boolean showKaraokeResultIfNeeded(@Nullable Runnable after) { if (result == null) return false; mKaraokeResultShown = true; KaraokeResultView view = new KaraokeResultView(this).setLeanbackLandscapeExpanded(true).setResult(result); - AlertDialog dialog = new WebHtvAlertDialogBuilder(this, R.style.ThemeOverlay_WebHTV_Dialog).setView(view).create(); + AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_WebHTV_LightDialog).setView(view).create(); view.setAction(() -> { dialog.dismiss(); runAfterKaraokeResult(after); @@ -11052,7 +8899,6 @@ public void onLutSelected(LutPreset preset) { private void setTraffic() { Traffic.setSpeed(mBinding.progress.traffic, service() == null ? null : player()); - hidePlaybackProgressIfStale(); App.post(mR3, 1000); } @@ -11174,7 +9020,6 @@ private void prepareImmersiveAudioPlayback(AudioPlaybackResolver.Resolved resolv playerStartTime = detailStartTime; beginPlayHealth(); mPlaybackEpisodeKey = audioQueueEpisodeKey(episode); - mPlaybackFlagKey = playbackFlagKey(resolved.getFlag()); clearLyrics(); clearKaraokeState(); setAudioOnly(true); @@ -11185,7 +9030,6 @@ private void applyImmersiveAudioSelection(AudioPlaybackResolver.Resolved resolve Episode episode = resolved.getEpisode(); mFlagAdapter.setSelected(flag); mFlagAdapter.toggle(episode); - mPlaybackFlagKey = playbackFlagKey(flag); int position = mFlagAdapter.indexOf(flag); if (position != RecyclerView.NO_POSITION) mBinding.flag.setSelectedPosition(position); notifyItemChanged(mBinding.flag, mFlagAdapter); @@ -11255,9 +9099,6 @@ private void setVideoDetailsVisible(boolean visible) { mBinding.row1.setVisibility(value); mBinding.director.setVisibility(showNativeMetadata && !TextUtils.isEmpty(mBinding.director.getText()) ? View.VISIBLE : View.GONE); mBinding.actor.setVisibility(showNativeMetadata && !TextUtils.isEmpty(mBinding.actor.getText()) ? View.VISIBLE : View.GONE); - // 简介区必须跟着一起收:沉浸音频/纯音频播放时它叠在播放器右侧,不收会在音频面板旁边 - // 留一块详情文本。影视原生此前没有内容所以看不出来,现在它真的有文字了。 - mBinding.tmdbOverview.setVisibility(visible && !TextUtils.isEmpty(mBinding.tmdbOverview.getText()) ? View.VISIBLE : View.GONE); mBinding.row2.setVisibility(value); mBinding.scroll.setVisibility(value); } @@ -12380,7 +10221,6 @@ private void showAudioSheet(BottomSheetDialog dialog, boolean draggable, boolean applyAudioSheetWindowGlass(dialog); hideSystemBarsForAudioSheet(dialog); focusAudioSheetContent(dialog); - syncAudioDialog(dialog); } private void showCompactPlaybackSheet(BottomSheetDialog dialog) { @@ -12427,7 +10267,6 @@ private void showLyricsSearchSheetDialog(BottomSheetDialog dialog) { applyAudioSheetWindowGlass(dialog); hideSystemBarsForAudioSheet(dialog); focusAudioSheetContent(dialog); - syncAudioDialog(dialog); Window window = dialog.getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); @@ -12466,7 +10305,6 @@ private void showAudioDrawerSheet(BottomSheetDialog dialog, boolean atStart) { applyAudioSheetWindowGlass(dialog); hideSystemBarsForAudioSheet(dialog); focusAudioSheetContent(dialog); - syncAudioDialog(dialog); } private void showAudioQueueDrawerDialog(Dialog dialog) { @@ -12491,11 +10329,6 @@ private void showAudioQueueDrawerDialog(Dialog dialog) { window.clearFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND); hideSystemBarsForAudioDialog(dialog); focusAudioQueueSelectedItem(); - syncAudioDialog(dialog); - } - -private void syncAudioDialog(Dialog dialog) { - TouchOptimizationHelper.sync(dialog); } private void focusAudioSheetContent(BottomSheetDialog dialog) { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java index 1734c8040c..bece071062 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java @@ -34,14 +34,11 @@ import org.greenrobot.eventbus.ThreadMode; import java.util.Optional; -public class VodActivity extends BaseActivity implements TypeAdapter.OnClickListener, FolderFragment.FilterHost, FolderFragment.CategoryEdgeHost { +public class VodActivity extends BaseActivity implements TypeAdapter.OnClickListener, FolderFragment.FilterHost { private ActivityVodBinding mBinding; private TypeAdapter mAdapter; private View mOldView; - private boolean mPendingCategoryFocus; - private int focusGeneration; - private Runnable mPendingCategoryFocusRunnable; public static void start(Activity activity, Result result) { start(activity, VodConfig.get().getHome().getKey(), result); @@ -113,33 +110,10 @@ public class VodActivity extends BaseActivity implements TypeAdapter.OnClickList @Override protected void initEvent() { - mBinding.recycler.setOnFocusChangeListener((view, hasFocus) -> { - if (!hasFocus) invalidatePendingFocusRequests(); - }); mBinding.pager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { @Override public void onPageSelected(int position) { mBinding.recycler.setSelectedPosition(position); - if (mPendingCategoryFocus) { - mPendingCategoryFocus = false; - final int generation = focusGeneration; - mPendingCategoryFocusRunnable = () -> { - if (generation != focusGeneration) return; - mPendingCategoryFocusRunnable = null; - if (isFinishing() || isDestroyed() || mBinding.pager.getCurrentItem() != position) return; - // A newly loaded page may have no cards yet; the host must reveal its header. - mBinding.recycler.setVisibility(View.VISIBLE); - mBinding.recycler.requestFocus(); - getFragment().scrollContentToTop(generation); - mBinding.recycler.setSelectedPosition(position, holder -> { - if (generation != focusGeneration || !mBinding.recycler.hasFocus()) return; - if (mBinding.pager.getCurrentItem() == position && mBinding.recycler.getSelectedPosition() == position) holder.itemView.requestFocus(); - }); - }; - mBinding.recycler.post(mPendingCategoryFocusRunnable); - return; - } - invalidatePendingFocusRequests(); mBinding.recycler.requestFocus(); } }); @@ -208,7 +182,6 @@ public class VodActivity extends BaseActivity implements TypeAdapter.OnClickList @Override public void onItemClick(Class item) { - invalidatePendingFocusRequests(); updateFilter(item); } @@ -217,25 +190,6 @@ public class VodActivity extends BaseActivity implements TypeAdapter.OnClickList getFragment().onRefresh(); } - @Override - public void onLongPress(Class item) { - if (item == null || mAdapter.indexOf(item) != mBinding.pager.getCurrentItem()) return; - invalidatePendingFocusRequests(); - FolderFragment fragment = getFragment(); - if (fragment != null) fragment.resetFilter(); - } - - @Override - public void onCategoryContentHorizontalEdge(Class item, int contentRow, boolean towardEnd) { - int position = mAdapter.indexOf(item); - int target = position + (towardEnd ? 1 : -1); - if (position != mBinding.pager.getCurrentItem() || contentRow < 0 || target < 0 || target >= mAdapter.getItemCount()) return; - App.removeCallbacks(mRunnable); - invalidatePendingFocusRequests(); - mPendingCategoryFocus = true; - mBinding.pager.setCurrentItem(target); - } - @Override public boolean dispatchKeyEvent(KeyEvent event) { if (KeyUtil.isMenuKey(event)) updateFilter(); @@ -244,42 +198,17 @@ public class VodActivity extends BaseActivity implements TypeAdapter.OnClickList } private boolean requestContentFocus() { - invalidatePendingFocusRequests(); FolderFragment fragment = getFragment(); return fragment != null && fragment.requestContentFocus(); } - private int invalidatePendingFocusRequests() { - focusGeneration++; - App.removeCallbacks(mRunnable); - if (mBinding != null && mPendingCategoryFocusRunnable != null) { - mBinding.recycler.removeCallbacks(mPendingCategoryFocusRunnable); - } - mPendingCategoryFocusRunnable = null; - mPendingCategoryFocus = false; - return focusGeneration; - } - @Override protected void onBackInvoked() { - invalidatePendingFocusRequests(); if (isFilterVisible()) updateFilter(); else if (getFragment().canBack()) getFragment().goBack(); else super.onBackInvoked(); } - @Override - protected void onPause() { - invalidatePendingFocusRequests(); - super.onPause(); - } - - @Override - protected void onDestroy() { - invalidatePendingFocusRequests(); - super.onDestroy(); - } - class PageAdapter extends FragmentStatePagerAdapter { public PageAdapter(@NonNull FragmentManager fm) { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java index 471822c663..ad1e2ca338 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java @@ -29,7 +29,6 @@ public class AdRuleAdapter extends RecyclerView.Adapter payloads) { - if (!payloads.isEmpty() && payloads.get(payloads.size() - 1) instanceof Boolean enabled) { - RuleItem item = mItems.get(position); - holder.binding.toggle.setChecked(enabled); - holder.binding.toggle.setContentDescription(item.getName()); - if (item.getType() == RuleType.HLS_RULE) { - holder.binding.summary.setText(item.getSummary(holder.itemView.getContext())); - } - return; - } - super.onBindViewHolder(holder, position, payloads); - } - public static class ViewHolder extends RecyclerView.ViewHolder { private final AdapterAdRuleBinding binding; diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/CollectAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/CollectAdapter.java index a8a56681d0..79a2c0ea6d 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/CollectAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/CollectAdapter.java @@ -21,8 +21,6 @@ public class CollectAdapter extends RecyclerView.Adapter mItems; - private int progressCurrent; - private int progressTotal; public CollectAdapter(OnClickListener listener) { this.listener = listener; @@ -33,9 +31,7 @@ public class CollectAdapter extends RecyclerView.Adapter 0 && !mItems.isEmpty()) notifyChanged(0); - } - public int findCollectIndex(String siteKey) { for (int i = 0; i < mItems.size(); i++) { if (mItems.get(i).getSite().getKey().equals(siteKey)) return i; @@ -164,10 +154,7 @@ public class CollectAdapter extends RecyclerView.Adapter= 0 && listener.onCollectKey(adapterPosition, keyCode, event); }); - String name = item.getSite().getDisplayName(); - holder.binding.text.setText("all".equals(item.getSite().getKey()) && progressTotal > 0 - ? name + " " + progressCurrent + "/" + progressTotal - : name); + holder.binding.text.setText(item.getSite().getDisplayName()); holder.binding.text.setSelected(holder.binding.text.hasFocus() || item.isSelected()); holder.binding.text.setOnFocusChangeListener((v, hasFocus) -> holder.binding.text.setSelected(hasFocus || item.isSelected())); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java index e1a798cec5..7b7bf36e38 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java @@ -1,19 +1,14 @@ package com.fongmi.android.tv.ui.adapter; import android.text.TextUtils; -import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; -import android.view.ViewParent; import android.view.ViewGroup; import androidx.annotation.NonNull; -import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.setting.InterfaceOrderStore; import com.fongmi.android.tv.databinding.AdapterConfigBinding; import java.util.List; @@ -23,8 +18,6 @@ public class ConfigAdapter extends RecyclerView.Adapter mItems; private boolean readOnly; - private boolean protectCurrent; - private Config current; public ConfigAdapter(OnClickListener listener) { this.listener = listener; @@ -34,10 +27,6 @@ public class ConfigAdapter extends RecyclerView.Adapter TextUtils.equals(item.getUrl(), currentUrl)); + if (!readOnly && !TextUtils.isEmpty(currentUrl)) mItems.removeIf(item -> TextUtils.equals(item.getUrl(), currentUrl)); return this; } - private boolean isCurrent(Config item) { - if (!protectCurrent || current == null) return false; - if (current.getId() > 0 && item.getId() == current.getId()) return true; - return current.getType() == item.getType() - && !TextUtils.isEmpty(current.getInterfaceKey()) - && TextUtils.equals(current.getInterfaceKey(), item.getInterfaceKey()); - } - - public boolean isProtectedCurrent(Config item) { - return isCurrent(item); - } - public int remove(Config item) { int position = mItems.indexOf(item); if (position == -1) return -1; item.delete(); mItems.remove(position); - if (item.getType() == 0) InterfaceOrderStore.saveVodConfigs(mItems); notifyItemRemoved(position); return getItemCount(); } - public boolean drag(int from, int to) { - if (from < 0 || to < 0 || from >= mItems.size() || to >= mItems.size() || from == to) return false; - Config item = mItems.remove(from); - mItems.add(to, item); - notifyItemMoved(from, to); - if (mItems.size() > 0) InterfaceOrderStore.saveVodConfigs(mItems); - return true; - } - @Override public int getItemCount() { return mItems.size(); @@ -108,120 +69,12 @@ public class ConfigAdapter extends RecyclerView.Adapter { - if (!current) listener.onTextClick(item); - }); - holder.binding.text.setOnLongClickListener(v -> !current && listener.onTextLongClick(holder)); - holder.binding.edit.setVisibility(readOnly ? View.GONE : View.VISIBLE); - bindVerticalFocus(holder.binding.edit, position); - holder.binding.edit.setOnClickListener(v -> listener.onEditClick(item)); + holder.binding.text.setOnClickListener(v -> listener.onTextClick(item)); holder.binding.delete.setVisibility(readOnly ? View.GONE : View.VISIBLE); - holder.binding.delete.setAlpha(current ? 0.38f : 1f); - bindVerticalFocus(holder.binding.delete, position); holder.binding.delete.setOnClickListener(v -> listener.onDeleteClick(item)); } - private void bindVerticalFocus(View view, int position) { - view.setOnKeyListener((source, keyCode, event) -> { - if (event.getAction() != KeyEvent.ACTION_DOWN) return false; - if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) return moveFocus(source, position + 1); - if (keyCode == KeyEvent.KEYCODE_DPAD_UP) return moveFocus(source, position - 1); - return false; - }); - } - - private boolean moveFocus(View source, int position) { - if (position < 0 || position >= getItemCount()) return false; - RecyclerView recycler = findRecycler(source); - if (recycler == null) return false; - requestFocus(recycler, position, source.getId()); - return true; - } - - private void requestFocus(RecyclerView recycler, int position, int targetId) { - FocusRequest focus = new FocusRequest(recycler, position, targetId); - recycler.stopScroll(); - // 目标行可能尚未挂载;监听它实际挂载的时机,避免提前回调无效后焦点停在原行。 - focus.listenForTarget(); - if (recycler.findViewHolderForAdapterPosition(position) == null) { - RecyclerView.LayoutManager layoutManager = recycler.getLayoutManager(); - if (layoutManager instanceof LinearLayoutManager linearLayoutManager) { - linearLayoutManager.scrollToPositionWithOffset(position, recycler.getPaddingTop()); - } else if (layoutManager != null) { - layoutManager.scrollToPosition(position); - } - } - // 已挂载的目标直接处理;未挂载的目标由 FocusRequest 的 attach 回调处理。 - recycler.postOnAnimation(focus::run); - } - - private class FocusRequest implements RecyclerView.OnChildAttachStateChangeListener { - - private final RecyclerView recycler; - private final int position; - private final int targetId; - private boolean listening; - private boolean finished; - - private FocusRequest(RecyclerView recycler, int position, int targetId) { - this.recycler = recycler; - this.position = position; - this.targetId = targetId; - } - - private void listenForTarget() { - recycler.addOnChildAttachStateChangeListener(this); - listening = true; - } - - private void run() { - if (finished) return; - RecyclerView.ViewHolder holder = recycler.findViewHolderForAdapterPosition(position); - if (holder == null) return; - // 上下移动时保持当前列:接口名称到接口名称、修改到修改、删除到删除。 - View target = holder.itemView.findViewById(targetId); - if (target == null || target.getVisibility() != View.VISIBLE || !target.isFocusable()) { - target = holder.itemView.findViewById(R.id.text); - } - if (target != null && target.getVisibility() == View.VISIBLE && target.isFocusable()) { - finish(target); - } else { - finish(holder.itemView); - } - } - - private void finish(View target) { - finished = true; - if (listening) { - recycler.removeOnChildAttachStateChangeListener(this); - listening = false; - } - target.requestFocus(); - } - - @Override - public void onChildViewAttachedToWindow(@NonNull View view) { - if (recycler.getChildAdapterPosition(view) == position) run(); - } - - @Override - public void onChildViewDetachedFromWindow(@NonNull View view) { - } - } - - private RecyclerView findRecycler(View source) { - ViewParent parent = source.getParent(); - while (parent != null && !(parent instanceof RecyclerView)) parent = parent.getParent(); - return parent instanceof RecyclerView ? (RecyclerView) parent : null; - } - public class ViewHolder extends RecyclerView.ViewHolder { private final AdapterConfigBinding binding; diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpisodeAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpisodeAdapter.java index 2bf0a55d86..292a2deb02 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpisodeAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpisodeAdapter.java @@ -315,9 +315,13 @@ public class EpisodeAdapter extends RecyclerView.Adapter applyTextActive(holder, item, hasFocus)); + textView.setOnFocusChangeListener((view, hasFocus) -> { + boolean active = item.isSelected() || hasFocus; + textView.setSelected(active); + if (holder.nativeFileSizeView != null) holder.nativeFileSizeView.setSelected(active); + }); textView.setOnKeyListener(keyListener); textView.setOnClickListener(v -> mListener.onItemClick(item)); if (mLongClickListener != null) { @@ -328,18 +332,6 @@ public class EpisodeAdapter extends RecyclerView.Adapter { public void onBindViewHolder(@NonNull ViewHolder holder, int position) { Flag item = mItems.get(position); holder.binding.text.setText(item.getShow()); - // selected 只是跑马灯开关(selector_video_item 的 state_selected 无描边); - // 「当前播放线路」的绿色 2dp tvCurrentRing 描边走 state_activated, - // 见 selector_video_item.xml 顶部的统一规范注释。 - holder.binding.text.setActivated(item.isSelected()); holder.binding.text.setSelected(item.isSelected()); holder.binding.text.setNextFocusDownId(nextFocusDown); holder.binding.text.setOnKeyListener(keyListener); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/GroupAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/GroupAdapter.java index f1a356b38d..61604ae938 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/GroupAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/GroupAdapter.java @@ -65,7 +65,7 @@ public class GroupAdapter extends RecyclerView.Adapter @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { Group item = mItems.get(position); - holder.binding.name.setText(item.getDisplayName()); + holder.binding.name.setText(item.getName()); holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item)); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java index ce5d034fc2..77308ae285 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java @@ -10,7 +10,6 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.Product; import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.databinding.AdapterVodBinding; -import com.fongmi.android.tv.ui.helper.TvFocusScale; import com.fongmi.android.tv.utils.ImgUtil; import com.fongmi.android.tv.utils.ResUtil; @@ -29,7 +28,7 @@ public class HistoryAdapter extends BaseDiffAdapter listener.onLongClick()); root.setOnClickListener(view -> { - if (isDelete()) listener.onItemDelete(item, holder.getBindingAdapterPosition()); + if (isDelete()) listener.onItemDelete(item); else listener.onItemClick(item); }); } @@ -80,7 +78,7 @@ public class HistoryAdapter extends BaseDiffAdapter { setMarquee(hasFocus); - TvFocusScale.apply(v, hasFocus); - v.setTranslationZ(hasFocus ? 10f : 0f); + if (hasFocus) { + v.animate().scaleX(1.1f).scaleY(1.1f).setDuration(150).start(); + v.setTranslationZ(10f); + } else { + v.animate().scaleX(1f).scaleY(1f).setDuration(150).start(); + v.setTranslationZ(0f); + } }); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeepAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeepAdapter.java index 63d0ebe42a..1e729de0f9 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeepAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeepAdapter.java @@ -10,7 +10,6 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.Product; import com.fongmi.android.tv.bean.Keep; import com.fongmi.android.tv.databinding.AdapterVodBinding; -import com.fongmi.android.tv.ui.helper.TvFocusScale; import com.fongmi.android.tv.utils.ImgUtil; import com.fongmi.android.tv.utils.ResUtil; @@ -95,8 +94,13 @@ public class KeepAdapter extends BaseDiffAdapter { private void setFocusListener() { itemView.setOnFocusChangeListener((v, hasFocus) -> { setMarquee(hasFocus); - TvFocusScale.apply(v, hasFocus); - v.setTranslationZ(hasFocus ? 10f : 0f); + if (hasFocus) { + v.animate().scaleX(1.1f).scaleY(1.1f).setDuration(150).start(); + v.setTranslationZ(10f); + } else { + v.animate().scaleX(1f).scaleY(1f).setDuration(150).start(); + v.setTranslationZ(0f); + } }); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/LiveAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/LiveAdapter.java index ad35c8bdcb..11c991e18d 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/LiveAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/LiveAdapter.java @@ -55,7 +55,7 @@ public class LiveAdapter extends RecyclerView.Adapter { @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { Live item = mItems.get(position); - holder.binding.text.setText(item.getDisplayName()); + holder.binding.text.setText(item.getName()); holder.binding.text.setSelected(item.isSelected()); holder.binding.boot.setImageResource(item.getBootIcon()); holder.binding.pass.setImageResource(item.getPassIcon()); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QuickAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QuickAdapter.java index 84257e1e3e..a3ab173b0c 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QuickAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QuickAdapter.java @@ -1,6 +1,5 @@ package com.fongmi.android.tv.ui.adapter; -import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -23,7 +22,6 @@ public class QuickAdapter extends RecyclerView.Adapter private int width; private int nextFocusUp; private int nextFocusDown; - private String currentSiteKey = ""; public QuickAdapter(OnClickListener listener) { mListener = listener; @@ -56,13 +54,6 @@ public class QuickAdapter extends RecyclerView.Adapter notifyDataSetChanged(); } - public void setCurrentSiteKey(String currentSiteKey) { - String value = currentSiteKey == null ? "" : currentSiteKey; - if (TextUtils.equals(this.currentSiteKey, value)) return; - this.currentSiteKey = value; - notifyDataSetChanged(); - } - public void setWidth(int width) { if (this.width == width) return; this.width = width; @@ -100,7 +91,6 @@ public class QuickAdapter extends RecyclerView.Adapter holder.binding.name.setText(item.getName()); holder.binding.site.setText(item.getSiteName()); holder.binding.remark.setText(item.getRemarks()); - holder.binding.getRoot().setActivated(TextUtils.equals(currentSiteKey, item.getSiteKey())); holder.binding.getRoot().setNextFocusUpId(nextFocusUp == 0 ? View.NO_ID : nextFocusUp); holder.binding.getRoot().setNextFocusDownId(nextFocusDown == 0 ? View.NO_ID : nextFocusDown); holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item)); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java index 83403ad7d6..5c4d8ee6ee 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java @@ -1,6 +1,7 @@ package com.fongmi.android.tv.ui.adapter; import android.content.res.ColorStateList; +import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -15,7 +16,6 @@ import com.fongmi.android.tv.databinding.AdapterSiteSwitchBinding; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.setting.SiteHealthStore; import com.fongmi.android.tv.setting.SiteNameStore; -import com.fongmi.android.tv.ui.helper.SiteDialogTheme; import com.github.catvod.crawler.SpiderDebug; import java.util.ArrayList; @@ -199,12 +199,10 @@ public class SiteAdapter extends RecyclerView.Adapter { super(binding.getRoot()); this.actionBinding = binding; this.switchBinding = null; + binding.text.setGravity(Gravity.CENTER); binding.getRoot().setOnClickListener(v -> click()); binding.getRoot().setOnLongClickListener(v -> longClick()); - binding.getRoot().setOnFocusChangeListener((v, hasFocus) -> { - binding.text.setSelected(hasFocus || isSelected()); - SiteDialogTheme.updateSiteText(binding.text, hasFocus, isSelected()); - }); + binding.getRoot().setOnFocusChangeListener((v, hasFocus) -> binding.text.setSelected(hasFocus || isSelected())); binding.getRoot().setOnKeyListener((v, keyCode, event) -> onKey(keyCode, event)); } @@ -212,12 +210,10 @@ public class SiteAdapter extends RecyclerView.Adapter { super(binding.getRoot()); this.actionBinding = null; this.switchBinding = binding; + binding.text.setGravity(Gravity.CENTER); binding.getRoot().setOnClickListener(v -> click()); binding.getRoot().setOnLongClickListener(v -> longClick()); - binding.getRoot().setOnFocusChangeListener((v, hasFocus) -> { - binding.text.setSelected(hasFocus || isSelected()); - SiteDialogTheme.updateSiteText(binding.text, hasFocus, isSelected()); - }); + binding.getRoot().setOnFocusChangeListener((v, hasFocus) -> binding.text.setSelected(hasFocus || isSelected())); binding.getRoot().setOnKeyListener((v, keyCode, event) -> onKey(keyCode, event)); } @@ -229,15 +225,11 @@ public class SiteAdapter extends RecyclerView.Adapter { actionBinding.check.setChecked(getChecked(item)); actionBinding.text.setSelected(item.isSelected()); actionBinding.getRoot().setSelected(item.isSelected()); - SiteDialogTheme.applySiteItem(actionBinding.getRoot(), actionBinding.text, actionBinding.check); - SiteDialogTheme.updateSiteText(actionBinding.text, actionBinding.getRoot().hasFocus(), item.isSelected()); } else { switchBinding.text.setText(item.getDisplayName()); switchBinding.health.setBackgroundTintList(ColorStateList.valueOf(SiteHealthStore.getColor(item))); switchBinding.text.setSelected(item.isSelected()); switchBinding.getRoot().setSelected(item.isSelected()); - SiteDialogTheme.applySiteItem(switchBinding.getRoot(), switchBinding.text, null); - SiteDialogTheme.updateSiteText(switchBinding.text, switchBinding.getRoot().hasFocus(), item.isSelected()); } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ThemeAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ThemeAdapter.java deleted file mode 100644 index d5645f824f..0000000000 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/ThemeAdapter.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.fongmi.android.tv.ui.adapter; - -import android.graphics.drawable.GradientDrawable; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.databinding.AdapterThemeBinding; -import com.fongmi.android.tv.setting.Setting; -import com.github.bassaer.library.MDColor; - -public class ThemeAdapter extends RecyclerView.Adapter { - - private final OnClickListener listener; - private final int selected; - private final int[] mItems; - - public ThemeAdapter(OnClickListener listener, int[] items, int selected) { - this.listener = listener; - this.selected = selected; - this.mItems = items; - } - - public interface OnClickListener { - - void onItemClick(int color); - } - - @Override - public int getItemCount() { - return mItems.length; - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(AdapterThemeBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - int color = mItems[position]; - holder.binding.circle.setBackground(getCircle(color)); - holder.binding.getRoot().setOnClickListener(v -> listener.onItemClick(color)); - holder.binding.check.setVisibility(selected == color ? View.VISIBLE : View.INVISIBLE); - holder.binding.close.setVisibility(selected != color && color == -1 ? View.VISIBLE : View.GONE); - } - - private GradientDrawable getCircle(int color) { - GradientDrawable circle = new GradientDrawable(); - circle.setShape(GradientDrawable.OVAL); - if (color == 0) circle.setColor(Setting.getWallColor()); - else if (color == -1) circle.setColor(MDColor.GREY_500); - else circle.setColor(color); - return circle; - } - - public class ViewHolder extends RecyclerView.ViewHolder { - - private final AdapterThemeBinding binding; - - ViewHolder(@NonNull AdapterThemeBinding binding) { - super(binding.getRoot()); - this.binding = binding; - } - } -} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/TypeAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/TypeAdapter.java index 3fab011b84..e3688dd556 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/TypeAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/TypeAdapter.java @@ -60,7 +60,6 @@ public class TypeAdapter extends RecyclerView.Adapter { holder.binding.text.setCompoundDrawablePadding(ResUtil.dp2px(4)); holder.binding.text.setCompoundDrawablesWithIntrinsicBounds(0, 0, getIcon(item), 0); holder.binding.text.setListener(() -> mListener.onRefresh(item)); - holder.binding.text.setLongPressListener(() -> mListener.onLongPress(item)); holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item)); } @@ -73,8 +72,6 @@ public class TypeAdapter extends RecyclerView.Adapter { void onItemClick(Class item); void onRefresh(Class item); - - void onLongPress(Class item); } public static class ViewHolder extends RecyclerView.ViewHolder { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java index ac19124f2b..69d47a06e0 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java @@ -8,16 +8,9 @@ import android.content.res.Resources; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; -import android.view.Window; import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.FragmentActivity; import androidx.recyclerview.widget.RecyclerView; @@ -25,25 +18,18 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.Updater; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.server.process.ApkUrlPush; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.ui.custom.CustomWallView; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.Util; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; -import me.jessyan.autosize.AutoSizeConfig; import me.jessyan.autosize.AutoSizeCompat; public abstract class BaseActivity extends AppCompatActivity { - private static final int DESIGN_WIDTH_IN_DP = 960; - private static final int DESIGN_HEIGHT_IN_DP = 540; - protected abstract ViewBinding getBinding(); @Override @@ -54,51 +40,19 @@ public abstract class BaseActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - ThemeController.applyFromPreferences(this); - registerFragmentLifecycleCallbacks(); - // getBinding() inflates on every call, so resolve it once: the root that is set as - // content must be the same instance that initView()/initEvent() configure. - View content = getBinding().getRoot(); - setContentView(content); - bindFullWindowContent(); - ThemeController.bindTheme(content); + setContentView(getBinding().getRoot()); EventBus.getDefault().register(this); initView(savedInstanceState); - ThemeController.bindTheme(content); Util.hideSystemUI(this); setBackCallback(); initEvent(); } - /** - * 让电视端内容区等于整块窗口,避免底部出现整条黑带。 - * - *

背景(实测):模拟器 5554 的屏幕带底部 overscan 90px - * ({@code dumpsys window displays} 里 {@code overscan (0,0,0,90)}),系统会把这条内边距 - * 下发给窗口;AppCompat 的 decor 里有两层都会各自应用一次 fitsSystemWindows 内边距 - * (外层 window content 与内层 {@code android:id/content}),于是内容区被扣掉 2×90=180px。 - * 同一块屏上 launcher 的内容区是 990(只扣一次),本应用实测是 900,卡片标题被齐腰切断、 - * {@code 900..1080} 整条是窗口背景黑。 - * - *

电视端本来就全屏沉浸({@link Util#hideSystemUI}),且各弹层自己留了 overscan 安全边距 - * (见 AboutDialog / UpdateDialog 的注释),所以这里在 DecorView 上装一个"只消费、不加内边距" - * 的监听器,inset 不再下发给任何一层,内容铺满窗口。没有 inset 的电视/盒子上这条路径 - * 与改动前完全一致(insets 为 0 时各层本来就不会加内边距)。 - * - *

弹层是独立窗口,不受影响;软键盘仍是窗口 resize(adjustResize),不依赖这条 inset。 - */ - private void bindFullWindowContent() { - WindowCompat.setDecorFitsSystemWindows(getWindow(), false); - ViewCompat.setOnApplyWindowInsetsListener(getWindow().getDecorView(), (view, insets) -> WindowInsetsCompat.CONSUMED); - View decorContent = findViewById(android.R.id.content); - if (decorContent != null) decorContent.setFitsSystemWindows(false); - } - @Override public void setContentView(View view) { super.setContentView(view); - if (customWall()) addCustomWall(); - TouchOptimizationHelper.sync(getWindow().getDecorView()); + if (!customWall()) return; + addCustomWall(); } private void addCustomWall() { @@ -175,8 +129,6 @@ public abstract class BaseActivity extends AppCompatActivity { private Resources hackResources(Resources resources) { try { - // Keep the TV AutoSize pipeline and express the user scale through its design size. - applyUiScale(); AutoSizeCompat.autoConvertDensityOfGlobal(resources); return resources; } catch (Exception ignored) { @@ -184,16 +136,9 @@ public abstract class BaseActivity extends AppCompatActivity { } } - private void applyUiScale() { - AutoSizeConfig config = AutoSizeConfig.getInstance(); - float factor = Setting.getUiScaleFactor(Setting.getUiScale()); - config.setDesignWidthInDp(Math.round(DESIGN_WIDTH_IN_DP / factor)); - config.setDesignHeightInDp(Math.round(DESIGN_HEIGHT_IN_DP / factor)); - } - @Subscribe(threadMode = ThreadMode.MAIN) public void onSubscribe(Object o) { - if (o instanceof RefreshEvent event && (event.getType() == RefreshEvent.Type.LANGUAGE || event.getType() == RefreshEvent.Type.UI_SCALE || event.getType() == RefreshEvent.Type.THEME)) recreate(); + if (o instanceof RefreshEvent event && event.getType() == RefreshEvent.Type.LANGUAGE) recreate(); } @Override @@ -220,26 +165,7 @@ public abstract class BaseActivity extends AppCompatActivity { @Override protected void onResume() { super.onResume(); - TouchOptimizationHelper.sync(getWindow().getDecorView()); Updater.create().resume(this); - ApkUrlPush.get().resume(this); - } - - private void registerFragmentLifecycleCallbacks() { - getSupportFragmentManager().registerFragmentLifecycleCallbacks(new FragmentManager.FragmentLifecycleCallbacks() { - @Override - public void onFragmentViewCreated(@NonNull FragmentManager fragmentManager, @NonNull Fragment fragment, @NonNull View view, Bundle savedInstanceState) { - TouchOptimizationHelper.sync(view); - } - - @Override - public void onFragmentStarted(@NonNull FragmentManager fragmentManager, @NonNull Fragment fragment) { - if (!(fragment instanceof DialogFragment dialog) || dialog.getDialog() == null) return; - Window window = dialog.getDialog().getWindow(); - if (window == null) return; - window.getDecorView().post(() -> TouchOptimizationHelper.sync(window.getDecorView())); - } - }, true); } @Override diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownVod.java b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownVod.java index 248d3cd925..fd8bd06ccc 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownVod.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomKeyDownVod.java @@ -33,16 +33,6 @@ public class CustomKeyDownVod extends GestureDetector.SimpleOnGestureListener { return detector.onTouchEvent(e); } - public boolean isChangingSpeed() { - return changeSpeed; - } - - public void releaseSpeed() { - if (!changeSpeed) return; - changeSpeed = false; - listener.onSpeedEnd(); - } - public void setFull(boolean full) { this.full = full; } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTypeView.java b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTypeView.java index c2945fedae..5a244e35b4 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTypeView.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTypeView.java @@ -14,17 +14,8 @@ import com.google.android.material.textview.MaterialTextView; public class CustomTypeView extends MaterialTextView { - public static final long LONG_PRESS_MS = 550; - private Listener listener; - private LongPressListener longPressListener; private boolean coolDown; - private boolean longPressFired; - - private final Runnable longPressRunnable = () -> { - longPressFired = true; - if (longPressListener != null) longPressListener.onLongPress(); - }; public CustomTypeView(@NonNull Context context) { super(context); @@ -38,49 +29,14 @@ public class CustomTypeView extends MaterialTextView { this.listener = listener; } - public void setLongPressListener(LongPressListener listener) { - this.longPressListener = listener; - } - private boolean hasEvent(KeyEvent event) { return !coolDown && KeyUtil.isActionDown(event) && KeyUtil.isUpKey(event); } - private boolean hasLongPressEvent(KeyEvent event) { - return longPressListener != null && KeyUtil.isEnterKey(event); - } - @Override public boolean dispatchKeyEvent(KeyEvent event) { if (listener != null && hasEvent(event)) return onKeyDown(); - if (hasLongPressEvent(event)) return onLongPressKey(event); - return super.dispatchKeyEvent(event); - } - - private boolean onLongPressKey(KeyEvent event) { - if (KeyUtil.isActionDown(event)) { - // Key repeats must not restart the timer; only the first press arms it. A fresh press - // also clears any latch left behind by a release this view never received (pause, - // sleep, detach), otherwise that stale latch would swallow the next click. - if (event.getRepeatCount() == 0) { - longPressFired = false; - App.post(longPressRunnable, LONG_PRESS_MS); - } - return super.dispatchKeyEvent(event); - } - if (KeyUtil.isActionUp(event)) { - cancelLongPressTimer(); - boolean consumed = longPressFired; - // The long press already ran, so this release must not deliver a click as well. - if (consumed) longPressFired = false; - else super.dispatchKeyEvent(event); - return consumed; - } - return super.dispatchKeyEvent(event); - } - - private void cancelLongPressTimer() { - App.removeCallbacks(longPressRunnable); + else return super.dispatchKeyEvent(event); } private boolean onKeyDown() { @@ -90,40 +46,15 @@ public class CustomTypeView extends MaterialTextView { return true; } - @Override - public boolean performClick() { - if (longPressFired) { - longPressFired = false; - return true; - } - return super.performClick(); - } - @Override protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { super.onFocusChanged(focused, direction, previouslyFocusedRect); App.post(() -> coolDown = false, 500); if (focused) coolDown = true; - else { - cancelLongPressTimer(); - longPressFired = false; - } - } - - @Override - protected void onDetachedFromWindow() { - cancelLongPressTimer(); - longPressFired = false; - super.onDetachedFromWindow(); } public interface Listener { void onRefresh(); } - - public interface LongPressListener { - - void onLongPress(); - } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/FlagSelectionListener.java b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/FlagSelectionListener.java deleted file mode 100644 index 56b03938b4..0000000000 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/FlagSelectionListener.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.fongmi.android.tv.ui.custom; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.leanback.widget.HorizontalGridView; -import androidx.leanback.widget.OnChildViewHolderSelectedListener; -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.bean.Flag; -import com.fongmi.android.tv.ui.adapter.FlagAdapter; - -public final class FlagSelectionListener extends OnChildViewHolderSelectedListener implements Runnable { - - private final HorizontalGridView view; - private final FlagAdapter adapter; - private final FlagAdapter.OnClickListener listener; - private Flag pendingItem; - private Flag dispatchedItem; - - public FlagSelectionListener(HorizontalGridView view, FlagAdapter adapter, FlagAdapter.OnClickListener listener) { - this.view = view; - this.adapter = adapter; - this.listener = listener; - } - - @Override - public void onChildViewHolderSelected(@NonNull RecyclerView parent, @Nullable RecyclerView.ViewHolder child, int position, int subposition) { - view.removeCallbacks(this); - Flag item = position >= 0 && position < adapter.getItemCount() ? adapter.get(position) : null; - // 只有「选到另一条线路」才结束回声窗口:之后哪怕再选回原来那条,也必须正常派发。 - // 行刷新途中会短暂派发 NO_POSITION(item == null),那不算换成别的线路;若也拿它清窗口, - // 同一条线路的自刷新就能再次进来,回声会重新接上。 - if (item != null && item != dispatchedItem) dispatchedItem = null; - pendingItem = item; - // Leanback may dispatch selection inside layout. The whole route switch can - // refresh this grid through focus changes and seamless episode selection. - if (pendingItem != null) view.post(this); - } - - @Override - public void run() { - if (!view.isAttachedToWindow() || view.getAdapter() != adapter) { - pendingItem = null; - return; - } - if (view.isComputingLayout()) { - view.postOnAnimation(this); - return; - } - Flag item = pendingItem; - pendingItem = null; - int position = view.getSelectedPosition(); - // A newer selection or a refreshed route list makes the queued item stale. - if (item != null && position >= 0 && position < adapter.getItemCount() && adapter.get(position) == item) { - // 选中回声:线路切换自身就会重设选中位(setSelectedPosition / notifyItemsChanged / - // setEpisodeAdapter -> updateFocus -> notifyDataSetChanged),Leanback 于是再派发一次 - // 同一条线路的选中,把 onItemClick 又拉回来,形成 200ms 级自激切换循环 - // (日志表现为 from==to 反复刷屏、选集选中位被反复重置)。 - // 只在「没有选过别的线路」时拦下这一次回声;用户改选另一条线路后 - // dispatchedItem 已被清空,再选回原线路仍会正常派发。 - // 点击(getRoot() 的 OnClickListener)不走这里,永远不会被拦。 - if (item == dispatchedItem) return; - dispatchedItem = item; - listener.onItemClick(item); - } - } -} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java index 7a96c0aa3a..0494960252 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java @@ -3,15 +3,12 @@ package com.fongmi.android.tv.ui.dialog; import android.app.Activity; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; -import android.text.Editable; -import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.Gravity; import android.view.Window; import android.view.WindowManager; -import android.widget.EditText; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; @@ -19,28 +16,18 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.AdBlockStatsStore; -import com.fongmi.android.tv.bean.AdBlockLog; -import com.fongmi.android.tv.bean.AdBlockLogFilter; import com.fongmi.android.tv.bean.AdBlockStats; import com.fongmi.android.tv.bean.RuleHitRecord; -import com.fongmi.android.tv.databinding.AdapterAdBlockLogBinding; import com.fongmi.android.tv.databinding.DialogAdBlockStatsBinding; import com.fongmi.android.tv.impl.Callback; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.utils.AdBlockTimeFormatter; -import com.fongmi.android.tv.widget.AdBlockChartView; -import com.google.android.material.button.MaterialButton; import com.fongmi.android.tv.utils.ResUtil; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Date; import java.util.Comparator; -import java.util.EnumMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; /** @@ -51,13 +38,6 @@ public class AdBlockStatsDialog { private final DialogAdBlockStatsBinding binding; private final AlertDialog dialog; private final Activity activity; - private final EnumMap logFilters = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final EnumMap> logSelections = - new EnumMap<>(AdBlockLogFilter.Column.class); - private List currentLogs = new ArrayList<>(); - private BlockLogAdapter logAdapter; - private boolean logFiltersBound; public static AdBlockStatsDialog create(Activity activity) { return new AdBlockStatsDialog(activity); @@ -66,7 +46,7 @@ public class AdBlockStatsDialog { private AdBlockStatsDialog(Activity activity) { this.activity = activity; this.binding = DialogAdBlockStatsBinding.inflate(LayoutInflater.from(activity)); - this.dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) + this.dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) .setView(binding.getRoot()) .create(); } @@ -76,151 +56,26 @@ public class AdBlockStatsDialog { loadStats(); dialog.show(); configureWindow(); - configureTabFocus(); } private void configureWindow() { Window window = dialog.getWindow(); if (window == null) return; - int horizontalMargin = ResUtil.dp2px(24); - int verticalMargin = ResUtil.dp2px(24); - int width = Math.max(1, DialogSafeArea.width(activity, window) - horizontalMargin * 2); - int height = Math.max(1, DialogSafeArea.height(activity, window) - verticalMargin * 2); + int width = Math.min(Math.round(ResUtil.getScreenWidth(activity) * 0.72f), ResUtil.dp2px(720)); + int height = Math.min(Math.round(ResUtil.getScreenHeight(activity) * 0.82f), ResUtil.dp2px(680)); WindowManager.LayoutParams params = window.getAttributes(); - params.width = width; - params.height = height; + params.width = Math.max(width, ResUtil.dp2px(420)); + params.height = Math.max(height, ResUtil.dp2px(360)); params.gravity = Gravity.CENTER; window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); window.setAttributes(params); window.setLayout(params.width, params.height); - binding.getRoot().setMinimumHeight(height); } private void initView() { binding.reset.setOnClickListener(v -> onReset()); binding.close.setOnClickListener(v -> dialog.dismiss()); - if (binding.statsTabs.getTabCount() == 0) { - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_overview)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_site_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_rule_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_pipeline_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_log)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_chart)); - binding.statsTabs.addOnTabSelectedListener(new com.google.android.material.tabs.TabLayout.OnTabSelectedListener() { - @Override public void onTabSelected(com.google.android.material.tabs.TabLayout.Tab tab) { showPage(tab.getPosition()); } - @Override public void onTabUnselected(com.google.android.material.tabs.TabLayout.Tab tab) {} - @Override public void onTabReselected(com.google.android.material.tabs.TabLayout.Tab tab) {} - }); - } - if (!logFiltersBound) { - bindLogFilters(); - logFiltersBound = true; - } - showPage(binding.statsTabs.getSelectedTabPosition()); - } - - private void configureTabFocus() { - if (binding.statsTabs.getChildCount() == 0) return; - View strip = binding.statsTabs.getChildAt(0); - if (!(strip instanceof ViewGroup tabStrip)) return; - for (int i = 0; i < tabStrip.getChildCount(); i++) { - View tabView = tabStrip.getChildAt(i); - tabView.setFocusable(true); - tabView.setFocusableInTouchMode(true); - tabView.setBackgroundResource(R.drawable.selector_mpv_tab_focus); - tabView.setOnFocusChangeListener((view, hasFocus) -> { - if (!hasFocus) return; - int position = tabStrip.indexOfChild(view); - com.google.android.material.tabs.TabLayout.Tab tab = binding.statsTabs.getTabAt(position); - if (tab != null) tab.select(); - }); - } - int position = binding.statsTabs.getSelectedTabPosition(); - if (position < 0 || position >= tabStrip.getChildCount()) position = 0; - if (tabStrip.getChildCount() > 0) { - View selectedTab = tabStrip.getChildAt(position); - selectedTab.post(selectedTab::requestFocus); - } - } - - private void bindLogFilters() { - bindCategoricalFilter(binding.siteFilter, AdBlockLogFilter.Column.SITE_NAME, R.string.ad_log_site_name); - bindCategoricalFilter(binding.siteDomainFilter, AdBlockLogFilter.Column.SITE_DOMAIN, R.string.ad_log_site_domain); - bindCategoricalFilter(binding.vodFilter, AdBlockLogFilter.Column.VOD_NAME, R.string.ad_log_playback_context); - bindCategoricalFilter(binding.lineFilter, AdBlockLogFilter.Column.LINE_NAME, R.string.ad_log_line_name); - bindCategoricalFilter(binding.episodeFilter, AdBlockLogFilter.Column.EPISODE_NAME, R.string.ad_log_episode_name); - bindCategoricalFilter(binding.ruleFilter, AdBlockLogFilter.Column.RULE, R.string.ad_log_rule_domain); - bindCategoricalFilter(binding.adDomainFilter, AdBlockLogFilter.Column.AD_DOMAIN, R.string.ad_log_ad_domain); - bindCategoricalFilter(binding.pipelineFilter, AdBlockLogFilter.Column.PIPELINE, R.string.ad_log_pipeline); - bindTextFilter(binding.blockedAtFilter, AdBlockLogFilter.Column.BLOCKED_AT); - bindTextFilter(binding.segmentStartFilter, AdBlockLogFilter.Column.SEGMENT_START); - bindTextFilter(binding.segmentEndFilter, AdBlockLogFilter.Column.SEGMENT_END); - bindTextFilter(binding.segmentDurationFilter, AdBlockLogFilter.Column.SEGMENT_DURATION); - } - - private void bindCategoricalFilter(MaterialButton button, AdBlockLogFilter.Column column, int title) { - button.setOnClickListener(v -> showCategoricalFilter(button, column, title)); - updateCategoricalButton(button, column); - } - - private void showCategoricalFilter(MaterialButton button, AdBlockLogFilter.Column column, int title) { - Set selected = logSelections.get(column); - AdBlockLogFilterDialog.show(activity, activity.getString(title), currentLogs, - selected == null ? java.util.Set.of() : new java.util.LinkedHashSet<>(selected), - log -> displayValue(log, column), values -> { - if (values == null || values.isEmpty()) logSelections.remove(column); - else logSelections.put(column, new java.util.LinkedHashSet<>(values)); - updateCategoricalButton(button, column); - if (logAdapter != null) { - logAdapter.setSelection(column, values); - updateLogEmptyState(); - } - }); - } - - private void updateCategoricalButton(MaterialButton button, AdBlockLogFilter.Column column) { - Set selected = logSelections.get(column); - if (selected == null || selected.isEmpty()) { - button.setText(R.string.ad_log_filter_hint); - } else if (selected.size() == 1) { - button.setText(selected.iterator().next()); - } else { - button.setText(activity.getString(R.string.ad_log_filter_selected, selected.size())); - } - button.setEnabled(!currentLogs.isEmpty()); - } - - private void bindTextFilter(EditText input, AdBlockLogFilter.Column column) { - input.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - - @Override - public void afterTextChanged(Editable s) { - String query = s == null ? "" : s.toString(); - if (query.trim().isEmpty()) logFilters.remove(column); - else logFilters.put(column, query); - if (logAdapter != null) { - logAdapter.setFilter(column, query); - updateLogEmptyState(); - } - } - }); - } - - private void showPage(int position) { - binding.overviewPage.setVisibility(position == 0 ? View.VISIBLE : View.GONE); - binding.sitePage.setVisibility(position == 1 ? View.VISIBLE : View.GONE); - binding.rulePage.setVisibility(position == 2 ? View.VISIBLE : View.GONE); - binding.pipelinePage.setVisibility(position == 3 ? View.VISIBLE : View.GONE); - binding.logPage.setVisibility(position == 4 ? View.VISIBLE : View.GONE); - binding.chartPage.setVisibility(position == 5 ? View.VISIBLE : View.GONE); } private void loadStats() { @@ -237,29 +92,18 @@ public class AdBlockStatsDialog { binding.aiSuccessRate.setText("0%"); } - // 站点维度统计概览与排行使用同一份快照 + // 站点拦截排行 List siteRank = buildSiteRank(stats); - binding.siteCount.setText(String.valueOf(stats.getSiteBlocked().size())); - if (siteRank.isEmpty()) { - binding.topSite.setText("-"); - binding.topSiteShare.setText("0%"); - } else { - SiteRankItem top = siteRank.get(0); - binding.topSite.setText(top.getDisplayName()); - double share = stats.getTotalBlocked() > 0 ? top.getCount() * 100.0 / stats.getTotalBlocked() : 0.0; - binding.topSiteShare.setText(String.format(Locale.getDefault(), "%.1f%%", share)); - } - - // 站点、规则和播放链路默认显示汇总项,点击汇总项展开对应的逐条日志。 if (siteRank.isEmpty()) { binding.siteRankEmpty.setVisibility(View.VISIBLE); binding.siteRankRecycler.setVisibility(View.GONE); } else { binding.siteRankEmpty.setVisibility(View.GONE); binding.siteRankRecycler.setVisibility(View.VISIBLE); - binding.siteRankRecycler.setAdapter(new GroupedLogAdapter(siteRank, stats, GroupType.SOURCE)); + binding.siteRankRecycler.setAdapter(new SiteRankAdapter(siteRank)); } + // 规则命中排行 List ruleRank = AdBlockStatsStore.getTopRules(10); if (ruleRank.isEmpty()) { binding.ruleRankEmpty.setVisibility(View.VISIBLE); @@ -267,126 +111,24 @@ public class AdBlockStatsDialog { } else { binding.ruleRankEmpty.setVisibility(View.GONE); binding.ruleRankRecycler.setVisibility(View.VISIBLE); - binding.ruleRankRecycler.setAdapter(new RuleGroupedLogAdapter(ruleRank, stats)); + binding.ruleRankRecycler.setAdapter(new RuleRankAdapter(ruleRank)); } - - List pipelineRank = buildPipelineRank(stats); - if (pipelineRank.isEmpty()) { - binding.pipelineRankEmpty.setVisibility(View.VISIBLE); - binding.pipelineRankRecycler.setVisibility(View.GONE); - } else { - binding.pipelineRankEmpty.setVisibility(View.GONE); - binding.pipelineRankRecycler.setVisibility(View.VISIBLE); - binding.pipelineRankRecycler.setAdapter(new GroupedLogAdapter(pipelineRank, stats, GroupType.PIPELINE)); - } - - List logs = stats.getBlockLogs(); - currentLogs = new ArrayList<>(logs); - updateAllCategoricalButtons(); - logAdapter = new BlockLogAdapter(logs, logFilters, logSelections, this::filterValues); - binding.logRecycler.setAdapter(logAdapter); - updateLogEmptyState(); - - List chartItems = buildSiteRank(stats); - binding.chartEmpty.setVisibility(chartItems.isEmpty() ? View.VISIBLE : View.GONE); - binding.chartView.setVisibility(chartItems.isEmpty() ? View.GONE : View.VISIBLE); - List chartEntries = chartItems.stream() - .map(item -> new AdBlockChartView.Entry(item.getDisplayName(), item.getCount())) - .collect(Collectors.toList()); - binding.chartView.setEntries(chartEntries); - } - - private void updateAllCategoricalButtons() { - updateCategoricalButton(binding.siteFilter, AdBlockLogFilter.Column.SITE_NAME); - updateCategoricalButton(binding.siteDomainFilter, AdBlockLogFilter.Column.SITE_DOMAIN); - updateCategoricalButton(binding.vodFilter, AdBlockLogFilter.Column.VOD_NAME); - updateCategoricalButton(binding.lineFilter, AdBlockLogFilter.Column.LINE_NAME); - updateCategoricalButton(binding.episodeFilter, AdBlockLogFilter.Column.EPISODE_NAME); - updateCategoricalButton(binding.ruleFilter, AdBlockLogFilter.Column.RULE); - updateCategoricalButton(binding.adDomainFilter, AdBlockLogFilter.Column.AD_DOMAIN); - updateCategoricalButton(binding.pipelineFilter, AdBlockLogFilter.Column.PIPELINE); - } - - private Map filterValues(AdBlockLog item) { - EnumMap values = new EnumMap<>(AdBlockLogFilter.Column.class); - for (AdBlockLogFilter.Column column : AdBlockLogFilter.Column.values()) { - values.put(column, displayValue(item, column)); - } - return values; - } - - private String displayValue(AdBlockLog item, AdBlockLogFilter.Column column) { - String unknown = activity.getString(R.string.ad_log_unknown); - switch (column) { - case SITE_NAME: return value(item.getSiteName(), unknown); - case SITE_DOMAIN: return value(item.getSiteDomain(), unknown); - case VOD_NAME: return value(item.getVodName(), unknown); - case LINE_NAME: return value(item.getLineName(), unknown); - case EPISODE_NAME: return value(item.getEpisodeName(), unknown); - case RULE: return value(AdBlockStatsStore.getRuleDisplayName(item.getRuleId()), unknown); - case AD_DOMAIN: return value(item.getAdDomain(), unknown); - case BLOCKED_AT: return item.getBlockedAt() > 0 - ? new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(new Date(item.getBlockedAt())) : unknown; - case SEGMENT_START: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentStartSeconds()) : unknown; - case SEGMENT_END: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentEndSeconds()) : unknown; - case SEGMENT_DURATION: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentDurationSeconds()) : unknown; - case PIPELINE: return value(item.getPipelineName(), unknown); - default: return unknown; - } - } - - private static String value(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; - } - - private void updateLogEmptyState() { - boolean noLogs = logAdapter == null || logAdapter.getSourceItemCount() == 0; - boolean noMatches = !noLogs && logAdapter.getItemCount() == 0; - binding.logEmpty.setText(noLogs ? R.string.ad_stats_empty : R.string.ad_stats_filter_empty); - binding.logEmpty.setVisibility(noLogs || noMatches ? View.VISIBLE : View.GONE); - binding.logTableScroll.setVisibility(noLogs ? View.GONE : View.VISIBLE); } private List buildSiteRank(AdBlockStats stats) { - Map displayNames = new java.util.HashMap<>(); - for (AdBlockLog log : stats.getBlockLogs()) { - String name = log.getSiteName(); - if (name == null || name.isBlank()) continue; - if (!log.getSiteKey().isBlank()) displayNames.putIfAbsent(log.getSiteKey(), name); - displayNames.putIfAbsent(log.getSourceName(), name); - } return stats.getSiteBlocked().entrySet().stream() - .map(entry -> new SiteRankItem(entry.getKey(), - displayNames.getOrDefault(entry.getKey(), entry.getKey()), entry.getValue())) + .map(entry -> new SiteRankItem(entry.getKey(), entry.getValue())) .sorted(Comparator.comparingLong(SiteRankItem::getCount).reversed()) .limit(10) .collect(Collectors.toList()); } - private List buildPipelineRank(AdBlockStats stats) { - return stats.getPipelineCounts().entrySet().stream() - .map(entry -> new SiteRankItem(entry.getKey(), entry.getValue())) - .sorted(Comparator.comparingLong(SiteRankItem::getCount).reversed()) - .collect(Collectors.toList()); - } - - private void clearTextFilters() { - binding.blockedAtFilter.setText(""); - binding.segmentStartFilter.setText(""); - binding.segmentEndFilter.setText(""); - binding.segmentDurationFilter.setText(""); - updateAllCategoricalButtons(); - } - private void onReset() { - new WebHtvAlertDialogBuilder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(R.string.ad_stats_reset) .setMessage(R.string.ad_stats_reset_confirm) .setPositiveButton(android.R.string.ok, (d, which) -> { AdBlockStatsStore.reset(); - logFilters.clear(); - logSelections.clear(); - clearTextFilters(); loadStats(); }) .setNegativeButton(android.R.string.cancel, null) @@ -396,16 +138,10 @@ public class AdBlockStatsDialog { // 站点排行项 private static class SiteRankItem { private final String siteKey; - private final String displayName; private final long count; public SiteRankItem(String siteKey, long count) { - this(siteKey, siteKey, count); - } - - public SiteRankItem(String siteKey, String displayName, long count) { this.siteKey = siteKey; - this.displayName = displayName; this.count = count; } @@ -413,232 +149,11 @@ public class AdBlockStatsDialog { return siteKey; } - public String getDisplayName() { - return displayName; - } - public long getCount() { return count; } } - private static void restoreItemFocus(RecyclerView recycler, int requestedPosition, int itemCount) { - if (recycler == null || itemCount <= 0) return; - int target = Math.min(Math.max(0, requestedPosition), itemCount - 1); - recycler.scrollToPosition(target); - recycler.post(() -> { - RecyclerView.ViewHolder targetHolder = recycler.findViewHolderForAdapterPosition(target); - if (targetHolder != null) targetHolder.itemView.requestFocus(); - }); - } - - private enum GroupType { SOURCE, PIPELINE } - - private static class GroupedLogAdapter extends RecyclerView.Adapter { - private final List groups; - private final AdBlockStats stats; - private final GroupType type; - private final java.util.Set expanded = new java.util.HashSet<>(); - private final List visibleItems = new ArrayList<>(); - - GroupedLogAdapter(List groups, AdBlockStats stats, GroupType type) { - this.groups = groups; - this.stats = stats; - this.type = type; - rebuild(); - } - - private void rebuild() { - visibleItems.clear(); - for (SiteRankItem group : groups) { - visibleItems.add(group); - if (expanded.contains(group.getSiteKey())) { - visibleItems.addAll(type == GroupType.SOURCE - ? stats.getBlockLogsBySite(group.getSiteKey()) - : stats.getBlockLogsByPipeline(group.getSiteKey())); - } - } - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - Object visible = visibleItems.get(position); - if (visible instanceof SiteRankItem) { - SiteRankItem group = (SiteRankItem) visible; - boolean isExpanded = expanded.contains(group.getSiteKey()); - holder.binding.name.setText((isExpanded ? "▼ " : "▶ ") + group.getDisplayName()); - holder.binding.source.setVisibility(View.GONE); - holder.binding.count.setText(String.valueOf(group.getCount())); - holder.itemView.setOnClickListener(view -> { - int focusedPosition = holder.getBindingAdapterPosition(); - RecyclerView recycler = (RecyclerView) view.getParent(); - if (!expanded.add(group.getSiteKey())) expanded.remove(group.getSiteKey()); - rebuild(); - notifyDataSetChanged(); - restoreItemFocus(recycler, focusedPosition, getItemCount()); - }); - } else { - bindLog(holder, (AdBlockLog) visible); - } - } - - private void bindLog(ViewHolder holder, AdBlockLog log) { - holder.binding.name.setText(" " + log.getAdDomain()); - holder.binding.source.setText(log.getSourceName() + " · " + log.getPipelineName() + " · " + log.getRuleId()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", log.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(view -> view.requestFocus()); - } - - @Override - public int getItemCount() { - return visibleItems.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private static class RuleGroupedLogAdapter extends RecyclerView.Adapter { - private final List groups; - private final AdBlockStats stats; - private final java.util.Set expanded = new java.util.HashSet<>(); - private final List visibleItems = new ArrayList<>(); - - RuleGroupedLogAdapter(List groups, AdBlockStats stats) { - this.groups = groups; - this.stats = stats; - rebuild(); - } - - private void rebuild() { - visibleItems.clear(); - for (RuleHitRecord group : groups) { - visibleItems.add(group); - if (expanded.contains(group.getRuleId())) visibleItems.addAll(stats.getBlockLogsByRule(group.getRuleId())); - } - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - Object visible = visibleItems.get(position); - if (visible instanceof RuleHitRecord) { - RuleHitRecord group = (RuleHitRecord) visible; - boolean isExpanded = expanded.contains(group.getRuleId()); - String name = "hls.legacy-fallback".equals(group.getRuleId()) ? "内部兜底规则" : group.getRuleName(); - if (name == null || name.isEmpty()) name = group.getRuleId(); - holder.binding.name.setText((isExpanded ? "▼ " : "▶ ") + name); - holder.binding.source.setText(group.getRuleSource()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.valueOf(group.getHitCount())); - holder.itemView.setOnClickListener(view -> { - int focusedPosition = holder.getBindingAdapterPosition(); - RecyclerView recycler = (RecyclerView) view.getParent(); - if (!expanded.add(group.getRuleId())) expanded.remove(group.getRuleId()); - rebuild(); - notifyDataSetChanged(); - restoreItemFocus(recycler, focusedPosition, getItemCount()); - }); - } else { - AdBlockLog log = (AdBlockLog) visible; - holder.binding.name.setText(" " + log.getAdDomain()); - holder.binding.source.setText(log.getSourceName() + " · " + log.getPipelineName()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", log.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(view -> view.requestFocus()); - } - } - - @Override - public int getItemCount() { - return visibleItems.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private static class ExpandableLogAdapter extends RecyclerView.Adapter { - private final List items; - private boolean expanded; - - ExpandableLogAdapter(List items) { - this.items = items; - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - holder.itemView.setFocusable(true); - holder.itemView.setFocusableInTouchMode(true); - holder.itemView.setOnClickListener(view -> view.requestFocus()); - AdBlockLog item = items.get(position); - holder.binding.name.setText(item.getAdDomain()); - holder.binding.source.setText(item.getSourceName() + " · " + item.getPipelineName() + " · " + item.getRuleId()); - holder.binding.source.setVisibility(expanded ? View.VISIBLE : View.GONE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", item.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(view -> { - expanded = !expanded; - notifyDataSetChanged(); - }); - } - - @Override - public int getItemCount() { - return items.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private List getBlockLogsBySource(AdBlockStats stats, String source) { - return stats.getBlockLogsBySource(source); - } - - private List getBlockLogsByRule(AdBlockStats stats, String rule) { - return stats.getBlockLogsByRule(rule); - } - - private List getBlockLogsByPipeline(AdBlockStats stats, String pipeline) { - return stats.getBlockLogsByPipeline(pipeline); - } - // 站点排行适配器 private static class SiteRankAdapter extends RecyclerView.Adapter { private final List items; @@ -656,7 +171,7 @@ public class AdBlockStatsDialog { @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { SiteRankItem item = items.get(position); - holder.binding.name.setText(item.getDisplayName()); + holder.binding.name.setText(item.getSiteKey()); holder.binding.count.setText(String.valueOf(item.getCount())); holder.binding.source.setVisibility(View.GONE); } @@ -676,106 +191,6 @@ public class AdBlockStatsDialog { } } - private static class BlockLogAdapter extends RecyclerView.Adapter { - private final List sourceItems; - private List items; - private final EnumMap filters = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final EnumMap> selections = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final AdBlockLogFilter.ValueProvider valueProvider; - private final SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()); - - BlockLogAdapter(List items, Map initialFilters, - Map> initialSelections, - AdBlockLogFilter.ValueProvider valueProvider) { - this.sourceItems = items == null ? new ArrayList<>() : new ArrayList<>(items); - if (initialFilters != null) filters.putAll(initialFilters); - if (initialSelections != null) { - for (Map.Entry> entry : initialSelections.entrySet()) { - if (entry.getValue() != null && !entry.getValue().isEmpty()) { - selections.put(entry.getKey(), new java.util.LinkedHashSet<>(entry.getValue())); - } - } - } - this.valueProvider = valueProvider; - applyFilters(); - } - - void setFilter(AdBlockLogFilter.Column column, String query) { - if (query == null || query.trim().isEmpty()) filters.remove(column); - else filters.put(column, query); - applyFilters(); - notifyDataSetChanged(); - } - - void setSelection(AdBlockLogFilter.Column column, Set values) { - if (values == null || values.isEmpty()) selections.remove(column); - else selections.put(column, new java.util.LinkedHashSet<>(values)); - applyFilters(); - notifyDataSetChanged(); - } - - int getSourceItemCount() { - return sourceItems.size(); - } - - private void applyFilters() { - items = AdBlockLogFilter.filter(sourceItems, filters, selections, valueProvider); - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - AdapterAdBlockLogBinding binding = AdapterAdBlockLogBinding.inflate( - LayoutInflater.from(parent.getContext()), parent, false); - return new ViewHolder(binding); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - AdBlockLog item = items.get(position); - String unknown = holder.itemView.getContext().getString(R.string.ad_log_unknown); - holder.binding.siteName.setText(value(item.getSiteName(), unknown)); - holder.binding.siteDomain.setText(value(item.getSiteDomain(), unknown)); - holder.binding.playbackContext.setText(value(item.getVodName(), unknown)); - holder.binding.lineName.setText(value(item.getLineName(), unknown)); - holder.binding.episodeName.setText(value(item.getEpisodeName(), unknown)); - String rule = AdBlockStatsStore.getRuleDisplayName(item.getRuleId()); - String domain = value(item.getAdDomain(), unknown); - holder.binding.ruleDomain.setText(value(rule, unknown)); - holder.binding.adDomain.setText(domain); - holder.binding.blockedAt.setText(item.getBlockedAt() > 0 - ? timeFormat.format(new Date(item.getBlockedAt())) : unknown); - holder.binding.segmentStart.setText(item.hasSegmentTiming() ? seconds(item.getSegmentStartSeconds()) : unknown); - holder.binding.segmentEnd.setText(item.hasSegmentTiming() ? seconds(item.getSegmentEndSeconds()) : unknown); - holder.binding.segmentDuration.setText(item.hasSegmentTiming() ? seconds(item.getSegmentDurationSeconds()) : unknown); - holder.binding.pipeline.setText(value(item.getPipelineName(), unknown)); - } - - private static String seconds(double value) { - return AdBlockTimeFormatter.formatSeconds(value); - } - - private static String value(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; - } - - @Override - public int getItemCount() { - return items.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final AdapterAdBlockLogBinding binding; - - ViewHolder(AdapterAdBlockLogBinding binding) { - super(binding.getRoot()); - this.binding = binding; - } - } - } - // 规则排行适配器 private static class RuleRankAdapter extends RecyclerView.Adapter { private final List items; diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java index 9047095f01..a28c221fdf 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java @@ -19,7 +19,6 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.UserAdRuleStore; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.databinding.DialogAdRuleEditBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -60,7 +59,7 @@ public class AdRuleEditDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java index 49af999ce9..8dd315086e 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java @@ -1,6 +1,5 @@ package com.fongmi.android.tv.ui.dialog; -import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; import android.graphics.Color; @@ -11,8 +10,6 @@ import android.view.KeyEvent; import android.view.View; import android.view.Window; import android.view.WindowManager; -import android.widget.LinearLayout; -import android.widget.TextView; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; @@ -31,12 +28,10 @@ import com.fongmi.android.tv.bean.Rule; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.databinding.DialogAdRuleManageBinding; import com.fongmi.android.tv.databinding.DialogAdRuleDetailBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.AdRuleAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.button.MaterialButton; import java.util.ArrayList; import java.util.List; @@ -72,7 +67,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -90,37 +85,15 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter binding.add.setOnClickListener(v -> onAddManual()); binding.stats.setOnClickListener(v -> onStats()); binding.importCandidates.setOnClickListener(v -> onImportCandidates()); - binding.enableImported.setOnClickListener(v -> onEnableImported()); - binding.disableImported.setOnClickListener(v -> onDisableImported()); binding.add.setOnKeyListener((v, keyCode, event) -> moveFocus(event, keyCode, KeyEvent.KEYCODE_DPAD_DOWN, binding.stats)); binding.stats.setOnKeyListener((v, keyCode, event) -> { if (moveFocus(event, keyCode, KeyEvent.KEYCODE_DPAD_UP, binding.add)) return true; if (event.getAction() != KeyEvent.ACTION_DOWN || keyCode != KeyEvent.KEYCODE_DPAD_DOWN) return false; if (binding.importCandidates.getVisibility() == View.VISIBLE) return requestFocus(binding.importCandidates); - if (binding.importBulkActions.getVisibility() == View.VISIBLE) return requestFocus(binding.enableImported); return focusFirstRule(); }); binding.importCandidates.setOnKeyListener((v, keyCode, event) -> { if (moveFocus(event, keyCode, KeyEvent.KEYCODE_DPAD_UP, binding.stats)) return true; - if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { - if (binding.importBulkActions.getVisibility() == View.VISIBLE) return requestFocus(binding.enableImported); - return focusFirstRule(); - } - return false; - }); - binding.enableImported.setOnKeyListener((v, keyCode, event) -> { - if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_UP) { - return requestFocus(binding.importCandidates.getVisibility() == View.VISIBLE ? binding.importCandidates : binding.stats); - } - if (moveFocus(event, keyCode, KeyEvent.KEYCODE_DPAD_RIGHT, binding.disableImported)) return true; - if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_DOWN) return focusFirstRule(); - return false; - }); - binding.disableImported.setOnKeyListener((v, keyCode, event) -> { - if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_UP) { - return requestFocus(binding.importCandidates.getVisibility() == View.VISIBLE ? binding.importCandidates : binding.stats); - } - if (moveFocus(event, keyCode, KeyEvent.KEYCODE_DPAD_LEFT, binding.enableImported)) return true; if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_DOWN) return focusFirstRule(); return false; }); @@ -148,25 +121,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter return focused; } - private void restoreRuleFocus(int position) { - if (position < 0) return; - binding.recycler.scrollToPosition(position); - binding.recycler.post(() -> { - View row = binding.recycler.getLayoutManager() == null - ? null : binding.recycler.getLayoutManager().findViewByPosition(position); - View target = row == null ? null : row.findViewById(R.id.toggle); - if (target == null) { - binding.recycler.post(() -> { - View retryRow = binding.recycler.getLayoutManager() == null - ? null : binding.recycler.getLayoutManager().findViewByPosition(position); - requestFocus(retryRow == null ? null : retryRow.findViewById(R.id.toggle)); - }); - } else { - requestFocus(target); - } - }); - } - private void loadData() { List items = new ArrayList<>(); @@ -199,8 +153,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter int pending = ImportedAdRuleCandidateStore.pending().size(); binding.importCandidates.setVisibility(pending == 0 ? View.GONE : View.VISIBLE); binding.importCandidates.setText(getString(R.string.ad_rule_import_candidates, pending)); - boolean hasImported = userRules.stream().anyMatch(rule -> UserAdRule.isInterfaceSource(rule.getSource())); - binding.importBulkActions.setVisibility(hasImported ? View.VISIBLE : View.GONE); } private void onAddManual() { @@ -211,25 +163,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter AdBlockStatsDialog.create((FragmentActivity) requireActivity()).show(); } - private void onEnableImported() { - if (UserAdRuleStore.setInterfaceRulesEnabled(true) == 0) return; - loadData(); - if (callback != null) callback.onRuleChanged(); - } - - private void onDisableImported() { - new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) - .setTitle(R.string.ad_rule_disable_imported) - .setMessage(R.string.ad_rule_disable_imported_confirm) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - UserAdRuleStore.setInterfaceRulesEnabled(false); - loadData(); - if (callback != null) callback.onRuleChanged(); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - private void onImportCandidates() { List candidates = ImportedAdRuleCandidateStore.pending(); if (candidates.isEmpty()) return; @@ -242,95 +175,19 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter .toArray(String[]::new); boolean[] selected = new boolean[candidates.size()]; for (int i = 0; i < selected.length; i++) selected[i] = ImportedAdRuleCandidate.RISK_LOW.equals(candidates.get(i).getRiskLevel()); - final AlertDialog[] dialogRef = new AlertDialog[1]; - View toolbar = createCandidateToolbar( - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 0), - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 1), - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 2), - () -> ignoreCandidates(dialogRef[0], candidates)); - MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) + new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.ad_rule_import_title) - .setCustomTitle(toolbar) .setMultiChoiceItems(labels, selected, (dialog, which, checked) -> selected[which] = checked) - .setPositiveButton(R.string.ad_rule_import_enable_action, (dialog, which) -> importSelectedCandidates(candidates, selected, true)) - .setNeutralButton(R.string.ad_rule_import_action, (dialog, which) -> importSelectedCandidates(candidates, selected, false)) - .setNegativeButton(android.R.string.cancel, null); - dialogRef[0] = builder.create(); - View finalToolbar = toolbar; - dialogRef[0].setOnShowListener(dialog -> { - if (finalToolbar instanceof LinearLayout root && root.getChildCount() > 1) requestFocus(root.getChildAt(1)); - }); - dialogRef[0].show(); - } - - private void ignoreCandidates(Dialog dialog, List candidates) { - List ids = new ArrayList<>(); - for (ImportedAdRuleCandidate candidate : candidates) ids.add(candidate.getId()); - ImportedAdRuleCandidateStore.ignoreCandidates(ids); - if (dialog != null) dialog.dismiss(); - loadData(); - } - - private void importSelectedCandidates(List candidates, boolean[] selected, boolean enabled) { - List ids = new ArrayList<>(); - for (int i = 0; i < candidates.size(); i++) if (selected[i]) ids.add(candidates.get(i).getId()); - if (!ids.isEmpty()) ImportedAdRuleCandidateStore.importCandidates(ids, enabled); - onRuleEdited(); - } - - private View createCandidateToolbar(Runnable selectLowRisk, Runnable selectAll, Runnable invert, Runnable ignoreAll) { - LinearLayout root = new LinearLayout(requireContext()); - root.setOrientation(LinearLayout.VERTICAL); - root.setPadding(ResUtil.dp2px(24), ResUtil.dp2px(8), ResUtil.dp2px(24), 0); - TextView title = new TextView(requireContext()); - title.setText(R.string.ad_rule_import_title); - title.setTextSize(20); - root.addView(title, new LinearLayout.LayoutParams(-1, -2)); - MaterialButton lowRisk = candidateActionButton(R.string.ad_rule_select_low_risk); - lowRisk.setOnClickListener(v -> selectLowRisk.run()); - MaterialButton all = candidateActionButton(R.string.ad_rule_select_all); - all.setOnClickListener(v -> selectAll.run()); - MaterialButton invertButton = candidateActionButton(R.string.ad_rule_invert_selection); - invertButton.setOnClickListener(v -> invert.run()); - MaterialButton ignore = candidateActionButton(R.string.ad_rule_ignore_all); - ignore.setOnClickListener(v -> ignoreAll.run()); - root.addView(lowRisk, new LinearLayout.LayoutParams(-1, -2)); - LinearLayout row = new LinearLayout(requireContext()); - row.setOrientation(LinearLayout.HORIZONTAL); - LinearLayout.LayoutParams first = new LinearLayout.LayoutParams(0, -2, 1f); - first.setMargins(0, ResUtil.dp2px(4), ResUtil.dp2px(2), 0); - row.addView(all, first); - LinearLayout.LayoutParams middle = new LinearLayout.LayoutParams(0, -2, 1f); - middle.setMargins(ResUtil.dp2px(2), ResUtil.dp2px(4), ResUtil.dp2px(2), 0); - row.addView(invertButton, middle); - LinearLayout.LayoutParams last = new LinearLayout.LayoutParams(0, -2, 1f); - last.setMargins(ResUtil.dp2px(2), ResUtil.dp2px(4), 0, 0); - row.addView(ignore, last); - root.addView(row); - return root; - } - - private MaterialButton candidateActionButton(int textRes) { - MaterialButton button = new MaterialButton(requireContext()); - button.setText(textRes); - button.setAllCaps(false); - button.setTextSize(12); - button.setMinHeight(ResUtil.dp2px(40)); - return button; - } - - private void updateCandidateSelection(Dialog dialog, List candidates, - boolean[] selected, int mode) { - if (mode == 0) { - for (int i = 0; i < selected.length; i++) selected[i] = ImportedAdRuleCandidate.RISK_LOW.equals(candidates.get(i).getRiskLevel()); - } else if (mode == 1) { - for (int i = 0; i < selected.length; i++) selected[i] = true; - } else { - for (int i = 0; i < selected.length; i++) selected[i] = !selected[i]; - } - if (dialog instanceof AlertDialog alert && alert.getListView() != null) { - for (int i = 0; i < selected.length; i++) alert.getListView().setItemChecked(i, selected[i]); - } + .setPositiveButton(R.string.ad_rule_import_action, (dialog, which) -> { + for (int i = 0; i < candidates.size(); i++) if (selected[i]) ImportedAdRuleCandidateStore.importCandidate(candidates.get(i).getId()); + onRuleEdited(); + }) + .setNeutralButton(R.string.ad_rule_ignore_all, (dialog, which) -> { + for (ImportedAdRuleCandidate candidate : candidates) ImportedAdRuleCandidateStore.ignore(candidate.getId()); + loadData(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); } private void onRuleEdited() { @@ -365,20 +222,14 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter } private void showHlsRuleDetail(HlsRuleConfig.Entry item) { - HlsRuleConfig.Entry current = HlsRuleConfig.getEntries().stream() - .filter(entry -> item.key().equals(entry.key())) - .findFirst().orElse(item); - String name = current.name().isBlank() ? current.id() : current.name(); - String status = current.valid() - ? getString(current.enabled() ? R.string.ad_rule_hls_enabled : R.string.ad_rule_hls_disabled) - : getString(R.string.ad_rule_hls_invalid, current.error()); - String details = HlsRuleConfig.LEGACY_FALLBACK_KEY.equals(current.id()) - ? "具体判断规则\n" + HlsRuleConfig.LEGACY_FALLBACK_DETAIL - : getString(R.string.ad_rule_detail_json) + "\n" + current.detail(); - String content = getString(R.string.ad_rule_hls_builtin_summary, current.version(), status) - + "\n" + getString(R.string.ad_rule_detail_id, current.id()) - + "\n" + getString(R.string.ad_rule_detail_source, current.source()) - + "\n\n" + details; + String name = item.name().isBlank() ? item.id() : item.name(); + String status = item.valid() + ? getString(item.enabled() ? R.string.ad_rule_hls_enabled : R.string.ad_rule_hls_disabled) + : getString(R.string.ad_rule_hls_invalid, item.error()); + String content = getString(R.string.ad_rule_hls_builtin_summary, item.version(), status) + + "\n" + getString(R.string.ad_rule_detail_id, item.id()) + + "\n" + getString(R.string.ad_rule_detail_source, item.source()) + + "\n\n" + getString(R.string.ad_rule_detail_json) + "\n" + item.detail(); showTextDetail(name, content, null); } @@ -395,7 +246,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void showTextDetail(String name, String content, Runnable editAction) { DialogAdRuleDetailBinding detail = DialogAdRuleDetailBinding.inflate(getLayoutInflater()); detail.content.setText(content); - MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) .setTitle(name) .setView(detail.getRoot()) .setPositiveButton(android.R.string.ok, null); @@ -422,11 +273,11 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter } private void confirmDisable(String name, int messageRes, Runnable action) { - new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) + new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) .setTitle(name) .setMessage(messageRes) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) - .setNegativeButton(android.R.string.cancel, null) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> loadData()) .show(); } @@ -434,7 +285,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter public void onUserToggleClick(UserAdRule item, boolean enabled) { if (!enabled) { int message = UserAdRule.SOURCE_AI.equals(item.getSource()) ? R.string.ad_rule_ai_disable_confirm - : UserAdRule.isInterfaceSource(item.getSource()) ? R.string.ad_rule_imported_disable_confirm + : item.getSource() != null && item.getSource().startsWith("interface_") ? R.string.ad_rule_imported_disable_confirm : R.string.ad_rule_manual_disable_confirm; confirmDisable(item.getName(), message, () -> setUserEnabled(item, false)); } else { @@ -445,9 +296,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void setUserEnabled(UserAdRule item, boolean enabled) { item.setEnabled(enabled); UserAdRuleStore.update(item); - int position = adapter.positionOfUserRule(item); - adapter.refreshUserEnabled(item); - restoreRuleFocus(position); + loadData(); if (callback != null) callback.onRuleChanged(); } @@ -462,9 +311,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void setDefaultEnabled(String ruleId, boolean enabled) { DisabledDefaultRuleStore.setDisabled(ruleId, !enabled); - int position = adapter.positionOfDefaultRule(ruleId); - adapter.refreshDefaultEnabled(ruleId, enabled); - restoreRuleFocus(position); + loadData(); if (callback != null) callback.onRuleChanged(); } @@ -480,15 +327,13 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void setHlsEnabled(String key, boolean enabled) { HlsRuleStateStore.setEnabled(key, enabled); - int position = adapter.positionOfHlsRule(key); - adapter.refreshHlsEnabled(key, enabled); - restoreRuleFocus(position); + loadData(); if (callback != null) callback.onRuleChanged(); } @Override public void onDeleteClick(UserAdRule item) { - AlertDialog alert = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) + AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.ad_rule_delete_confirm) .setMessage(getString(R.string.ad_rule_delete_message, item.getName())) .setPositiveButton(R.string.ad_rule_delete_confirm, (dialog, which) -> deleteUserRule(item)) diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java index 3cc95a88fa..fc9b67e53d 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java @@ -15,7 +15,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.AdDetectionResult; import com.fongmi.android.tv.databinding.DialogAdRulePreviewBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -54,7 +53,7 @@ public class AdRulePreviewDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java deleted file mode 100644 index 1f2311f5f3..0000000000 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java +++ /dev/null @@ -1,205 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Dialog; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.setting.PlayerSetting; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.AppearanceRowTheme; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeProfile; -import com.fongmi.android.tv.theme.ThemeProfileStore; -import com.fongmi.android.tv.utils.ResUtil; -import com.google.android.material.textview.MaterialTextView; - -import java.util.ArrayList; -import java.util.List; - -public final class AppearanceDialog extends DialogFragment { - - private String[] uiScales; - private String[] languages; - private String[] imageSizes; - private final List rows = new ArrayList<>(); - private MaterialTextView uiScaleValue; - private MaterialTextView themeModeValue; - private MaterialTextView themeValue; - private MaterialTextView imageSizeValue; - private MaterialTextView languageValue; - - /** One dialog row, kept so a theme change can re-colour it without a rebuild. */ - private record Row(LinearLayout root, MaterialTextView title, MaterialTextView summary) { - } - - public static void show(Fragment fragment) { - new AppearanceDialog().show(fragment.getChildFragmentManager(), AppearanceDialog.class.getSimpleName()); - } - - public static void show(FragmentActivity activity) { - new AppearanceDialog().show(activity.getSupportFragmentManager(), AppearanceDialog.class.getSimpleName()); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - uiScales = ResUtil.getStringArray(R.array.select_ui_scale); - languages = ResUtil.getStringArray(R.array.select_language); - imageSizes = ResUtil.getStringArray(R.array.select_size); - return LightDialog.create(requireContext(), getString(R.string.setting_appearance), createContent(), getString(R.string.dialog_close), null, null, null); - } - - private View createContent() { - LinearLayout content = new LinearLayout(requireContext()); - content.setOrientation(LinearLayout.VERTICAL); - uiScaleValue = addRow(content, R.string.setting_ui_scale, uiScales[Setting.getUiScaleIndex()], this::chooseUiScale); - themeModeValue = addRow(content, R.string.setting_theme_mode, getThemeModeText(), this::chooseThemeMode); - themeValue = addRow(content, R.string.setting_theme_color, getThemeText(), view -> ThemeDialog.show(this)); - imageSizeValue = addRow(content, R.string.setting_size, imageSizes[PlayerSetting.getSize()], this::chooseImageSize); - languageValue = addRow(content, R.string.setting_language, languages[Setting.getLanguageIndex()], this::chooseLanguage); - return content; - } - - private MaterialTextView addRow(LinearLayout content, int titleRes, String value, View.OnClickListener listener) { - LinearLayout row = new LinearLayout(requireContext()); - row.setOrientation(LinearLayout.HORIZONTAL); - row.setGravity(Gravity.CENTER_VERTICAL); - row.setClickable(true); - row.setFocusable(true); - row.setPadding(dp(16), 0, dp(16), 0); - row.setOnClickListener(listener); - - MaterialTextView title = new MaterialTextView(requireContext()); - title.setText(titleRes); - title.setTextSize(15); - title.setSingleLine(true); - row.addView(title, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); - - MaterialTextView summary = new MaterialTextView(requireContext()); - summary.setText(value); - summary.setTextSize(14); - summary.setGravity(Gravity.END); - summary.setSingleLine(true); - summary.setEllipsize(TextUtils.TruncateAt.END); - LinearLayout.LayoutParams summaryParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1); - summaryParams.leftMargin = dp(12); - row.addView(summary, summaryParams); - - // Row fill, stroke and both text colours must come from one palette. The row used - // to keep the fixed light selector_git_cloud_card while its text already followed - // ThemeController.current(), which on the TV dark table measured 1.16:1 / 1.36:1. - AppearanceRowTheme.apply(row, title, summary, ThemeController.current()); - rows.add(new Row(row, title, summary)); - - LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(58)); - rowParams.bottomMargin = dp(8); - content.addView(row, rowParams); - return summary; - } - - /** - * Re-colours the rows with the active palette. - * - *

Called when the dialog is shown and again after a theme change, so an open - * dialog follows a new profile without being rebuilt. - */ - private void applyRowTheme() { - for (Row row : rows) AppearanceRowTheme.apply(row.root(), row.title(), row.summary(), ThemeController.current()); - } - - private void chooseUiScale(View view) { - ChoiceDialog.showSingle(this, R.string.setting_ui_scale, uiScales, Setting.getUiScaleIndex(), which -> { - if (which == Setting.getUiScaleIndex()) return; - uiScaleValue.setText(uiScales[which]); - Setting.putUiScaleIndex(which); - dismissAllowingStateLoss(); - requireActivity().recreate(); - }); - } - - private void chooseThemeMode(View view) { - String[] modes = {getString(R.string.setting_theme_mode_system), getString(R.string.setting_theme_mode_light), getString(R.string.setting_theme_mode_dark)}; - int current = switch (Setting.getThemeMode()) { - case 0 -> 1; - case 1 -> 2; - default -> 0; - }; - ChoiceDialog.showSingle(this, R.string.setting_theme_mode, modes, current, which -> { - int mode = which == 0 ? -1 : which - 1; - if (mode == Setting.getThemeMode()) return; - Setting.putThemeMode(mode); - themeModeValue.setText(getThemeModeText()); - ThemeController.applyNightModeToApp(); - dismissAllowingStateLoss(); - RefreshEvent.theme(); - }); - } - - private String getThemeModeText() { - return switch (Setting.getThemeMode()) { - case 0 -> getString(R.string.setting_theme_mode_light); - case 1 -> getString(R.string.setting_theme_mode_dark); - default -> getString(R.string.setting_theme_mode_system); - }; - } - - private void chooseImageSize(View view) { - ChoiceDialog.showSingle(this, R.string.setting_size, imageSizes, PlayerSetting.getSize(), which -> { - imageSizeValue.setText(imageSizes[which]); - PlayerSetting.putSize(which); - RefreshEvent.size(); - }); - } - - private void chooseLanguage(View view) { - ChoiceDialog.showSingle(this, R.string.setting_language, languages, Setting.getLanguageIndex(), which -> { - if (which == Setting.getLanguageIndex()) return; - languageValue.setText(languages[which]); - Setting.putLanguageIndex(which); - dismissAllowingStateLoss(); - RefreshEvent.language(); - }); - } - - @Override - public void onStart() { - super.onStart(); - // A profile applied while this dialog stayed on screen must reach the rows, and - // the rows are only attached after createContent(), so the repaint happens here. - applyRowTheme(); - } - - void onThemeProfileApplied() { - themeValue.setText(getThemeText()); - applyRowTheme(); - dismissAllowingStateLoss(); - RefreshEvent.theme(); - } - - private String getThemeText() { - ThemeProfile profile = ThemeProfileStore.load(); - String name = profile.displayName(); - return "Default".equals(name) ? themeText(Setting.getThemeColor()) : name; - } - - private String themeText(int color) { - if (color == -1) return getString(R.string.setting_off); - return getString(color == 0 ? R.string.setting_auto : R.string.setting_custom); - } - - private int dp(int value) { - return ResUtil.dp2px(value); - } -} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java index 738fd60c6c..7dfe4e5ed0 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java @@ -3,16 +3,9 @@ package com.fongmi.android.tv.ui.dialog; import android.app.Dialog; import android.app.Activity; import android.content.Intent; -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; import android.os.Bundle; import android.text.TextUtils; -import android.view.Gravity; import android.view.View; -import android.view.Window; -import android.view.ViewGroup; -import android.view.ViewParent; -import android.view.WindowManager; import android.view.inputmethod.EditorInfo; import androidx.activity.result.ActivityResultLauncher; @@ -32,13 +25,12 @@ import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.databinding.DialogConfigBinding; import com.fongmi.android.tv.event.ServerEvent; import com.fongmi.android.tv.impl.ConfigListener; -import com.fongmi.android.tv.playback.PlaybackIdentityResolver; import com.fongmi.android.tv.server.Server; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.QRCode; +import com.fongmi.android.tv.utils.ResUtil; import com.github.catvod.utils.Path; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -46,14 +38,11 @@ import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; -import java.util.ArrayList; -import java.util.List; import java.util.Objects; public class ConfigDialog extends BaseAlertDialog { private DialogConfigBinding binding; - private Config config; private boolean append = true; private boolean edit; private String url; @@ -83,24 +72,14 @@ public class ConfigDialog extends BaseAlertDialog { return this; } - public ConfigDialog target(Config config) { - this.config = config; - return this; - } - public void show(FragmentActivity activity) { show(activity.getSupportFragmentManager(), null); } - public void show(androidx.fragment.app.Fragment fragment) { - show(fragment.getChildFragmentManager(), null); - } - @Override @NonNull public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - int height = Math.max(1, ResUtil.getScreenHeight(requireContext()) - ResUtil.dp2px(48)); - Dialog dialog = LightDialog.create(requireContext(), getDialogTitle(), getBinding().getRoot(), 0.95f, 0.95f, 2000, height); + Dialog dialog = LightDialog.create(requireContext(), getDialogTitle(), getBinding().getRoot()); initView(); initEvent(); return dialog; @@ -118,15 +97,13 @@ public class ConfigDialog extends BaseAlertDialog { @Override protected void initView() { - if (config == null) config = edit ? getConfig() : Config.create(type); + Config config = getConfig(); binding.name.setText(config.getName()); binding.text.setText(url = config.getUrl()); binding.text.setSelection(TextUtils.isEmpty(url) ? 0 : url.length()); - binding.addresses.setText(addressesText(config)); binding.positive.setText(edit ? R.string.dialog_edit : R.string.dialog_positive); binding.code.setImageBitmap(QRCode.getLightBitmap(Server.get().getAddress(4), 200, 0)); binding.info.setText(ResUtil.getString(R.string.push_info, Server.get().getAddress()).replace("\uff0c", "\n")); - binding.name.post(binding.name::requestFocus); } @Override @@ -135,7 +112,6 @@ public class ConfigDialog extends BaseAlertDialog { // 猫源本地包是一整个文件夹(index.js + index.config.js),文件列表选不到目录, // 所以单独给一个入口。选 zip 仍走上面那个文件选择。 binding.chooseDir.setOnClickListener(this::onChooseDir); - binding.chooseDir.setVisibility(type == 0 ? View.VISIBLE : View.GONE); binding.positive.setOnClickListener(this::onPositive); binding.negative.setOnClickListener(this::onNegative); binding.text.addTextChangedListener(new CustomTextListener() { @@ -219,47 +195,27 @@ public class ConfigDialog extends BaseAlertDialog { private void onPositive(View view) { String name = binding.name.getText().toString().trim(); String text = binding.text.getText().toString().trim(); - Config config = saveConfig(text, name, binding.addresses.getText().toString()); + Config config = saveConfig(text, name); if (config == null) { Notify.show(R.string.remote_trust_config_url_required); binding.text.requestFocus(); return; } ((ConfigListener) requireActivity()).setConfig(config); - PlaybackIdentityResolver.resolveSaved(config); dismiss(); } - private Config saveConfig(String text, String name, String addresses) { - Config saved; + private Config saveConfig(String text, String name) { if (text.isEmpty()) { if (!edit) return null; if (!TextUtils.isEmpty(url)) Config.delete(url, type); return getStoredConfig(); } else if (edit) { - saved = Config.find(config.getId()).url(text).name(name).update(); + return Config.find(url, type).url(text).name(name).update(); } else { Config exists = AppDatabase.get().getConfigDao().find(text, type); - saved = exists != null ? exists.name(name).update() : null; - if (saved == null) { - return config = Config.create(type).url(text).name(name) - .urls(addresses(text, addresses)).insert().update(); - } + return exists != null ? exists.name(name).update() : Config.create(type).url(text).name(name).update(); } - return config = saved.urls(addresses(text, addresses)).update(); - } - - private List addresses(String primary, String text) { - List result = new ArrayList<>(); - result.add(primary); - for (String item : text.split("\\R")) if (!TextUtils.isEmpty(item.trim())) result.add(item.trim()); - return result; - } - - private String addressesText(Config config) { - List addresses = new ArrayList<>(config.getUrls()); - addresses.remove(config.getUrl()); - return TextUtils.join("\n", addresses); } private void onNegative(View view) { @@ -277,53 +233,10 @@ public class ConfigDialog extends BaseAlertDialog { @Override public void onStart() { super.onStart(); - configureWindow(); + setWidth(0.55f); EventBus.getDefault().register(this); } - private void configureWindow() { - if (getDialog() == null || getDialog().getWindow() == null) return; - Window window = getDialog().getWindow(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); - int width = screenWidth - ResUtil.dp2px(48); - int height = screenHeight - ResUtil.dp2px(48); - WindowManager.LayoutParams params = window.getAttributes(); - params.width = width; - params.height = height; - params.gravity = Gravity.CENTER; - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.getDecorView().setPadding(0, 0, 0, 0); - window.setAttributes(params); - window.setLayout(params.width, params.height); - expandContentToWindow(width, height); - } - - private void expandContentToWindow(int width, int height) { - View content = binding == null ? null : binding.getRoot(); - if (content == null) return; - content.setMinimumWidth(width); - content.setMinimumHeight(height); - ViewGroup.LayoutParams contentParams = content.getLayoutParams(); - if (contentParams != null) { - contentParams.width = ViewGroup.LayoutParams.MATCH_PARENT; - contentParams.height = ViewGroup.LayoutParams.MATCH_PARENT; - content.setLayoutParams(contentParams); - } - ViewParent parent = content.getParent(); - while (parent instanceof ViewGroup group) { - group.setMinimumWidth(width); - group.setMinimumHeight(height); - ViewGroup.LayoutParams params = group.getLayoutParams(); - if (params != null) { - params.width = ViewGroup.LayoutParams.MATCH_PARENT; - params.height = ViewGroup.LayoutParams.MATCH_PARENT; - group.setLayoutParams(params); - } - parent = group.getParent(); - } - } - @Override public void onStop() { super.onStop(); @@ -333,15 +246,12 @@ public class ConfigDialog extends BaseAlertDialog { private final ActivityResultLauncher launcher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null || result.getData().getData() == null) return; FragmentActivity activity = requireActivity(); - String path = Objects.toString(FileChooser.getPersistentPathFromUri(result.getData().getData()), ""); - if (TextUtils.isEmpty(path)) { - Notify.show(R.string.dialog_config_choose_failed); - return; - } + String path = Objects.toString(FileChooser.getPathFromUri(result.getData().getData()), ""); + if (TextUtils.isEmpty(path)) return; App.post(() -> { if (activity.isFinishing() || activity.isDestroyed()) return; dismissAllowingStateLoss(); - App.post(() -> ((ConfigListener) activity).setConfig(saveConfig("file:/" + path.replace(Path.rootPath(), ""), binding.name.getText().toString().trim(), binding.addresses.getText().toString())), 100); + App.post(() -> ((ConfigListener) activity).setConfig(saveConfig("file:/" + path.replace(Path.rootPath(), ""), binding.name.getText().toString().trim())), 100); }, 100); }); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ControlDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ControlDialog.java index 4a7b7dbaa1..5c93e11ca4 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ControlDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ControlDialog.java @@ -120,7 +120,7 @@ public class ControlDialog extends BaseBottomSheetDialog implements ParseAdapter protected ViewBinding getBinding(@NonNull LayoutInflater inflater, @Nullable ViewGroup container) { binding = DialogControlBinding.inflate(inflater, container, false); speeds = Arrays.asList(binding.speed05, binding.speed075, binding.speed10, binding.speed125, binding.speed15, binding.speed175, binding.speed20, binding.speed25, binding.speed30, binding.speed50); - displays = Arrays.asList(binding.displayTime, binding.displayTraffic, binding.displaySize, binding.displayProgress, binding.displayMini, binding.displayTitle, binding.displayParams, binding.displayMediaBadges); + displays = Arrays.asList(binding.displayTime, binding.displayTraffic, binding.displaySize, binding.displayProgress, binding.displayMini, binding.displayTitle, binding.displayParams); return binding; } @@ -202,7 +202,7 @@ public class ControlDialog extends BaseBottomSheetDialog implements ParseAdapter binding.panDiagnostic, binding.scale, binding.text, binding.audio, binding.video, binding.danmaku, binding.title, binding.displayTime, binding.displayTraffic, binding.displaySize, binding.displayProgress, - binding.displayMini, binding.displayTitle, binding.displayParams, binding.displayMediaBadges, + binding.displayMini, binding.displayTitle, binding.displayParams, binding.episodeColumn1, binding.episodeColumn2, binding.compactEpisodeTitle ); for (View view : views) setRemoteFocusable(view); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialog.java index c634c715d1..b795199da1 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialog.java @@ -55,9 +55,7 @@ public class DanmakuApiDialog extends BaseAlertDialog { @Override protected void initView() { String text; - // 只回填真实配置过的地址:用 getEffectiveApiUrl() 会把本地包运行时算出来的回环地址 - // 显示在输入框里,用户不改直接确定就把它固化进 prefs。 - binding.text.setText(text = DanmakuSetting.getConfiguredApiUrl()); + binding.text.setText(text = DanmakuSetting.getEffectiveApiUrl()); binding.text.setSelection(TextUtils.isEmpty(text) ? 0 : text.length()); binding.code.setImageBitmap(QRCode.getLightBitmap(Server.get().getAddress(4), 200, 0)); binding.info.setText(ResUtil.getString(R.string.push_info, Server.get().getAddress()).replace("\uff0c", "\n")); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java index 3204a9f497..b90ac7db8d 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java @@ -20,7 +20,6 @@ import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.bean.TmdbPerson; import com.fongmi.android.tv.service.TmdbService; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.EpisodePhotoAdapter; import com.fongmi.android.tv.ui.adapter.TmdbPersonAdapter; import com.fongmi.android.tv.utils.ResUtil; @@ -58,14 +57,7 @@ public class EpisodeDetailDialog { java.util.List preloadedPhotos, java.util.List preloadedGuests, android.content.DialogInterface.OnDismissListener dismissListener) { - show(activity, episode, null, site, preloadedPhotos, preloadedGuests, dismissListener); - } - - public static void show(FragmentActivity activity, Episode episode, TmdbEpisode boundTmdbEpisode, Site site, - java.util.List preloadedPhotos, - java.util.List preloadedGuests, - android.content.DialogInterface.OnDismissListener dismissListener) { - TmdbEpisode tmdbEpisode = boundTmdbEpisode != null ? boundTmdbEpisode : episode.getTmdbEpisode(); + TmdbEpisode tmdbEpisode = episode.getTmdbEpisode(); if (tmdbEpisode == null) { // 电影没有分集对象,尝试从宿主获取影片级数据 if (activity instanceof com.fongmi.android.tv.ui.host.TmdbDetailHost) { @@ -172,7 +164,7 @@ public class EpisodeDetailDialog { loadEpisodeMedia(activity, tmdbEpisode, site, light, photosLabel, photosGrid, guestsLabel, guestsGrid); } - MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(activity) + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity) .setView(view); AlertDialog alertDialog = builder.create(); @@ -275,7 +267,7 @@ public class EpisodeDetailDialog { guestsLabel.setVisibility(View.GONE); guestsGrid.setVisibility(View.GONE); - MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(activity) + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity) .setView(view); AlertDialog alertDialog = builder.create(); @@ -335,7 +327,7 @@ public class EpisodeDetailDialog { private static void showSimpleDialog(FragmentActivity activity, Episode episode, android.content.DialogInterface.OnDismissListener dismissListener) { // 标题放固定文案,源站文件名放可换行的正文,避免长名被单行标题截断 - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity) .setTitle(R.string.detail_tmdb_empty) .setMessage(episode.getName()) .setPositiveButton("关闭", null) @@ -360,7 +352,7 @@ public class EpisodeDetailDialog { try { // 调用TMDB API获取剧集图片 TmdbService service = new TmdbService(); - TmdbConfig config = TmdbConfig.effectiveCurrent(); + TmdbConfig config = TmdbConfig.objectFrom(Setting.getTmdbConfig()); android.util.Log.d("EpisodeDetail", "开始请求TMDB API..."); @@ -397,13 +389,10 @@ public class EpisodeDetailDialog { guestsLabel.setVisibility(View.VISIBLE); guestsGrid.setVisibility(View.VISIBLE); guestsGrid.setHorizontalSpacing(ResUtil.dp2px(12)); - boolean cinema = Setting.isTmdbCinemaStyle(); - guestsGrid.setRowHeight(ResUtil.dp2px(cinema ? 90 : 154)); - if (cinema) resizeGuestGrid(guestsGrid); + guestsGrid.setRowHeight(ResUtil.dp2px(154)); TmdbPersonAdapter guestAdapter = new TmdbPersonAdapter(person -> TmdbPersonDialog.show(activity, person, site)); guestAdapter.setLight(light); - guestAdapter.setCinema(cinema); guestAdapter.setItems(guests); guestsGrid.setAdapter(guestAdapter); } @@ -456,13 +445,10 @@ public class EpisodeDetailDialog { guestsLabel.setVisibility(View.VISIBLE); guestsGrid.setVisibility(View.VISIBLE); guestsGrid.setHorizontalSpacing(ResUtil.dp2px(12)); - boolean cinema = Setting.isTmdbCinemaStyle(); - guestsGrid.setRowHeight(ResUtil.dp2px(cinema ? 90 : 154)); - if (cinema) resizeGuestGrid(guestsGrid); + guestsGrid.setRowHeight(ResUtil.dp2px(154)); TmdbPersonAdapter guestAdapter = new TmdbPersonAdapter(person -> TmdbPersonDialog.show(activity, person, null)); guestAdapter.setLight(light); - guestAdapter.setCinema(cinema); guestAdapter.setItems(guests); guestsGrid.setAdapter(guestAdapter); } else { @@ -471,12 +457,6 @@ public class EpisodeDetailDialog { } } - private static void resizeGuestGrid(androidx.leanback.widget.HorizontalGridView guestsGrid) { - ViewGroup.LayoutParams params = guestsGrid.getLayoutParams(); - params.height = ResUtil.dp2px(104); - guestsGrid.setLayoutParams(params); - } - /** * 设置 ScrollView 按键监听,处理遥控器上下键滚动 * 策略:ScrollView 持有焦点时,按下滚动;网格可见且有数据时,转移焦点到网格 @@ -497,15 +477,29 @@ public class EpisodeDetailDialog { if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_DOWN) { // 从大海报往下:跳到第一个有数据的网格 - if (isSameOrDescendantOf(focus, stillCard)) { - if (requestGridFocus(photosGrid) || requestGridFocus(guestsGrid)) return true; + if (focus == stillCard || (focus != null && focus.getParent() == stillCard)) { + if (photosGrid != null && photosGrid.getVisibility() == View.VISIBLE + && photosGrid.getAdapter() != null && photosGrid.getAdapter().getItemCount() > 0) { + photosGrid.requestFocus(); + return true; + } + if (guestsGrid != null && guestsGrid.getVisibility() == View.VISIBLE + && guestsGrid.getAdapter() != null && guestsGrid.getAdapter().getItemCount() > 0) { + guestsGrid.requestFocus(); + return true; + } } } if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_UP) { // 客串卡片先回到剧照,剧照再回到大海报;焦点实际在网格 item 上,需要检查父级关系 if (focus != null && (focus == guestsGrid || isDescendantOf(focus, guestsGrid))) { - if (!requestGridFocus(photosGrid)) stillCard.requestFocus(); + if (photosGrid != null && photosGrid.getVisibility() == View.VISIBLE + && photosGrid.getAdapter() != null && photosGrid.getAdapter().getItemCount() > 0) { + photosGrid.requestFocus(); + } else { + stillCard.requestFocus(); + } return true; } if (focus != null && (focus == photosGrid || isDescendantOf(focus, photosGrid))) { @@ -518,25 +512,6 @@ public class EpisodeDetailDialog { }); } - /** - * 将焦点落到横向网格的实际卡片,而不是只把焦点留在网格容器上。 - * RecyclerView/HorizontalGridView 只有在 holder 回调里请求子项焦点时, - * 才能可靠地触发 ScrollView 的垂直滚动和后续遥控导航。 - */ - private static boolean requestGridFocus(androidx.leanback.widget.HorizontalGridView grid) { - if (grid == null || grid.getVisibility() != View.VISIBLE - || grid.getAdapter() == null || grid.getAdapter().getItemCount() == 0) { - return false; - } - int position = Math.max(0, grid.getSelectedPosition()); - grid.setSelectedPosition(position, holder -> holder.itemView.requestFocus()); - return true; - } - - private static boolean isSameOrDescendantOf(View view, View ancestor) { - return view != null && (view == ancestor || isDescendantOf(view, ancestor)); - } - /** * 检查 view 是否是 ancestor 的子孙视图 */ diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java index 2437c33f95..d7e17a788b 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java @@ -1,31 +1,16 @@ package com.fongmi.android.tv.ui.dialog; -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; - import androidx.fragment.app.FragmentActivity; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.LiveConfig; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.api.config.WallConfig; import com.fongmi.android.tv.bean.Config; import com.fongmi.android.tv.databinding.DialogHistoryBinding; import com.fongmi.android.tv.impl.ConfigListener; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.ConfigAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnClickListener { @@ -34,9 +19,7 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl private ConfigAdapter adapter; private ConfigListener listener; private boolean readOnly; - private boolean manage; private int type; - private ItemTouchHelper sortTouchHelper; public static HistoryDialog create() { return new HistoryDialog(); @@ -62,11 +45,6 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl return this; } - public HistoryDialog manage() { - manage = true; - return this; - } - public void show(FragmentActivity activity) { show(activity.getSupportFragmentManager(), null); if (activity instanceof ConfigListener) listener = (ConfigListener) activity; @@ -93,31 +71,7 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl binding.recycler.setItemAnimator(null); binding.recycler.setHasFixedSize(false); binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 16)); - binding.recycler.setAdapter(adapter.readOnly(readOnly).protectCurrent(manage).addAll(type, getConfig())); - binding.add.setVisibility(manage ? View.VISIBLE : View.GONE); - binding.add.setOnClickListener(v -> onAdd()); - if (type == 0 && !readOnly) attachSortHelper(); - } - - private void onAdd() { - ConfigDialog dialog = ConfigDialog.create(); - if (type == 0) dialog.vod(); - else if (type == 1) dialog.live(); - else dialog.wall(); - if (getParentFragment() != null) dialog.show(getParentFragment()); - else dialog.show(requireActivity()); - dismiss(); - } - - private void attachSortHelper() { - sortTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { - @Override public boolean isLongPressDragEnabled() { return false; } - @Override public boolean onMove(@NonNull RecyclerView view, @NonNull RecyclerView.ViewHolder from, @NonNull RecyclerView.ViewHolder to) { - return adapter.drag(from.getBindingAdapterPosition(), to.getBindingAdapterPosition()); - } - @Override public void onSwiped(@NonNull RecyclerView.ViewHolder holder, int direction) { } - }); - sortTouchHelper.attachToRecyclerView(binding.recycler); + binding.recycler.setAdapter(adapter.readOnly(readOnly).addAll(type, getConfig())); } private Config getConfig() { @@ -131,73 +85,19 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl @Override public void onTextClick(Config item) { - if (listener == null) return; - listener.setConfig(item); - dismiss(); - } - - @Override - public boolean onTextLongClick(ConfigAdapter.ViewHolder holder) { - if (type != 0 || readOnly || sortTouchHelper == null) return false; - sortTouchHelper.startDrag(holder); - return true; - } - - @Override - public void onEditClick(Config item) { - ConfigDialog dialog = ConfigDialog.create().target(item).edit(); - if (type == 0) dialog.vod(); - else if (type == 1) dialog.live(); - else dialog.wall(); - if (getParentFragment() != null) dialog.show(getParentFragment()); - else dialog.show(requireActivity()); + if (listener != null) listener.setConfig(item); dismiss(); } @Override public void onDeleteClick(Config item) { - if (adapter.isProtectedCurrent(item)) { - Notify.show(R.string.config_current_delete_message); - return; - } - androidx.appcompat.app.AlertDialog dialog = new WebHtvAlertDialogBuilder(requireContext()) - .setTitle(R.string.config_delete_title) - .setMessage(getString(R.string.config_delete_message, item.getDesc())) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, (target, which) -> { - if (adapter.remove(item) == 0) dismiss(); - }) - .create(); - dialog.setOnShowListener(target -> dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE).requestFocus()); - dialog.show(); + if (adapter.remove(item) == 0) dismiss(); } @Override public void onStart() { super.onStart(); - if (adapter.getItemCount() == 0 && !manage) dismiss(); - else configureWindow(); - } - - private void configureWindow() { - if (getDialog() == null || getDialog().getWindow() == null) return; - Window window = getDialog().getWindow(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); - int width = screenWidth - ResUtil.dp2px(48); - int height = screenHeight - ResUtil.dp2px(48); - WindowManager.LayoutParams params = window.getAttributes(); - params.width = width; - params.height = height; - params.gravity = Gravity.CENTER; - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.getDecorView().setPadding(0, 0, 0, 0); - window.setAttributes(params); - window.setLayout(params.width, params.height); - binding.getRoot().setMinimumHeight(height); - ViewGroup.LayoutParams recyclerParams = binding.recycler.getLayoutParams(); - recyclerParams.height = 0; - if (recyclerParams instanceof androidx.appcompat.widget.LinearLayoutCompat.LayoutParams linearParams) linearParams.weight = 1; - binding.recycler.setLayoutParams(recyclerParams); + if (adapter.getItemCount() == 0) dismiss(); + else setWidth(0.4f); } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuDialog.java index 604093c738..0e3be1ecff 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuDialog.java @@ -26,7 +26,7 @@ import java.util.List; public class HomeMenuDialog extends BaseAlertDialog { private static final String TAG = "home_menu_dialog"; - // 菜单项按 3 列排布;去掉首项后 11 项共 4 行,仍在 maxHeight 一屏内显示完 + // 9 项按 3 列排布,正好一屏显示完,与参考实现一致 private static final int GRID_COUNT = 3; private DialogHomeMenuBinding binding; @@ -35,7 +35,7 @@ public class HomeMenuDialog extends BaseAlertDialog { public interface Listener { /** - * @param index select_home_menu_key 中的下标,取值 1..11 + * @param index select_home_menu_key 中的下标,取值 1..9 */ void onHomeMenuItem(int index); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuKeyDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuKeyDialog.java index 65503e2168..6a00dd7875 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuKeyDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuKeyDialog.java @@ -1,10 +1,10 @@ package com.fongmi.android.tv.ui.dialog; +import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; public class HomeMenuKeyDialog { @@ -12,7 +12,7 @@ public class HomeMenuKeyDialog { String[] items = activity.getResources().getStringArray(R.array.select_home_menu_key); int current = Setting.getHomeMenuKey(); - new WebHtvAlertDialogBuilder(activity) + new AlertDialog.Builder(activity) .setTitle(R.string.setting_home_menu_key) .setSingleChoiceItems(items, current, (dialog, which) -> { Setting.putHomeMenuKey(which); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java index 76722040fb..3cadc4048c 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java @@ -20,8 +20,6 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterPlayerOsdBinding; import com.fongmi.android.tv.databinding.DialogPlayerOsdBinding; -import com.fongmi.android.tv.theme.PlayerSettingsRowTheme; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.ResUtil; import java.util.Arrays; @@ -117,12 +115,11 @@ public final class PlayerOsdDialog extends DialogFragment { public void onBindViewHolder(@NonNull ViewHolder holder, int position) { boolean visible = checked[position]; holder.binding.name.setText(items[position]); + holder.binding.name.setAlpha(visible ? 1f : 0.55f); holder.binding.visible.setImageResource(visible ? R.drawable.ic_player_button_visible : R.drawable.ic_player_button_hidden); holder.binding.visible.setContentDescription(getString(visible ? R.string.setting_show : R.string.setting_hide)); holder.binding.visible.setSelected(visible); holder.binding.visible.setActivated(visible); - PlayerSettingsRowTheme.apply(holder.binding.root, holder.binding.name, ThemeController.current(), visible); - PlayerSettingsRowTheme.applySwitch(holder.binding.visible, ThemeController.current()); holder.binding.root.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition())); holder.binding.visible.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition())); holder.binding.visible.setNextFocusLeftId(R.id.visible); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/QuickSearchDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/QuickSearchDialog.java index 5c6606f1ee..41585e5a21 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/QuickSearchDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/QuickSearchDialog.java @@ -42,7 +42,6 @@ public class QuickSearchDialog extends BaseAlertDialog implements QuickAdapter.O private int progressCurrent; private int progressTotal; private boolean searchFinished; - private String currentSiteKey = ""; public QuickSearchDialog() { pending = new ArrayList<>(); @@ -57,12 +56,6 @@ public class QuickSearchDialog extends BaseAlertDialog implements QuickAdapter.O return this; } - public QuickSearchDialog currentSiteKey(String currentSiteKey) { - this.currentSiteKey = currentSiteKey == null ? "" : currentSiteKey; - if (adapter != null) adapter.setCurrentSiteKey(this.currentSiteKey); - return this; - } - public QuickSearchDialog dismissListener(DialogInterface.OnDismissListener dismissListener) { this.dismissListener = dismissListener; return this; @@ -108,7 +101,6 @@ public class QuickSearchDialog extends BaseAlertDialog implements QuickAdapter.O binding.recycler.setItemAnimator(null); binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 8)); binding.recycler.setAdapter(adapter = new QuickAdapter(this)); - adapter.setCurrentSiteKey(currentSiteKey); adapter.setWidth(panelWidth - ResUtil.dp2px(32)); adapter.setNextFocus(0, 0); updateProgress(); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java index 223b63f0da..3f23685298 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java @@ -2,12 +2,10 @@ package com.fongmi.android.tv.ui.dialog; import android.app.Dialog; import android.graphics.Color; -import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.os.Bundle; import android.os.SystemClock; import android.text.TextUtils; -import android.view.Gravity; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; @@ -18,6 +16,7 @@ import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.LinearLayoutCompat; +import androidx.core.content.ContextCompat; import androidx.fragment.app.FragmentActivity; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -33,11 +32,8 @@ import com.fongmi.android.tv.databinding.DialogSiteBinding; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.impl.SiteListener; import com.fongmi.android.tv.setting.SiteGroupOrderStore; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.ui.adapter.SiteAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; -import com.fongmi.android.tv.ui.helper.SiteDialogTheme; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.KeyUtil; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; @@ -52,23 +48,6 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi private static final int GRID_COUNT = 3; private static final String TAG = "site_dialog"; private static final int INITIAL_BATCH = 48; - /** 站点卡片高度,必须与 adapter_site*.xml 的 layout_height 一致。 */ - private static final int ROW_HEIGHT_DP = 52; - /** 网格行间距,必须与 SpaceItemDecoration(GRID_COUNT, 16) 的 16 一致。 */ - private static final int ROW_SPACING_DP = 16; - /** - * 面板占屏宽的比例,对齐仓库既有大弹窗惯例(CacheManagementDialog.SCREEN_FRACTION = 0.9f)。 - * 这里刻意不再叠加 dp 上限:真机电视常见 density 1.0,1920px 屏即 1920dp,旧上限 1180dp - * 会把面板压到只剩 61% 屏宽,分组标签行放不下、末尾标签被硬裁。 - */ - private static final float PANEL_WIDTH_RATIO = 0.9f; - /** 极窄屏兜底宽度,避免面板窄到无法阅读。 */ - private static final int PANEL_MIN_WIDTH_DP = 480; - /** - * 面板高度占可用高度的上限。同样对齐 0.9 惯例,让一屏能多显示几行站点; - * 网格高度仍按整数行收敛,不会把末行裁半截。 - */ - private static final float PANEL_MAX_HEIGHT_RATIO = 0.9f; private static String selectedGroup = ""; @@ -135,9 +114,6 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi initView(); initEvent(); dialog.setOnKeyListener((d, keyCode, event) -> onDialogKey(keyCode, event)); - ThemeController.bindDialog(dialog); - SiteDialogTheme.applyShell(binding.getRoot(), binding.keyword, binding.config, binding.search, - binding.change, binding.select, binding.cancel, binding.mode); return dialog; } @@ -168,10 +144,6 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi directDialog.show(); log("show call end cost=%sms total=%sms", cost(showDialogStart), cost()); applyWindow(directDialog.getWindow()); - ThemeController.bindDialog(directDialog); - SiteDialogTheme.applyShell(binding.getRoot(), binding.keyword, binding.config, binding.search, - binding.change, binding.select, binding.cancel, binding.mode); - TouchOptimizationHelper.sync(directDialog); log("window applied total=%sms", cost()); } @@ -185,9 +157,6 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi long start = System.currentTimeMillis(); setRootWidth(); setRecyclerHeight(INITIAL_BATCH); - binding.header.setVisibility(View.VISIBLE); - binding.title.setText(action ? R.string.site_dialog_title_action : R.string.site_dialog_title); - binding.count.setVisibility(View.GONE); binding.searchBar.setVisibility(View.GONE); binding.keyword.setVisibility(View.GONE); binding.actionGap.setVisibility(View.GONE); @@ -197,8 +166,6 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi binding.select.setVisibility(action ? View.VISIBLE : View.GONE); binding.cancel.setVisibility(action ? View.VISIBLE : View.GONE); binding.mode.setVisibility(View.GONE); - SiteDialogTheme.applyShell(binding.getRoot(), binding.keyword, binding.config, binding.search, - binding.change, binding.select, binding.cancel, binding.mode); setActionEnabled(false); binding.recycler.setAdapter(null); binding.recycler.setItemAnimator(null); @@ -227,35 +194,18 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi long layoutStart = System.currentTimeMillis(); setType(type); setRecyclerView(); + setRecyclerHeight(adapter.getItemCount()); setMode(); setGroupView(); - setRecyclerHeight(adapter.getItemCount()); setActionEnabled(true); - updateCountText(); log("view configured cost=%sms total=%sms", cost(layoutStart), cost()); runAfterFirstPreDraw("list preDraw", () -> { if (adapter != null) adapter.showAll(); log("list expanded total=%sms items=%s", cost(), adapter == null ? -1 : adapter.getItemCount()); - // 首帧后 wrap_content 的标题/分组行才有真实高度,此时再收敛一次网格高度, - // 面板高度才是「内容 + 整数行网格」,不会在屏幕外多留一行。 - if (binding != null && adapter != null) { - setRecyclerHeight(adapter.getItemCount()); - updateCountText(); - } focusSelectedSite(); }); } - private void updateCountText() { - if (binding == null || adapter == null) return; - int count = adapter.getItemCount(); - // 走视图自己的 context:TV 版是裸 Dialog(showDirect),Fragment 从未 attach 到 - // FragmentManager,Fragment.getString()/requireContext() 会抛 - // IllegalStateException: not attached to a context。 - binding.count.setText(binding.getRoot().getContext().getString(R.string.site_dialog_count, count)); - binding.count.setVisibility(count > 0 ? View.VISIBLE : View.GONE); - } - @Override protected void initEvent() { binding.config.setOnClickListener(v -> { @@ -280,7 +230,6 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi setRecyclerView(); setRecyclerHeight(adapter.getItemCount()); setMode(); - updateCountText(); setWidth(); } }); @@ -296,75 +245,17 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi } private void setRecyclerHeight(int count) { - // 面板不再铺满:网格高度收敛到整数行,多余的行交给滚动,避免最后一行被裁掉半截。 - // 高度在这里显式给定,因此不再需要 maxHeight 兜底(Integer.MAX_VALUE 还会被 - // CustomRecyclerView 折成 AT_MOST 的畸形 spec)。 + // 全屏模式下,recycler 由布局 layout_weight 决定高度,无需动态计算 ViewGroup.LayoutParams params = binding.recycler.getLayoutParams(); - params.height = gridHeight(count); + params.height = ViewGroup.LayoutParams.MATCH_PARENT; binding.recycler.setLayoutParams(params); - } - - private int gridHeight(int count) { - int rows = Math.max(1, (Math.max(0, count) + getCount() - 1) / getCount()); - int row = ResUtil.dp2px(ROW_HEIGHT_DP); - int spacing = ResUtil.dp2px(ROW_SPACING_DP); - int budget = gridBudgetHeight(); - if (budget < row) return row; - int fitRows = Math.max(1, (budget + spacing) / (row + spacing)); - int visibleRows = Math.min(rows, fitRows); - return visibleRows * row + (visibleRows - 1) * spacing; - } - - /** 网格高度预算 = 可用高度 × 面板高度上限 − 面板里除网格以外的固定内容高度。 */ - private int gridBudgetHeight() { - return Math.max(0, Math.round(windowVisibleHeight() * PANEL_MAX_HEIGHT_RATIO) - panelChromeHeight()); - } - - /** - * 窗口实际可用高度。优先用 decor 的可见显示区域:它已经扣掉了系统栏, - * 比直接用屏幕高度更接近面板真正能占的高度;拿不到时退回屏幕高度。 - */ - private int windowVisibleHeight() { - View root = binding == null ? null : binding.getRoot(); - if (root != null) { - Rect frame = new Rect(); - root.getWindowVisibleDisplayFrame(frame); - if (frame.height() > 0) return frame.height(); - } - return ResUtil.getScreenHeight(getDialogActivity()); - } - - /** - * 面板除网格以外的固定高度。按实际布局测量,避免把 dialog_site.xml 的尺寸写死成第二份常量: - * 改布局的人不需要记得同步这里。 - */ - private int panelChromeHeight() { - if (binding == null) return 0; - int chrome = binding.getRoot().getPaddingTop() + binding.getRoot().getPaddingBottom(); - chrome += fixedHeight(binding.header); - chrome += fixedHeight(binding.searchBar); - chrome += fixedHeight(binding.groupScroll); - chrome += fixedHeight(binding.groupHint); - if (binding.recycler.getParent() instanceof View container && container.getLayoutParams() instanceof ViewGroup.MarginLayoutParams margins) { - chrome += Math.max(0, margins.topMargin); - } - return chrome; - } - - private int fixedHeight(View view) { - if (view == null || view.getVisibility() == View.GONE) return 0; - int height = view.getMeasuredHeight(); - if (height <= 0 && view.getLayoutParams() != null) height = Math.max(0, view.getLayoutParams().height); - if (view.getLayoutParams() instanceof ViewGroup.MarginLayoutParams margins) height += Math.max(0, margins.topMargin); - return height; + binding.recycler.setMaxHeight(Integer.MAX_VALUE); } private void setRootWidth() { ViewGroup.LayoutParams params = binding.getRoot().getLayoutParams(); - if (params == null) params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - // 宽度填满窗口(窗口本身已按屏宽比例收窄),高度贴合内容。 + if (params == null) params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.width = ViewGroup.LayoutParams.MATCH_PARENT; - params.height = ViewGroup.LayoutParams.WRAP_CONTENT; binding.getRoot().setLayoutParams(params); } @@ -396,10 +287,8 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi private void setActionEnabled(boolean enabled) { binding.search.setEnabled(enabled); binding.change.setEnabled(enabled); - // Keep bulk actions in the D-pad focus chain even in plain switch mode. - // setType() controls whether they are clickable; enabled only reflects loading state. - binding.select.setEnabled(enabled); - binding.cancel.setEnabled(enabled); + binding.select.setEnabled(enabled && type > 0); + binding.cancel.setEnabled(enabled && type > 0); } @Override @@ -508,22 +397,12 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi window.setWindowAnimations(0); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); WindowManager.LayoutParams params = window.getAttributes(); - // 居中浮层面板:四周留出安全边距,高度贴合内容,不再铺满整屏。 - params.width = panelWidth(); - params.height = WindowManager.LayoutParams.WRAP_CONTENT; - params.gravity = Gravity.CENTER; - params.dimAmount = 0.62f; - window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + params.width = WindowManager.LayoutParams.MATCH_PARENT; + params.height = WindowManager.LayoutParams.MATCH_PARENT; window.setAttributes(params); window.setLayout(params.width, params.height); } - private int panelWidth() { - int screen = ResUtil.getScreenWidth(getDialogActivity()); - int min = Math.min(ResUtil.dp2px(PANEL_MIN_WIDTH_DP), screen); - return Math.max(min, Math.round(screen * PANEL_WIDTH_RATIO)); - } - private void runAfterFirstPreDraw(String label, Runnable action) { View root = binding == null ? null : binding.getRoot(); if (root == null) { @@ -599,17 +478,16 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi private androidx.appcompat.widget.AppCompatTextView getGroupView(String group, String text) { androidx.appcompat.widget.AppCompatTextView button = new androidx.appcompat.widget.AppCompatTextView(getDialogActivity()); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - // 间距与内边距收紧到 10dp/14dp:面板 90% 宽时 14 个标签刚好放得下, - // 不再让末尾标签被面板右缘裁掉半个(超出时仍由 groupScroll 横向滚动兜底)。 - params.setMarginEnd(ResUtil.dp2px(10)); + params.setMarginEnd(ResUtil.dp2px(12)); button.setLayoutParams(params); button.setTag(group); button.setText(text); button.setSingleLine(true); button.setAllCaps(false); button.setGravity(android.view.Gravity.CENTER); - button.setPadding(ResUtil.dp2px(14), ResUtil.dp2px(8), ResUtil.dp2px(14), ResUtil.dp2px(8)); - SiteDialogTheme.applyGroup(button); + button.setPadding(ResUtil.dp2px(16), ResUtil.dp2px(8), ResUtil.dp2px(16), ResUtil.dp2px(8)); + button.setTextColor(ContextCompat.getColorStateList(getDialogActivity(), R.color.selector_group_text)); + button.setBackgroundResource(R.drawable.selector_group_button); button.setFocusable(true); button.setClickable(true); button.setNextFocusDownId(binding.recycler.getId()); @@ -729,7 +607,6 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi updateGroupView(); adapter.filter(selectedGroup, ""); setRecyclerHeight(adapter.getItemCount()); - updateCountText(); scrollRecyclerToTop(); centerGroup(view); } @@ -756,7 +633,6 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi button.setContentDescription(TextUtils.isEmpty(group) ? text : getDialogActivity().getString(description, text)); view.setAlpha(groupReordering && !reordering ? 0.55f : 1.0f); view.setSelected(selected); - SiteDialogTheme.applyGroup(button); } } @@ -809,3 +685,4 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi return -1; } } + diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java deleted file mode 100644 index 128a6e1276..0000000000 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java +++ /dev/null @@ -1,533 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Dialog; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; -import android.widget.Button; -import android.widget.HorizontalScrollView; -import android.widget.LinearLayout; -import android.widget.ScrollView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeColorPickerDialog; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeEditor; -import com.fongmi.android.tv.theme.ThemeEditorUi; -import com.fongmi.android.tv.theme.ThemePaletteStyle; -import com.fongmi.android.tv.theme.ThemePresets; -import com.fongmi.android.tv.theme.ThemePreviewView; -import com.fongmi.android.tv.theme.ThemeProfile; -import com.fongmi.android.tv.theme.ThemeProfileCodec; -import com.fongmi.android.tv.theme.ThemeProfileStore; -import com.fongmi.android.tv.theme.ThemeTokens; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Util; - -import java.util.EnumMap; - -/** Locally themed editing session. Only Save publishes the draft to the rest of the app. */ -public final class ThemeDialog extends DialogFragment implements ThemePreviewView.Callbacks { - - private final EnumMap cards = new EnumMap<>(ThemePresets.Preset.class); - private final EnumMap styles = new EnumMap<>(ThemePaletteStyle.class); - private ThemeEditor editor; - private ThemeTokens previewTokens; - private boolean dark; - private boolean modeChanged; - private int wallpaperColor; - private int savedScroll; - /** - * System-bar insets reported by the dialog window, or {@code null} before they arrive. - * - *

{@code onStart} runs before the window is always laid out, so the insets can still - * be missing when the window is first sized; the listener installed in {@link #onStart} - * re-sizes the window once they are known. - */ - private ThemeDialogLayout.Insets windowInsets; - private LinearLayout root; - private ThemePreviewView panel; - private ScrollView scroll; - private TextView title; - private TextView note; - private TextView presetTitle; - private TextView paletteTitle; - private TextView modeTitle; - private TextView status; - private Button lightButton; - private Button darkButton; - private Button importButton; - private Button resetButton; - private Button cancelButton; - private Button saveButton; - - public static void show(Fragment fragment) { - new ThemeDialog().show(fragment.getChildFragmentManager(), ThemeDialog.class.getSimpleName()); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - editor = ThemeEditor.load(); - int savedMode = Setting.getThemeMode(); - dark = savedMode == 1 || (savedMode < 0 && ThemeController.isNight(requireContext())); - wallpaperColor = Setting.getWallColor(); - if (savedInstanceState != null) { - dark = savedInstanceState.getBoolean("preview_dark", dark); - modeChanged = savedInstanceState.getBoolean("mode_changed", false); - savedScroll = savedInstanceState.getInt("preview_scroll", 0); - String draft = savedInstanceState.getString("preview_draft"); - if (draft != null) { - try { - editor.replace(ThemeProfileCodec.parse(draft)); - } catch (RuntimeException ignored) { /* Invalid saved UI state must not block opening settings. */ } - } - } - previewTokens = editor.preview(dark, wallpaperColor); - root = ThemeEditorUi.column(requireContext()); - // This subtree belongs to the draft, not the builder's persisted-theme binder. - root.setTag("webhtv:ignore"); - ThemeEditorUi.padding(root, 18, 16); - buildHeader(); - - scroll = new ScrollView(requireContext()); - scroll.setClipToPadding(false); - LinearLayout content = ThemeEditorUi.column(requireContext()); - content.setPadding(0, 0, 0, dp(12)); - presetTitle = heading(R.string.theme_editor_presets); - content.addView(presetTitle, ThemeEditorUi.fullWidth(requireContext(), 14)); - // Default is first in the very first configuration group, ahead of palette styles. - content.addView(buildPresetRow(), ThemeEditorUi.fullWidth(requireContext(), 10)); - content.addView(buildModeRow(), ThemeEditorUi.fullWidth(requireContext(), 14)); - paletteTitle = heading(R.string.theme_editor_palette_style); - content.addView(paletteTitle, ThemeEditorUi.fullWidth(requireContext(), 12)); - content.addView(buildPaletteRow(), ThemeEditorUi.fullWidth(requireContext(), 8)); - panel = ThemePreviewView.createPanel(requireContext(), editor, dark, previewTokens, this); - content.addView(panel); - scroll.addView(content); - root.addView(scroll, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); - buildFooter(); - render(); - // Custom actions avoid AlertDialog's unconditional positive-button dismissal on save failure. - return new WebHtvAlertDialogBuilder(requireContext()).setView(root).create(); - } - - private void buildHeader() { - LinearLayout header = ThemeEditorUi.row(requireContext()); - title = ThemeEditorUi.text(requireContext(), getString(R.string.setting_theme_color), 22, previewTokens.colorOnSurface()); - ThemeEditorUi.heading(title); - header.addView(title, ThemeEditorUi.weighted()); - importButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_import); - importButton.setOnClickListener(view -> ThemeImportDialog.show(this, profile -> { - ThemeEditor.Result result = editor.replace(profile); - if (result.success()) render(); - else setStatus(result.error()); - })); - header.addView(importButton); - root.addView(header); - note = ThemeEditorUi.text(requireContext(), getString(R.string.theme_editor_inline_hint), 12, previewTokens.colorOnSurfaceVariant()); - root.addView(note, ThemeEditorUi.fullWidth(requireContext(), 6)); - } - - private View buildPresetRow() { - LinearLayout row = ThemeEditorUi.row(requireContext()); - row.setPadding(dp(2), dp(2), dp(2), dp(2)); - for (ThemePresets.Preset preset : ThemePresets.Preset.values()) { - PresetCard card = new PresetCard(preset); - cards.put(preset, card); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(128), ViewGroup.LayoutParams.WRAP_CONTENT); - params.setMarginEnd(dp(10)); - row.addView(card.view, params); - } - return horizontalScroll(row); - } - - private View buildModeRow() { - LinearLayout row = ThemeEditorUi.row(requireContext()); - modeTitle = heading(R.string.theme_editor_edit_mode); - row.addView(modeTitle, ThemeEditorUi.weighted()); - lightButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_light); - darkButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_dark); - lightButton.setOnClickListener(view -> selectMode(false)); - darkButton.setOnClickListener(view -> selectMode(true)); - LinearLayout.LayoutParams lightParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - lightParams.setMarginEnd(dp(8)); - row.addView(lightButton, lightParams); - row.addView(darkButton); - return row; - } - - private View buildPaletteRow() { - LinearLayout row = ThemeEditorUi.row(requireContext()); - row.setPadding(dp(2), dp(2), dp(2), dp(2)); - for (ThemePaletteStyle style : ThemePaletteStyle.values()) { - Button button = ThemeEditorUi.button(requireContext(), paletteLabel(style)); - button.setOnClickListener(view -> { - editor.replace(ThemePresets.withPaletteStyle(editor.draft(), style)); - render(); - }); - styles.put(style, button); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.setMarginEnd(dp(8)); - row.addView(button, params); - } - return horizontalScroll(row); - } - - private View horizontalScroll(View child) { - HorizontalScrollView scroll = new HorizontalScrollView(requireContext()); - scroll.setHorizontalScrollBarEnabled(false); - scroll.addView(child); - return scroll; - } - - private void buildFooter() { - status = ThemeEditorUi.text(requireContext(), "", 12, previewTokens.colorOnSurfaceVariant()); - status.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE); - root.addView(status, ThemeEditorUi.fullWidth(requireContext(), 10)); - LinearLayout actions = ThemeEditorUi.row(requireContext()); - cancelButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_cancel); - resetButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_reset); - saveButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_apply); - cancelButton.setOnClickListener(view -> dismiss()); - resetButton.setOnClickListener(view -> { editor.reset(); render(); }); - saveButton.setOnClickListener(view -> applyDraft()); - for (Button button : new Button[]{cancelButton, resetButton, saveButton}) { - LinearLayout.LayoutParams params = ThemeEditorUi.weighted(); - if (button != saveButton) params.setMarginEnd(dp(8)); - actions.addView(button, params); - } - root.addView(actions, ThemeEditorUi.fullWidth(requireContext(), 8)); - } - - /** One snapshot drives every real control; no view is recreated during a colour edit or drag. */ - private void render() { - previewTokens = editor.preview(dark, wallpaperColor); - ThemeTokens tokens = previewTokens; - title.setTextColor(tokens.colorOnSurface()); - note.setTextColor(tokens.colorOnSurfaceVariant()); - for (TextView heading : new TextView[]{presetTitle, paletteTitle, modeTitle}) { - heading.setTextColor(tokens.colorOnSurfaceVariant()); - } - ThemeEditorUi.buttonColors(importButton, tokens.colorSecondaryContainer(), tokens.colorOnSecondaryContainer(), tokens, false); - modeButton(lightButton, !dark); - modeButton(darkButton, dark); - ThemeProfile draft = editor.draft(); - for (PresetCard card : cards.values()) card.render(draft); - for (ThemePaletteStyle style : styles.keySet()) { - boolean selected = style.id().equals(draft.paletteStyle); - modeButton(styles.get(style), selected); - } - if (panel != null) panel.render(editor, dark, tokens); - ThemeEditorUi.buttonColors(cancelButton, tokens.colorSurfaceContainer(), tokens.colorOnSurface(), tokens, false); - ThemeEditorUi.buttonColors(resetButton, tokens.colorSurfaceContainer(), tokens.colorOnSurface(), tokens, false); - ThemeEditorUi.buttonColors(saveButton, tokens.colorPrimary(), tokens.colorOnPrimary(), tokens, false); - setStatus(getString(editor.isDirty() ? R.string.theme_editor_dirty : R.string.theme_editor_saved_state)); - Dialog dialog = getDialog(); - if (dialog != null && dialog.getWindow() != null) { - Window window = dialog.getWindow(); - window.setBackgroundDrawable(ThemeEditorUi.shape(requireContext(), - ThemeEditorUi.withAlpha(tokens.colorSurface(), tokens.dialogOpacity()), 0, 0, 24)); - window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - WindowManager.LayoutParams params = window.getAttributes(); - params.dimAmount = ThemeEditorUi.alpha(tokens.colorScrim()); - window.setAttributes(params); - } - } - - private void modeButton(Button button, boolean selected) { - ThemeTokens tokens = previewTokens; - ThemeEditorUi.buttonColors(button, selected ? tokens.colorPrimaryContainer() : tokens.colorSurfaceContainer(), - selected ? tokens.colorOnPrimaryContainer() : tokens.colorOnSurface(), tokens, selected); - } - - private final class PresetCard { - final ThemePresets.Preset preset; - final ThemeProfile profile; - final ThemeTokens light; - final ThemeTokens night; - final LinearLayout view; - final TextView name; - final TextView detail; - final View[] swatches = new View[4]; - - PresetCard(ThemePresets.Preset preset) { - this.preset = preset; - profile = preset.profile(); - ThemeEditor sample = new ThemeEditor(profile); - light = sample.preview(false, wallpaperColor); - night = sample.preview(true, wallpaperColor); - view = ThemeEditorUi.column(requireContext()); - ThemeEditorUi.padding(view, 12, 12); - view.setFocusable(true); - view.setTag("theme-preset:" + preset.name()); - view.setOnClickListener(ignored -> { - ThemeProfile next = profile.copy(); - if (preset != ThemePresets.Preset.DEFAULT) next.name = getString(preset.label); - next.mode = editor.draft().mode; - editor.replace(next); - ThemeDialog.this.render(); - }); - name = ThemeEditorUi.text(requireContext(), getString(preset.label), 14, light.colorOnSurface()); - ThemeEditorUi.heading(name); - name.setSingleLine(true); - view.addView(name); - detail = ThemeEditorUi.text(requireContext(), "", 10, light.colorOnSurfaceVariant()); - detail.setSingleLine(true); - view.addView(detail, ThemeEditorUi.fullWidth(requireContext(), 6)); - LinearLayout row = ThemeEditorUi.row(requireContext()); - for (int i = 0; i < swatches.length; i++) { - swatches[i] = new View(requireContext()); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, dp(18), 1); - if (i < swatches.length - 1) params.setMarginEnd(dp(4)); - row.addView(swatches[i], params); - } - view.addView(row, ThemeEditorUi.fullWidth(requireContext(), 10)); - } - - void render(ThemeProfile draft) { - ThemeTokens tokens = dark ? night : light; - boolean active = ThemePresets.find(draft) == preset; - boolean modified = false; - if (active) { - ThemeProfile comparable = draft.copy(); - comparable.name = profile.name; - comparable.mode = profile.mode; - modified = !ThemeProfileCodec.encode(comparable).equals(ThemeProfileCodec.encode(profile)); - } - name.setText((active ? "✓ " : "") + getString(preset.label)); - name.setTextColor(tokens.colorOnSurface()); - detail.setText(modified ? R.string.theme_editor_preset_modified - : preset == ThemePresets.Preset.DEFAULT ? R.string.theme_editor_preset_original - : preset == ThemePresets.Preset.WALLPAPER ? R.string.theme_editor_preset_dynamic : R.string.theme_editor_preset_pair); - detail.setTextColor(tokens.colorOnSurfaceVariant()); - view.setSelected(active); - view.setContentDescription(getString(preset.label) + (active ? " · " + getString(R.string.theme_editor_selected) : "")); - view.setBackground(ThemeEditorUi.interactive(requireContext(), tokens.colorSurface(), tokens.colorOutline(), - previewTokens.colorFocus(), active)); - int[] colors = {tokens.colorPrimary(), tokens.colorSecondaryContainer(), tokens.colorSurfaceContainerHigh(), tokens.colorSuccess()}; - for (int i = 0; i < swatches.length; i++) swatches[i].setBackground(ThemeEditorUi.shape(requireContext(), colors[i], 0, 0, 5)); - } - } - - @Override - public void onStart() { - super.onStart(); - Dialog dialog = getDialog(); - if (dialog == null) return; - configureWindow(dialog); - render(); - // The window can be laid out before the platform reports its system-bar insets, in - // which case the first sizing falls back to the display bounds and the footer row - // ends up under the navigation bar. Re-sizing when the real insets arrive is what - // makes the fix independent of that ordering. - Window window = dialog.getWindow(); - View decorView = window == null ? null : window.getDecorView(); - if (decorView != null) { - ViewCompat.setOnApplyWindowInsetsListener(decorView, (view, insets) -> { - androidx.core.graphics.Insets bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); - ThemeDialogLayout.Insets next = new ThemeDialogLayout.Insets( - bars.left, bars.top, bars.right, bars.bottom); - if (!next.equals(windowInsets)) { - windowInsets = next; - Dialog current = getDialog(); - if (current != null && current.getWindow() != null) configureWindow(current); - } - return insets; - }); - ViewCompat.requestApplyInsets(decorView); - } - if (savedScroll > 0) scroll.post(() -> scroll.scrollTo(0, savedScroll)); - } - - private void configureWindow(Dialog dialog) { - Window window = dialog.getWindow(); - if (window == null) return; - int margin = ResUtil.dp2px(ThemeDialogLayout.marginDp(Util.isLeanback())); - View decorView = window.getDecorView(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); - ThemeDialogLayout.Area area = ThemeDialogLayout.safeArea(screenWidth, screenHeight, - systemBarInsets(screenWidth, screenHeight)); - int width = ThemeDialogLayout.width(area.width(), margin); - int height = ThemeDialogLayout.height(area.height(), margin); - WindowManager.LayoutParams params = window.getAttributes(); - params.width = width; - params.height = height; - params.gravity = Gravity.CENTER; - Drawable panel = ThemeDialogLayout.panelBackground(decorView.getBackground()); - if (panel != null) window.setBackgroundDrawable(panel); - decorView.setPadding(0, 0, 0, 0); - window.setAttributes(params); - window.setLayout(width, height); - if (root != null && root.getLayoutParams() != null) { - ViewGroup.LayoutParams rootParams = root.getLayoutParams(); - rootParams.width = ViewGroup.LayoutParams.MATCH_PARENT; - rootParams.height = ViewGroup.LayoutParams.MATCH_PARENT; - root.setLayoutParams(rootParams); - } - } - - /** - * The system bars the editor window has to stay clear of. - * - *

Insets reported by the dialog window are exact and preferred. Until they arrive, the - * space the host already gave up to the bars (the display minus its content view) is - * used; that value also covers the host's own chrome, so it can only over-reserve, never - * clip. A host content view that has not been laid out yet reports nothing, and the - * display is used until the listener re-sizes the window. - */ - private ThemeDialogLayout.Insets systemBarInsets(int screenWidth, int screenHeight) { - // 1) The dialog window's own report, which is the exact value whenever the platform - // actually fills it in. - if (windowInsets != null && !windowInsets.isEmpty()) return windowInsets; - // 2) The host activity's root window insets. This is the same source Util.isFullscreen - // already trusts, and it stays correct while a floating dialog window keeps reporting - // systemBars() = [0,0,0,0] on API 28. - ThemeDialogLayout.Insets host = hostWindowInsets(); - if (!host.isEmpty()) return host; - // 3) The space the host content view has already given up to the bars. - ThemeDialogLayout.Insets fallback = - ThemeDialogLayout.Insets.ofContent(screenWidth, screenHeight, contentWidth(), contentHeight()); - return fallback.isEmpty() ? ThemeDialogLayout.Insets.none() : fallback; - } - - /** - * The system bars the host activity has to keep clear. - * - *

Used because a floating dialog window is not guaranteed to report its own insets: on - * API 28 the editor's window reports {@code systemBars() = [0,0,0,0]} while the navigation bar - * is on screen, and accepting that as "no bars" is what pushed the footer row under it.

- */ - private ThemeDialogLayout.Insets hostWindowInsets() { - try { - WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(requireActivity().getWindow().getDecorView()); - if (insets == null) return ThemeDialogLayout.Insets.none(); - androidx.core.graphics.Insets bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); - return new ThemeDialogLayout.Insets(bars.left, bars.top, bars.right, bars.bottom); - } catch (RuntimeException error) { - return ThemeDialogLayout.Insets.none(); - } - } - - /** - * The host activity's content view size. - * - *

Its difference from the display is the system-bar space the host gave up, which is - * the safe-area fallback while the dialog window's own insets are still unknown. - */ - private int contentWidth() { - View content = requireActivity().findViewById(android.R.id.content); - return content == null ? 0 : content.getWidth(); - } - - private int contentHeight() { - View content = requireActivity().findViewById(android.R.id.content); - return content == null ? 0 : content.getHeight(); - } - - @Override - public void onSaveInstanceState(@NonNull Bundle state) { - super.onSaveInstanceState(state); - if (editor != null) state.putString("preview_draft", ThemeProfileCodec.encode(editor.draft())); - state.putBoolean("preview_dark", dark); - state.putBoolean("mode_changed", modeChanged); - state.putInt("preview_scroll", scroll == null ? savedScroll : scroll.getScrollY()); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - cards.clear(); - styles.clear(); - root = null; - panel = null; - scroll = null; - // The next window reports its own insets; a stale value from this one must not be reused. - windowInsets = null; - } - - @Override - public void onColorSlotClicked(ThemeEditor.Slot slot, boolean slotDark) { - String initial = ThemeEditorUi.hex(ThemePreviewView.colorOf(previewTokens, slot)); - ThemeColorPickerDialog.create(requireContext(), getString(ThemePreviewView.labelOf(slot)), initial, previewTokens, hex -> { - ThemeEditor.Result result = editor.set(slot, slotDark, hex, 0f); - if (result.success()) render(); - else setStatus(result.error()); - }).show(); - } - - @Override - public void onDraftChanged() { - render(); - } - - private void selectMode(boolean dark) { - this.dark = dark; - modeChanged = true; - editor.setMode(dark ? ThemeProfile.MODE_DARK : ThemeProfile.MODE_LIGHT); - render(); - } - - private void applyDraft() { - ThemeProfileStore.ApplyResult result = editor.apply(); - if (!result.success()) { - setStatus(getString(R.string.theme_editor_save_failed, result.error())); - return; - } - if (modeChanged) { - Setting.putThemeMode(dark ? 1 : 0); - ThemeController.applyNightModeToApp(); - } - dismissAllowingStateLoss(); - // AppearanceDialog already publishes the refresh event. Do not post a second recreation. - if (getParentFragment() instanceof AppearanceDialog appearance) appearance.onThemeProfileApplied(); - else RefreshEvent.theme(); - } - - private TextView heading(int label) { - TextView heading = ThemeEditorUi.text(requireContext(), getString(label), 13, previewTokens.colorOnSurfaceVariant()); - ThemeEditorUi.heading(heading); - return heading; - } - - private void setStatus(String message) { - status.setTextColor(previewTokens.colorOnSurfaceVariant()); - status.setText(message == null ? "" : message); - } - - private int paletteLabel(ThemePaletteStyle style) { - return switch (style) { - case TONAL_SPOT -> R.string.theme_editor_palette_tonal; - case VIBRANT -> R.string.theme_editor_palette_vibrant; - case EXPRESSIVE -> R.string.theme_editor_palette_expressive; - case RAINBOW -> R.string.theme_editor_palette_rainbow; - case FRUIT_SALAD -> R.string.theme_editor_palette_fruit_salad; - case FIDELITY -> R.string.theme_editor_palette_fidelity; - case CONTENT -> R.string.theme_editor_palette_content; - case NEUTRAL -> R.string.theme_editor_palette_neutral; - case MONOCHROME -> R.string.theme_editor_palette_monochrome; - }; - } - - private int dp(int value) { - return ThemeEditorUi.dp(requireContext(), value); - } -} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java index 3b37c959fe..4ee19b7cf8 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java @@ -16,10 +16,6 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Update; import com.fongmi.android.tv.databinding.DialogUpdateBinding; import com.fongmi.android.tv.impl.UpdateListener; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeTokens; -import com.fongmi.android.tv.theme.UpdateVersionRowTheme; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.AppVersion; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.MarkdownText; @@ -79,7 +75,7 @@ public class UpdateDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog_NoInset).setView(getBinding().getRoot()).setCancelable(false); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog_NoInset).setView(getBinding().getRoot()).setCancelable(false); } @Override @@ -170,7 +166,6 @@ public class UpdateDialog extends BaseAlertDialog { View content = stableChannel ? binding.stableContent : binding.betaContent; item.setSelected(expanded); content.setVisibility(expanded ? View.VISIBLE : View.GONE); - applyCardTheme(channel, expanded); if (stableChannel) { binding.stableVersion.setText(getVersion(update)); binding.stableStatus.setText(getStatus(update)); @@ -192,20 +187,6 @@ public class UpdateDialog extends BaseAlertDialog { } } - /** - * Paints one version card from the active palette; see - * {@link UpdateVersionRowTheme} for why the card cannot rely on the static selector. - */ - private void applyCardTheme(String channel, boolean expanded) { - ThemeTokens tokens = ThemeController.current(); - boolean stableChannel = Update.CHANNEL_STABLE.equals(channel); - if (stableChannel) { - UpdateVersionRowTheme.apply(binding.stableItem, binding.stableName, binding.stableVersion, binding.stableStatus, binding.stableExpand, binding.stableDesc, expanded, tokens); - } else { - UpdateVersionRowTheme.apply(binding.betaItem, binding.betaName, binding.betaVersion, binding.betaStatus, binding.betaExpand, binding.betaDesc, expanded, tokens); - } - } - private void renderAction() { Update update = getSelected(); if (update == null || !update.hasUpdate()) binding.cancel.setText(R.string.about_acknowledge); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java index 7015c07178..c30d9511c3 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java @@ -7,15 +7,15 @@ import android.view.WindowManager; import androidx.appcompat.app.AlertDialog; import com.fongmi.android.tv.App; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class WebDialog { private final AlertDialog dialog; private WebDialog(View view) { - this.dialog = new WebHtvAlertDialogBuilder(App.activity()).setView(view).create(); + this.dialog = new MaterialAlertDialogBuilder(App.activity()).setView(view).create(); this.dialog.setOnDismissListener((DialogInterface.OnDismissListener) view); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java index 95e0ed8760..4cfd9f7409 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java @@ -79,7 +79,7 @@ public class CollectFragment extends BaseFragment implements CustomScroller.Call private void setRecyclerView() { setGridSpec(); CustomSelector selector = new CustomSelector(); - selector.addPresenter(ListRow.class, new CustomRowPresenter(ResUtil.dpOf(R.dimen.webhtv_size_tv_card_gap), FocusHighlight.ZOOM_FACTOR_NONE, HorizontalGridView.FOCUS_SCROLL_ALIGNED), VodPresenter.class); + selector.addPresenter(ListRow.class, new CustomRowPresenter(16, FocusHighlight.ZOOM_FACTOR_NONE, HorizontalGridView.FOCUS_SCROLL_ALIGNED), VodPresenter.class); mBinding.recycler.setAdapter(new ItemBridgeAdapter(mAdapter = new ArrayObjectAdapter(selector))); mBinding.recycler.addOnScrollListener(mScroller = new CustomScroller(this)); mBinding.recycler.setHeader(getActivity(), R.id.result, R.id.recycler); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/FolderFragment.java b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/FolderFragment.java index 5f34a7ab85..026702ded0 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/FolderFragment.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/FolderFragment.java @@ -41,9 +41,6 @@ public class FolderFragment extends BaseFragment { private FragmentFolderBinding mBinding; private Boolean pendingFilterVisible; private Integer pendingContentRow; - private boolean pendingScrollToTop; - private int focusGeneration; - private int scrollGeneration; private Class mType; public static FolderFragment newInstance(String key, Class type) { @@ -101,7 +98,6 @@ public class FolderFragment extends BaseFragment { FragmentTransaction transaction = getChildFragmentManager().beginTransaction().replace(R.id.container, TypeFragment.newInstance(getKey(), mType.getTypeId(), mType.getStyle(), getExtend(), mType.isFolder(), getHistoryResumeCid(), getHistoryResumeKey(), getHistoryResumeTargetCid())); transaction.runOnCommit(this::applyPendingFilter); transaction.runOnCommit(this::applyPendingContentFocus); - transaction.runOnCommit(this::applyPendingScrollToTop); transaction.commit(); } @@ -153,12 +149,7 @@ public class FolderFragment extends BaseFragment { } public void requestContentFocus(int contentRow) { - requestContentFocus(contentRow, ++focusGeneration); - } - - public void requestContentFocus(int contentRow, int generation) { pendingContentRow = Math.max(0, contentRow); - focusGeneration = generation; applyPendingContentFocus(); } @@ -166,33 +157,12 @@ public class FolderFragment extends BaseFragment { if (pendingContentRow == null) return; TypeFragment child = getChild(); if (child == null) return; - child.requestContentFocus(pendingContentRow, focusGeneration); + child.requestContentFocus(pendingContentRow); pendingContentRow = null; } - public void scrollContentToTop() { - scrollContentToTop(++scrollGeneration); - } - - public void scrollContentToTop(int generation) { - pendingScrollToTop = true; - scrollGeneration = generation; - applyPendingScrollToTop(); - } - - private void applyPendingScrollToTop() { - if (!pendingScrollToTop) return; - TypeFragment child = getChild(); - if (child == null) return; - child.scrollContentToTop(scrollGeneration); - pendingScrollToTop = false; - } - public void clearContentFocusRequest() { pendingContentRow = null; - pendingScrollToTop = false; - focusGeneration++; - scrollGeneration++; TypeFragment child = getChild(); if (child != null) child.clearContentFocusRequest(); } @@ -201,11 +171,6 @@ public class FolderFragment extends BaseFragment { Optional.ofNullable(getChild()).ifPresent(TypeFragment::onRefresh); } - public boolean resetFilter() { - TypeFragment child = getChild(); - return child != null && child.resetFilter(); - } - public boolean canBack() { return getChildFragmentManager().getBackStackEntryCount() > 0; } @@ -217,15 +182,6 @@ public class FolderFragment extends BaseFragment { @Override public void setUserVisibleHint(boolean isVisibleToUser) { super.setUserVisibleHint(isVisibleToUser); - if (mBinding != null && !isVisibleToUser) { - clearContentFocusRequest(); - Optional.ofNullable(getChild()).ifPresent(f -> f.setUserVisibleHint(false)); - } - } - - @Override - public void onDestroyView() { - clearContentFocusRequest(); - super.onDestroyView(); + if (mBinding != null && !isVisibleToUser) Optional.ofNullable(getChild()).ifPresent(f -> f.setUserVisibleHint(false)); } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java index 8543e783f3..3b05bcc624 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java @@ -59,7 +59,6 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac private List mFilters; private Integer pendingContentRow; private int contentFocusGeneration; - private int scrollGeneration; private boolean headerVisible; private boolean filterVisible; @@ -156,7 +155,7 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac private void setRecyclerView() { CustomSelector selector = new CustomSelector(); selector.addPresenter(Vod.class, new VodPresenter(this, Style.list())); - selector.addPresenter(ListRow.class, new CustomRowPresenter(ResUtil.dpOf(R.dimen.webhtv_size_tv_card_gap), this::onContentHorizontalEdge), VodPresenter.class); + selector.addPresenter(ListRow.class, new CustomRowPresenter(16, this::onContentHorizontalEdge), VodPresenter.class); selector.addPresenter(ListRow.class, new CustomRowPresenter(8, FocusHighlight.ZOOM_FACTOR_NONE, HorizontalGridView.FOCUS_SCROLL_ALIGNED, true), FilterPresenter.class); mBinding.recycler.setAdapter(new ItemBridgeAdapter(mAdapter = new ArrayObjectAdapter(selector))); mBinding.recycler.setHeader(getActivity(), getParent().getScrollHeaderIds()); @@ -278,29 +277,6 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac else mBinding.swipeLayout.setRefreshing(true); } - public boolean resetFilter() { - if (mFilters.isEmpty()) return false; - boolean changed = !mExtends.isEmpty(); - for (Filter filter : mFilters) { - for (Value value : filter.getValue()) { - if (value.isSelected()) changed = true; - value.setSelected(false); - } - } - mExtends.clear(); - if (changed) { - // Rebuilding the rows is what makes the chips render as unselected; skip it when - // nothing was selected so a no-op long press does not scroll the panel back to row 0. - if (filterVisible) { - hideFilter(); - showFilter(); - } - onRefresh(); - Notify.show(R.string.filter_reset); - } - return changed; - } - public void onRefresh() { getVideo(); } @@ -308,7 +284,7 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac @Override public void onItemClick(Vod item) { if (item.isAction()) { - com.fongmi.android.tv.content.ActionCardHelper.handleAction(requireActivity(), getKey(), item.getAction()); + mViewModel.action(getKey(), item.getAction()); } else if (item.isFolder()) { getParent().openFolder(item.getId(), mExtends); headerVisible = mBinding.recycler.isHeaderVisible(); @@ -342,9 +318,6 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac public void onHiddenChanged(boolean hidden) { super.onHiddenChanged(hidden); if (hidden) { - scrollGeneration++; - contentFocusGeneration++; - pendingContentRow = null; mBinding.recycler.showHeader(); } else { if (headerVisible) mBinding.recycler.showHeader(); @@ -371,35 +344,15 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac return mBinding.recycler.requestFocus(); } - public void scrollContentToTop() { - scrollContentToTop(++scrollGeneration); - } - - public void scrollContentToTop(int generation) { - if (mBinding == null || mAdapter == null) return; - int target = filterVisible ? mFilters.size() : 0; - scrollGeneration = generation; - mBinding.recycler.post(() -> { - if (generation != scrollGeneration || mBinding == null || mAdapter == null || mAdapter.size() <= target || !isVisible() || getParentFragment() == null || !getParentFragment().isVisible()) return; - mBinding.recycler.showHeader(); - mBinding.recycler.scrollToPosition(target); - }); - } - public void requestContentFocus(int contentRow) { - requestContentFocus(contentRow, contentFocusGeneration + 1); - } - - public void requestContentFocus(int contentRow, int generation) { pendingContentRow = Math.max(0, contentRow); - contentFocusGeneration = generation; + contentFocusGeneration++; applyPendingContentFocus(); } public void clearContentFocusRequest() { pendingContentRow = null; contentFocusGeneration++; - scrollGeneration++; } private void applyPendingContentFocus() { @@ -429,12 +382,4 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac holder.itemView.requestFocus(); } - @Override - public void onDestroyView() { - pendingContentRow = null; - contentFocusGeneration++; - scrollGeneration++; - super.onDestroyView(); - } - } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java b/app/src/leanback/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java deleted file mode 100644 index 9e64b6ac90..0000000000 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.graphics.drawable.GradientDrawable; -import android.graphics.drawable.StateListDrawable; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeTokens; -import com.google.android.material.checkbox.MaterialCheckBox; - -/** Applies the active semantic palette to the TV site selector's programmatic states. */ -public final class SiteDialogTheme { - - private SiteDialogTheme() { - } - - public static void applyShell(View root, TextView keyword, ImageView... actions) { - ThemeTokens tokens = ThemeController.current(); - if (root != null) root.setBackground(shell(root.getContext(), tokens)); - if (keyword != null) { - keyword.setTextColor(tokens.colorOnSurface()); - keyword.setHintTextColor(tokens.colorOnSurfaceVariant()); - keyword.setBackground(input(keyword.getContext(), tokens)); - } - if (actions != null) for (ImageView action : actions) applyAction(action); - } - - public static void applySiteItem(View root, TextView text, MaterialCheckBox check) { - ThemeTokens tokens = ThemeController.current(); - if (root != null) root.setBackground(siteItem(root.getContext(), tokens)); - if (text != null) text.setTextColor(textColors(tokens)); - if (check != null) check.setButtonTintList(checkColors(tokens)); - } - - public static void updateSiteText(TextView text, boolean focused, boolean selected) { - if (text == null) return; - ThemeTokens tokens = ThemeController.current(); - text.setTextColor(focused ? tokens.colorOnPrimary() : selected ? tokens.colorPrimary() : tokens.colorOnSurface()); - } - - public static void applyGroup(TextView button) { - if (button == null) return; - ThemeTokens tokens = ThemeController.current(); - button.setTextColor(textColors(tokens)); - button.setBackground(groupButton(button.getContext(), tokens)); - } - - public static void applyAction(ImageView action) { - if (action == null) return; - ThemeTokens tokens = ThemeController.current(); - action.setImageTintList(actionColors(tokens)); - action.setBackground(actionBackground(action.getContext(), tokens)); - } - - private static GradientDrawable shell(Context context, ThemeTokens tokens) { - // 面板是浮在暗化背景上的独立层,需要一条可见的轮廓把它和背景分开; - // 全屏时代面板边缘就是屏幕边缘,0 描边看不出来,收窄成浮层后必须补回来。 - return rounded(context, tokens.colorSurfaceContainer(), tokens.colorOutlineVariant(), 22, 1); - } - - private static StateListDrawable input(Context context, ThemeTokens tokens) { - StateListDrawable result = new StateListDrawable(); - result.addState(new int[]{android.R.attr.state_focused}, rounded(context, tokens.colorPrimaryContainer(), tokens.colorPrimary(), 12, 1)); - result.addState(new int[]{}, rounded(context, tokens.colorSurfaceContainerHigh(), tokens.colorOutline(), 12, 1)); - return result; - } - - private static StateListDrawable siteItem(Context context, ThemeTokens tokens) { - StateListDrawable result = new StateListDrawable(); - result.addState(new int[]{android.R.attr.state_focused}, rounded(context, tokens.colorPrimary(), tokens.colorOnPrimary(), 12, 2)); - result.addState(new int[]{android.R.attr.state_selected}, rounded(context, tokens.colorPrimaryContainer(), tokens.colorPrimary(), 12, 2)); - // 常态填充必须是比面板高一层的容器色:面板用 colorSurfaceContainer, - // 卡片若也用同一色,卡片边界在面板上完全消失(只剩文字)。 - result.addState(new int[]{}, rounded(context, tokens.colorSurfaceContainerHigh(), tokens.colorOutlineVariant(), 12, 1)); - return result; - } - - private static StateListDrawable groupButton(Context context, ThemeTokens tokens) { - StateListDrawable result = new StateListDrawable(); - result.addState(new int[]{android.R.attr.state_focused}, rounded(context, tokens.colorPrimary(), tokens.colorOnPrimary(), 20, 2)); - result.addState(new int[]{android.R.attr.state_selected}, rounded(context, tokens.colorPrimaryContainer(), tokens.colorPrimary(), 20, 2)); - result.addState(new int[]{}, rounded(context, tokens.colorSurface(), tokens.colorOutline(), 20, 1)); - return result; - } - - private static StateListDrawable actionBackground(Context context, ThemeTokens tokens) { - StateListDrawable result = new StateListDrawable(); - result.addState(new int[]{android.R.attr.state_focused}, rounded(context, tokens.colorPrimaryContainer(), tokens.colorPrimary(), 12, 2)); - result.addState(new int[]{android.R.attr.state_selected}, rounded(context, tokens.colorPrimaryContainer(), tokens.colorPrimary(), 12, 2)); - result.addState(new int[]{}, rounded(context, tokens.colorSurfaceContainer(), tokens.colorOutline(), 12, 1)); - return result; - } - - private static ColorStateList textColors(ThemeTokens tokens) { - return new ColorStateList( - new int[][]{ - {android.R.attr.state_focused}, - {android.R.attr.state_selected}, - {}, - }, - new int[]{tokens.colorOnPrimary(), tokens.colorPrimary(), tokens.colorOnSurface()}); - } - - private static ColorStateList checkColors(ThemeTokens tokens) { - return new ColorStateList( - new int[][]{ - {android.R.attr.state_focused}, - {android.R.attr.state_checked}, - {}, - }, - new int[]{tokens.colorOnPrimary(), tokens.colorPrimary(), tokens.colorOnSurfaceVariant()}); - } - - private static ColorStateList actionColors(ThemeTokens tokens) { - return new ColorStateList( - new int[][]{ - {android.R.attr.state_focused}, - {android.R.attr.state_selected}, - {}, - }, - new int[]{tokens.colorPrimary(), tokens.colorPrimary(), tokens.colorOnSurfaceVariant()}); - } - - private static GradientDrawable rounded(Context context, int fill, int stroke, int radiusDp, int strokeDp) { - float density = context.getResources().getDisplayMetrics().density; - GradientDrawable drawable = new GradientDrawable(); - drawable.setShape(GradientDrawable.RECTANGLE); - drawable.setColor(fill); - drawable.setCornerRadius(radiusDp * density); - if (strokeDp > 0) drawable.setStroke(Math.max(1, Math.round(strokeDp * density)), stroke); - return drawable; - } -} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HistoryPresenter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HistoryPresenter.java index e5c73f0640..ca4f704f91 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HistoryPresenter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/HistoryPresenter.java @@ -81,7 +81,7 @@ public class HistoryPresenter extends Presenter { holder.binding.playback.setVisibility(!delete && item.hasPlaybackTime() ? View.VISIBLE : View.GONE); setProgress(holder.binding, item); holder.binding.delete.setVisibility(!delete ? View.GONE : View.VISIBLE); - holder.binding.remark.setVisibility(same ? View.INVISIBLE : View.VISIBLE); + holder.binding.remark.setVisibility(delete || same ? View.INVISIBLE : View.VISIBLE); ImgUtil.load(item.getVodName(), item.getVodPic(), holder.binding.image); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java index e4228db753..203400191c 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java @@ -11,17 +11,15 @@ import androidx.leanback.widget.Presenter; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.TmdbPerson; import com.fongmi.android.tv.databinding.AdapterTmdbCastBinding; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.ImgUtil; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.card.MaterialCardView; public class TmdbCastPresenter extends Presenter { - // 统一焦点规范(与 selector_video_item.xml / selector_tmdb_cast_focus.xml 一致): - // 常态 1dp @color/tv_item_normal_stroke,焦点环 3dp @color/tv_item_focus_ring 由 foreground 绘制。 - private static final int STROKE_NORMAL = 0x33FFFFFF; - private static final int STROKE_WIDTH_NORMAL_DP = 1; + private static final int CARD_BACKGROUND = 0xFF16202A; + private static final int STROKE_NORMAL = 0x26FFFFFF; + private static final int STROKE_FOCUSED = 0xFFFFFFFF; private final OnClickListener mListener; @@ -65,14 +63,10 @@ public class TmdbCastPresenter extends Presenter { } private void applyFocusStyle(MaterialCardView card, boolean focused) { - var tokens = ThemeController.current(); card.setActivated(focused); - // 卡面走统一主题 token,自定义主题可覆写; - // 焦点环已由 foreground 的 selector 统一绘制,这里只维护常态描边, - // 避免"焦点用 3dp 卡片描边 + foreground 再画一圈"的双重描边。 - card.setCardBackgroundColor(tokens.colorSurfaceContainerHigh()); - card.setStrokeColor(STROKE_NORMAL); - card.setStrokeWidth(ResUtil.dp2px(STROKE_WIDTH_NORMAL_DP)); + card.setCardBackgroundColor(CARD_BACKGROUND); + card.setStrokeColor(focused ? STROKE_FOCUSED : STROKE_NORMAL); + card.setStrokeWidth(ResUtil.dp2px(focused ? 3 : 1)); card.setCardElevation(0); card.setTranslationZ(0); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbPhotoPresenter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbPhotoPresenter.java index 5b4c15677f..f382c798dc 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbPhotoPresenter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbPhotoPresenter.java @@ -1,7 +1,5 @@ package com.fongmi.android.tv.ui.presenter; -import android.content.res.ColorStateList; -import android.os.Build; import android.view.LayoutInflater; import android.view.ViewGroup; @@ -12,7 +10,6 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterTmdbPhotoBinding; import com.fongmi.android.tv.utils.ImgUtil; import com.fongmi.android.tv.utils.ResUtil; -import com.google.android.material.card.MaterialCardView; public class TmdbPhotoPresenter extends Presenter { @@ -41,7 +38,6 @@ public class TmdbPhotoPresenter extends Presenter { params.height = ResUtil.dp2px(222); binding.getRoot().setLayoutParams(params); } - bindFocusStyle(binding.getRoot()); return new ViewHolder(binding); } @@ -60,26 +56,7 @@ public class TmdbPhotoPresenter extends Presenter { public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { } - /** - * 剧照/海报卡片此前完全没有焦点外观:布局里关闭了系统默认焦点高亮,presenter 也不改描边, - * 所以遥控停在卡片上时看不出焦点在哪里。这里用前景 selector 画 3dp 焦点环, - * 与演员卡(selector_tmdb_cast_focus.xml)同一套做法:前景绘制在图片之上, - * 不需要在每次绑定时改动卡片描边。 - */ - private static void bindFocusStyle(MaterialCardView card) { - card.setRippleColor(ColorStateList.valueOf(0x00000000)); - card.setForeground(card.getContext().getDrawable(R.drawable.selector_tmdb_media_focus)); - card.setStateListAnimator(null); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) card.setDefaultFocusHighlightEnabled(false); - applyFocusStyle(card, card.hasFocus()); - card.setOnFocusChangeListener((view, focused) -> applyFocusStyle(card, focused)); - } - - private static void applyFocusStyle(MaterialCardView card, boolean focused) { - card.setActivated(focused); - } - - static class ViewHolder extends Presenter.ViewHolder { + public static class ViewHolder extends Presenter.ViewHolder { private final AdapterTmdbPhotoBinding binding; diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbRecommendationPresenter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbRecommendationPresenter.java index 6c62453ce2..53155d1620 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbRecommendationPresenter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbRecommendationPresenter.java @@ -7,25 +7,14 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.leanback.widget.Presenter; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.databinding.AdapterTmdbRecommendationBinding; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.ui.helper.TmdbRatingFormatter; import com.fongmi.android.tv.utils.ImgUtil; -import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.TmdbImageSelector; -import com.google.android.material.card.MaterialCardView; public class TmdbRecommendationPresenter extends Presenter { - // 焦点环宽度与环色都不得在本类里写死: - // 宽度 → @dimen/webhtv_focus_ring_width(全 TV 唯一宽度来源); - // 环色 → ThemeController.focusRingColor()(= ?attr/tvFocusRing 的代码路径同一来源)。 - // 此前这里写死 FOCUS_WIDTH_DP = 3 与 R.color.tv_item_focus_ring,于是前几轮统一 - // drawable 宽度时漏掉了本卡(用户报告「个性推荐等卡片的边框粗细没有改小没有统一」)。 - private static final int NORMAL_WIDTH_DP = 1; - private final OnClickListener mListener; private final OnLongClickListener mLongClickListener; private final OnFocusListener mFocusListener; @@ -79,10 +68,8 @@ public class TmdbRecommendationPresenter extends Presenter { }); holder.view.setOnLongClickListener(view -> mLongClickListener != null && mLongClickListener.onItemLongClick(tmdbItem)); holder.view.setOnFocusChangeListener((view, focused) -> { - applyUnifiedFocus((MaterialCardView) view, focused); if (mFocusListener != null) mFocusListener.onItemFocus(tmdbItem, focused); }); - applyUnifiedFocus((MaterialCardView) holder.view, holder.view.hasFocus()); if (holder.view.hasFocus() && mFocusListener != null) mFocusListener.onItemFocus(tmdbItem, true); } @@ -97,21 +84,6 @@ public class TmdbRecommendationPresenter extends Presenter { viewHolder.view.setOnFocusChangeListener(null); } - /** - * 统一焦点外观:焦点用 @dimen/webhtv_focus_ring_width 的主题环,失焦恢复常态描边。 - * 只作用于 leanback 播放页的推荐卡,不改变共享布局和独立详情页的焦点契约。 - */ - private static void applyUnifiedFocus(MaterialCardView card, boolean focused) { - if (focused) { - card.setStrokeWidth(card.getContext().getResources() - .getDimensionPixelSize(R.dimen.webhtv_focus_ring_width)); - card.setStrokeColor(ThemeController.focusRingColor(card.getContext())); - } else { - card.setStrokeWidth(ResUtil.dp2px(NORMAL_WIDTH_DP)); - card.setStrokeColor(card.getContext().getColor(R.color.tv_item_normal_stroke)); - } - } - public static class ViewHolder extends Presenter.ViewHolder { private final AdapterTmdbRecommendationBinding binding; diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbVideoPresenter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbVideoPresenter.java index b5d8f37213..df8be47fc6 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbVideoPresenter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbVideoPresenter.java @@ -1,20 +1,15 @@ package com.fongmi.android.tv.ui.presenter; -import android.content.res.ColorStateList; -import android.os.Build; import android.view.LayoutInflater; -import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.leanback.widget.Presenter; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.TmdbVideo; import com.fongmi.android.tv.databinding.AdapterTmdbVideoBinding; -import com.fongmi.android.tv.ui.helper.TvFocusScale; import com.fongmi.android.tv.utils.ImgUtil; -import com.google.android.material.card.MaterialCardView; +import com.fongmi.android.tv.utils.ResUtil; public class TmdbVideoPresenter extends Presenter { @@ -41,43 +36,20 @@ public class TmdbVideoPresenter extends Presenter { holder.binding.title.setText(name); holder.binding.subtitle.setText(video.getDisplayType() + " ? " + video.getScopeLabel()); ImgUtil.load(name, video.getThumbnailUrl(), holder.binding.poster, true, 300, 169); - bindFocusStyle(holder); setOnClickListener(holder, view -> { if (listener != null) listener.onItemClick(video); }); + holder.view.setOnFocusChangeListener((view, focused) -> { + float scale = focused ? 1.04f : 1.0f; + view.animate().scaleX(scale).scaleY(scale).setDuration(120).start(); + holder.binding.getRoot().setStrokeWidth(ResUtil.dp2px(focused ? 2 : 1)); + holder.binding.getRoot().setStrokeColor(focused ? 0xFFFFD166 : 0x33FFFFFF); + }); } @Override public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { - View root = viewHolder.view; - TvFocusScale.reset(root); - root.setOnFocusChangeListener(null); - root.setForeground(null); - root.setActivated(false); - } - - /** - * 相关视频卡片的封面 ImageView 是 match_parent 全出血的,而 MaterialCardView 的描边画在 - * 背景层,会被封面完全盖住——这正是“剧照修好之后,相关视频仍然看不出焦点在哪”的原因。 - * 与剧照/海报、演员卡一致,改用前景 selector 把 3dp 焦点环画在封面之上。 - */ - private void bindFocusStyle(ViewHolder holder) { - MaterialCardView card = holder.binding.getRoot(); - card.setRippleColor(ColorStateList.valueOf(0x00000000)); - card.setForeground(card.getContext().getDrawable(R.drawable.selector_tmdb_media_focus)); - card.setStateListAnimator(null); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) card.setDefaultFocusHighlightEnabled(false); - applyFocusChrome(holder, card.hasFocus()); - card.setOnFocusChangeListener((view, focused) -> applyFocusChrome(holder, focused)); - } - - /** - * RecyclerView 复用时卡片可能已经持有焦点,只挂监听不会触发回调, - * 必须按当前真实焦点状态补一次,否则“停下”的那张卡看不到焦点环。 - */ - private void applyFocusChrome(ViewHolder holder, boolean focused) { - holder.binding.getRoot().setActivated(focused); - TvFocusScale.applyCompact(holder.view, focused); + viewHolder.view.setOnFocusChangeListener(null); } static final class ViewHolder extends Presenter.ViewHolder { diff --git a/app/src/leanback/res/color/bg_remark.xml b/app/src/leanback/res/color/bg_remark.xml index dfe7254c8f..0f9d0b475d 100644 --- a/app/src/leanback/res/color/bg_remark.xml +++ b/app/src/leanback/res/color/bg_remark.xml @@ -1,12 +1,4 @@ - - + diff --git a/app/src/leanback/res/color/bg_site.xml b/app/src/leanback/res/color/bg_site.xml index 0310bcfef7..d351f36970 100644 --- a/app/src/leanback/res/color/bg_site.xml +++ b/app/src/leanback/res/color/bg_site.xml @@ -1,8 +1,4 @@ - - + diff --git a/app/src/leanback/res/color/bg_year.xml b/app/src/leanback/res/color/bg_year.xml index 683a2784cd..3951b5b321 100644 --- a/app/src/leanback/res/color/bg_year.xml +++ b/app/src/leanback/res/color/bg_year.xml @@ -1,8 +1,4 @@ - - + diff --git a/app/src/leanback/res/color/config_history_icon.xml b/app/src/leanback/res/color/config_history_icon.xml index a315f16570..535afaebaf 100644 --- a/app/src/leanback/res/color/config_history_icon.xml +++ b/app/src/leanback/res/color/config_history_icon.xml @@ -1,6 +1,6 @@ - - - + + + diff --git a/app/src/leanback/res/color/config_history_text.xml b/app/src/leanback/res/color/config_history_text.xml index cde6c4bb42..cc1716050b 100644 --- a/app/src/leanback/res/color/config_history_text.xml +++ b/app/src/leanback/res/color/config_history_text.xml @@ -1,6 +1,6 @@ - - - + + + diff --git a/app/src/leanback/res/color/home_menu_text.xml b/app/src/leanback/res/color/home_menu_text.xml deleted file mode 100644 index 830b9c44d7..0000000000 --- a/app/src/leanback/res/color/home_menu_text.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/src/leanback/res/color/selector_group_text.xml b/app/src/leanback/res/color/selector_group_text.xml index cf5695c19c..82475bcbc6 100644 --- a/app/src/leanback/res/color/selector_group_text.xml +++ b/app/src/leanback/res/color/selector_group_text.xml @@ -1,6 +1,6 @@ - - - + + + diff --git a/app/src/leanback/res/color/site_action_icon.xml b/app/src/leanback/res/color/site_action_icon.xml index f5f5c0159a..ad0b6d872e 100644 --- a/app/src/leanback/res/color/site_action_icon.xml +++ b/app/src/leanback/res/color/site_action_icon.xml @@ -1,7 +1,7 @@ - - - - + + + + diff --git a/app/src/leanback/res/color/site_item_check.xml b/app/src/leanback/res/color/site_item_check.xml deleted file mode 100644 index 66cfb24692..0000000000 --- a/app/src/leanback/res/color/site_item_check.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/leanback/res/color/site_item_text.xml b/app/src/leanback/res/color/site_item_text.xml deleted file mode 100644 index cbfa0da5af..0000000000 --- a/app/src/leanback/res/color/site_item_text.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/leanback/res/color/text.xml b/app/src/leanback/res/color/text.xml index 99e71a354c..ba92946818 100644 --- a/app/src/leanback/res/color/text.xml +++ b/app/src/leanback/res/color/text.xml @@ -1,8 +1,8 @@ - - - - - - + + + + + + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/ic_action_check.xml b/app/src/leanback/res/drawable/ic_action_check.xml deleted file mode 100644 index c7681a5e86..0000000000 --- a/app/src/leanback/res/drawable/ic_action_check.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/app/src/leanback/res/drawable/ic_action_close.xml b/app/src/leanback/res/drawable/ic_action_close.xml deleted file mode 100644 index 5ddf18632f..0000000000 --- a/app/src/leanback/res/drawable/ic_action_close.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/app/src/leanback/res/drawable/ic_banner.png b/app/src/leanback/res/drawable/ic_banner.png new file mode 100644 index 0000000000..670e5edf00 Binary files /dev/null and b/app/src/leanback/res/drawable/ic_banner.png differ diff --git a/app/src/leanback/res/drawable/ic_banner_foreground.xml b/app/src/leanback/res/drawable/ic_banner_foreground.xml new file mode 100644 index 0000000000..c2b3440adc --- /dev/null +++ b/app/src/leanback/res/drawable/ic_banner_foreground.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/src/leanback/res/drawable/selector_ad_stats_item.xml b/app/src/leanback/res/drawable/selector_ad_stats_item.xml deleted file mode 100644 index cfbaa6f433..0000000000 --- a/app/src/leanback/res/drawable/selector_ad_stats_item.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/leanback/res/drawable/selector_control_sheet_button.xml b/app/src/leanback/res/drawable/selector_control_sheet_button.xml index ca2247fc66..b131534d57 100644 --- a/app/src/leanback/res/drawable/selector_control_sheet_button.xml +++ b/app/src/leanback/res/drawable/selector_control_sheet_button.xml @@ -1,20 +1,4 @@ - @@ -24,8 +8,8 @@ + android:width="2dp" + android:color="#D9FFFFFF" /> @@ -33,31 +17,17 @@ + android:width="2dp" + android:color="#BFFFFFFF" /> - + - - - - - - - - + android:width="1dp" + android:color="#66FFFFFF" /> @@ -66,7 +36,7 @@ + android:color="#14FFFFFF" /> diff --git a/app/src/leanback/res/drawable/selector_danmaku_result_item.xml b/app/src/leanback/res/drawable/selector_danmaku_result_item.xml index ef820fa30f..4af3436a8a 100644 --- a/app/src/leanback/res/drawable/selector_danmaku_result_item.xml +++ b/app/src/leanback/res/drawable/selector_danmaku_result_item.xml @@ -1,19 +1,11 @@ - diff --git a/app/src/leanback/res/drawable/selector_danmaku_search_action.xml b/app/src/leanback/res/drawable/selector_danmaku_search_action.xml index 2a52ecc71a..6c14285fe6 100644 --- a/app/src/leanback/res/drawable/selector_danmaku_search_action.xml +++ b/app/src/leanback/res/drawable/selector_danmaku_search_action.xml @@ -1,17 +1,11 @@ - diff --git a/app/src/leanback/res/drawable/selector_empty_action.xml b/app/src/leanback/res/drawable/selector_empty_action.xml deleted file mode 100644 index 72aebf81aa..0000000000 --- a/app/src/leanback/res/drawable/selector_empty_action.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - diff --git a/app/src/leanback/res/drawable/selector_episode_dialog_item.xml b/app/src/leanback/res/drawable/selector_episode_dialog_item.xml index c75a4e4c09..1fc40bee2c 100644 --- a/app/src/leanback/res/drawable/selector_episode_dialog_item.xml +++ b/app/src/leanback/res/drawable/selector_episode_dialog_item.xml @@ -1,44 +1,20 @@ - - - - - - - - + android:color="#2AA46B" /> + + + + + + + diff --git a/app/src/leanback/res/drawable/selector_episode_dialog_page.xml b/app/src/leanback/res/drawable/selector_episode_dialog_page.xml index ee0894023e..a935914b3f 100644 --- a/app/src/leanback/res/drawable/selector_episode_dialog_page.xml +++ b/app/src/leanback/res/drawable/selector_episode_dialog_page.xml @@ -1,22 +1,11 @@ - + android:width="3dp" + android:color="#0077FF" /> @@ -25,7 +14,7 @@ + android:color="#2196F3" /> diff --git a/app/src/leanback/res/drawable/selector_exit_confirm_primary.xml b/app/src/leanback/res/drawable/selector_exit_confirm_primary.xml index 5d7d8628bf..6f33a2cbc4 100644 --- a/app/src/leanback/res/drawable/selector_exit_confirm_primary.xml +++ b/app/src/leanback/res/drawable/selector_exit_confirm_primary.xml @@ -1,16 +1,4 @@ - @@ -18,7 +6,7 @@ @@ -28,7 +16,7 @@ diff --git a/app/src/leanback/res/drawable/selector_exit_confirm_secondary.xml b/app/src/leanback/res/drawable/selector_exit_confirm_secondary.xml index 89ecf81582..91a240c1d0 100644 --- a/app/src/leanback/res/drawable/selector_exit_confirm_secondary.xml +++ b/app/src/leanback/res/drawable/selector_exit_confirm_secondary.xml @@ -1,18 +1,4 @@ - @@ -20,7 +6,7 @@ @@ -30,7 +16,7 @@ diff --git a/app/src/leanback/res/drawable/selector_glass_icon_action.xml b/app/src/leanback/res/drawable/selector_glass_icon_action.xml deleted file mode 100644 index e2bf56e5a7..0000000000 --- a/app/src/leanback/res/drawable/selector_glass_icon_action.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/leanback/res/drawable/selector_search_scope_item.xml b/app/src/leanback/res/drawable/selector_search_scope_item.xml index 11f4bbcf82..e20e89ba24 100644 --- a/app/src/leanback/res/drawable/selector_search_scope_item.xml +++ b/app/src/leanback/res/drawable/selector_search_scope_item.xml @@ -1,22 +1,4 @@ - @@ -24,27 +6,17 @@ - - - - - - - - - + diff --git a/app/src/leanback/res/drawable/selector_tv_search_field.xml b/app/src/leanback/res/drawable/selector_tv_search_field.xml deleted file mode 100644 index c141173869..0000000000 --- a/app/src/leanback/res/drawable/selector_tv_search_field.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - diff --git a/app/src/leanback/res/drawable/selector_video_item.xml b/app/src/leanback/res/drawable/selector_video_item.xml index 1fb7845b70..a1aa0eb093 100644 --- a/app/src/leanback/res/drawable/selector_video_item.xml +++ b/app/src/leanback/res/drawable/selector_video_item.xml @@ -1,66 +1,44 @@ - - - + + + android:width="2dp" + android:color="#FFD166" /> - - + + + android:width="2dp" + android:color="#2CC56F" /> + - - + + - - + + - diff --git a/app/src/leanback/res/drawable/shape_ad_rule_card.xml b/app/src/leanback/res/drawable/shape_ad_rule_card.xml index 42d03864a8..5b38019a12 100644 --- a/app/src/leanback/res/drawable/shape_ad_rule_card.xml +++ b/app/src/leanback/res/drawable/shape_ad_rule_card.xml @@ -1,6 +1,6 @@ - + - + diff --git a/app/src/leanback/res/drawable/shape_ad_stats_content.xml b/app/src/leanback/res/drawable/shape_ad_stats_content.xml deleted file mode 100644 index 4d8e0fecea..0000000000 --- a/app/src/leanback/res/drawable/shape_ad_stats_content.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/leanback/res/drawable/shape_ad_stats_tabs.xml b/app/src/leanback/res/drawable/shape_ad_stats_tabs.xml deleted file mode 100644 index a14b725498..0000000000 --- a/app/src/leanback/res/drawable/shape_ad_stats_tabs.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/app/src/leanback/res/drawable/shape_chip_activated.xml b/app/src/leanback/res/drawable/shape_chip_activated.xml index 03c78b0ef6..6e442e95fd 100644 --- a/app/src/leanback/res/drawable/shape_chip_activated.xml +++ b/app/src/leanback/res/drawable/shape_chip_activated.xml @@ -10,8 +10,4 @@ android:right="12dp" android:top="8dp" /> - - \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_chip_focused.xml b/app/src/leanback/res/drawable/shape_chip_focused.xml index e66068ebcd..69e2dc98d0 100644 --- a/app/src/leanback/res/drawable/shape_chip_focused.xml +++ b/app/src/leanback/res/drawable/shape_chip_focused.xml @@ -1,18 +1,4 @@ - @@ -25,7 +11,7 @@ android:top="8dp" /> + android:width="1.5dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_chip_round_activated.xml b/app/src/leanback/res/drawable/shape_chip_round_activated.xml index 4de15c3e0c..53be586478 100644 --- a/app/src/leanback/res/drawable/shape_chip_round_activated.xml +++ b/app/src/leanback/res/drawable/shape_chip_round_activated.xml @@ -10,8 +10,4 @@ android:right="14dp" android:top="8dp" /> - - \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_chip_round_focused.xml b/app/src/leanback/res/drawable/shape_chip_round_focused.xml index 73a5322c07..bacc422c15 100644 --- a/app/src/leanback/res/drawable/shape_chip_round_focused.xml +++ b/app/src/leanback/res/drawable/shape_chip_round_focused.xml @@ -1,19 +1,4 @@ - @@ -26,7 +11,7 @@ android:top="8dp" /> + android:width="1.5dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_config_history_dialog.xml b/app/src/leanback/res/drawable/shape_config_history_dialog.xml index 36e6def6a3..0ce7998154 100644 --- a/app/src/leanback/res/drawable/shape_config_history_dialog.xml +++ b/app/src/leanback/res/drawable/shape_config_history_dialog.xml @@ -2,8 +2,8 @@ - + - + diff --git a/app/src/leanback/res/drawable/shape_config_history_item_focused.xml b/app/src/leanback/res/drawable/shape_config_history_item_focused.xml index 769b1dbf2a..cb5bfbdb97 100644 --- a/app/src/leanback/res/drawable/shape_config_history_item_focused.xml +++ b/app/src/leanback/res/drawable/shape_config_history_item_focused.xml @@ -1,18 +1,8 @@ - - + @@ -23,7 +13,7 @@ android:top="8dp" /> + android:width="2dp" + android:color="#D7E5FF" /> diff --git a/app/src/leanback/res/drawable/shape_config_history_item_normal.xml b/app/src/leanback/res/drawable/shape_config_history_item_normal.xml index 5701f053a7..35dbe26010 100644 --- a/app/src/leanback/res/drawable/shape_config_history_item_normal.xml +++ b/app/src/leanback/res/drawable/shape_config_history_item_normal.xml @@ -2,7 +2,7 @@ - + @@ -14,6 +14,6 @@ + android:color="#DADCE0" /> diff --git a/app/src/leanback/res/drawable/shape_empty_action_focused.xml b/app/src/leanback/res/drawable/shape_empty_action_focused.xml deleted file mode 100644 index b225cfddce..0000000000 --- a/app/src/leanback/res/drawable/shape_empty_action_focused.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/leanback/res/drawable/shape_episode_dialog_panel.xml b/app/src/leanback/res/drawable/shape_episode_dialog_panel.xml index 6ccf1e1a21..c8db5a4467 100644 --- a/app/src/leanback/res/drawable/shape_episode_dialog_panel.xml +++ b/app/src/leanback/res/drawable/shape_episode_dialog_panel.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/leanback/res/drawable/shape_exit_confirm_badge.xml b/app/src/leanback/res/drawable/shape_exit_confirm_badge.xml index 1b3ff255a8..079cd9ba26 100644 --- a/app/src/leanback/res/drawable/shape_exit_confirm_badge.xml +++ b/app/src/leanback/res/drawable/shape_exit_confirm_badge.xml @@ -2,10 +2,10 @@ - + + android:color="#C7DBFF" /> diff --git a/app/src/leanback/res/drawable/shape_exit_confirm_dialog.xml b/app/src/leanback/res/drawable/shape_exit_confirm_dialog.xml index c24e7dce6a..9962ac58b0 100644 --- a/app/src/leanback/res/drawable/shape_exit_confirm_dialog.xml +++ b/app/src/leanback/res/drawable/shape_exit_confirm_dialog.xml @@ -2,12 +2,12 @@ - + - + + android:color="#E5EAF3" /> diff --git a/app/src/leanback/res/drawable/shape_group_button_focused.xml b/app/src/leanback/res/drawable/shape_group_button_focused.xml index 1691cad413..7d95427ce6 100644 --- a/app/src/leanback/res/drawable/shape_group_button_focused.xml +++ b/app/src/leanback/res/drawable/shape_group_button_focused.xml @@ -1,20 +1,13 @@ - - + + android:width="2dp" + android:color="#D7E5FF" /> diff --git a/app/src/leanback/res/drawable/shape_group_button_normal.xml b/app/src/leanback/res/drawable/shape_group_button_normal.xml index 1ca42991fc..a6946147ca 100644 --- a/app/src/leanback/res/drawable/shape_group_button_normal.xml +++ b/app/src/leanback/res/drawable/shape_group_button_normal.xml @@ -2,12 +2,12 @@ - + + android:color="#DADCE0" /> diff --git a/app/src/leanback/res/drawable/shape_group_button_selected.xml b/app/src/leanback/res/drawable/shape_group_button_selected.xml index 99a4161a66..5c4b09a2a4 100644 --- a/app/src/leanback/res/drawable/shape_group_button_selected.xml +++ b/app/src/leanback/res/drawable/shape_group_button_selected.xml @@ -2,12 +2,12 @@ - + + android:color="#A8DAB5" /> diff --git a/app/src/leanback/res/drawable/shape_item_activated.xml b/app/src/leanback/res/drawable/shape_item_activated.xml index 05ce7aa4e7..e851791431 100644 --- a/app/src/leanback/res/drawable/shape_item_activated.xml +++ b/app/src/leanback/res/drawable/shape_item_activated.xml @@ -1,10 +1,8 @@ - - + diff --git a/app/src/leanback/res/drawable/shape_item_focused.xml b/app/src/leanback/res/drawable/shape_item_focused.xml index 9aec88819e..a6e8c67a48 100644 --- a/app/src/leanback/res/drawable/shape_item_focused.xml +++ b/app/src/leanback/res/drawable/shape_item_focused.xml @@ -1,32 +1,8 @@ - - + @@ -37,7 +13,7 @@ android:top="8dp" /> + android:width="1.5dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_item_round_activated.xml b/app/src/leanback/res/drawable/shape_item_round_activated.xml index 568fb37db4..8e7f9fc6b2 100644 --- a/app/src/leanback/res/drawable/shape_item_round_activated.xml +++ b/app/src/leanback/res/drawable/shape_item_round_activated.xml @@ -1,10 +1,8 @@ - - + diff --git a/app/src/leanback/res/drawable/shape_item_round_focused.xml b/app/src/leanback/res/drawable/shape_item_round_focused.xml index f4e36c8b1a..a15a7191d0 100644 --- a/app/src/leanback/res/drawable/shape_item_round_focused.xml +++ b/app/src/leanback/res/drawable/shape_item_round_focused.xml @@ -1,15 +1,8 @@ - - + @@ -20,7 +13,7 @@ android:top="8dp" /> + android:width="1.5dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_item_selected.xml b/app/src/leanback/res/drawable/shape_item_selected.xml index 8b88b3f9b5..57f07b7d89 100644 --- a/app/src/leanback/res/drawable/shape_item_selected.xml +++ b/app/src/leanback/res/drawable/shape_item_selected.xml @@ -1,10 +1,4 @@ - @@ -19,7 +13,7 @@ android:top="8dp" /> + android:width="1.5dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_keyboard_focused.xml b/app/src/leanback/res/drawable/shape_keyboard_focused.xml index 7b231676ab..3ecccbbf8f 100644 --- a/app/src/leanback/res/drawable/shape_keyboard_focused.xml +++ b/app/src/leanback/res/drawable/shape_keyboard_focused.xml @@ -1,11 +1,4 @@ - @@ -14,7 +7,7 @@ + android:width="1.5dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_keyboard_normal.xml b/app/src/leanback/res/drawable/shape_keyboard_normal.xml index 701cce4560..967d62ab95 100644 --- a/app/src/leanback/res/drawable/shape_keyboard_normal.xml +++ b/app/src/leanback/res/drawable/shape_keyboard_normal.xml @@ -1,21 +1,9 @@ - - + - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_live_focused.xml b/app/src/leanback/res/drawable/shape_live_focused.xml index 45cd8cfae0..dfac27b036 100644 --- a/app/src/leanback/res/drawable/shape_live_focused.xml +++ b/app/src/leanback/res/drawable/shape_live_focused.xml @@ -1,20 +1,12 @@ - + android:width="1.5dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_live_list.xml b/app/src/leanback/res/drawable/shape_live_list.xml index 303fdee3bb..4e1460f195 100644 --- a/app/src/leanback/res/drawable/shape_live_list.xml +++ b/app/src/leanback/res/drawable/shape_live_list.xml @@ -2,6 +2,11 @@ - + - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_search_hot_word_focused.xml b/app/src/leanback/res/drawable/shape_search_hot_word_focused.xml index 97569625e9..90d3976b2c 100644 --- a/app/src/leanback/res/drawable/shape_search_hot_word_focused.xml +++ b/app/src/leanback/res/drawable/shape_search_hot_word_focused.xml @@ -1,22 +1,13 @@ - - + + android:width="1.5dp" + android:color="@color/white" /> diff --git a/app/src/leanback/res/drawable/shape_site_dialog.xml b/app/src/leanback/res/drawable/shape_site_dialog.xml index d691255ce2..f2e8ca1915 100644 --- a/app/src/leanback/res/drawable/shape_site_dialog.xml +++ b/app/src/leanback/res/drawable/shape_site_dialog.xml @@ -1,17 +1,9 @@ - - + - - - + diff --git a/app/src/leanback/res/drawable/shape_site_item_focused.xml b/app/src/leanback/res/drawable/shape_site_item_focused.xml index 518b4ef35c..8ab7f327ef 100644 --- a/app/src/leanback/res/drawable/shape_site_item_focused.xml +++ b/app/src/leanback/res/drawable/shape_site_item_focused.xml @@ -1,20 +1,14 @@ - - + - + + android:width="2dp" + android:color="@color/white" /> - - + - - - + - - + - + + android:width="1.5dp" + android:color="@color/yellow_500" /> - - - - - + android:shape="oval"> + android:width="1.5dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_subtitle_pressed.xml b/app/src/leanback/res/drawable/shape_subtitle_pressed.xml index d438ae3a83..cbcbfd03db 100644 --- a/app/src/leanback/res/drawable/shape_subtitle_pressed.xml +++ b/app/src/leanback/res/drawable/shape_subtitle_pressed.xml @@ -1,17 +1,11 @@ - + android:shape="oval"> - - - + + android:width="1.5dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_tv_search_field.xml b/app/src/leanback/res/drawable/shape_tv_search_field.xml deleted file mode 100644 index 810134299c..0000000000 --- a/app/src/leanback/res/drawable/shape_tv_search_field.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/leanback/res/drawable/shape_tv_search_field_focused.xml b/app/src/leanback/res/drawable/shape_tv_search_field_focused.xml deleted file mode 100644 index 7937d65941..0000000000 --- a/app/src/leanback/res/drawable/shape_tv_search_field_focused.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/leanback/res/drawable/shape_video_focused.xml b/app/src/leanback/res/drawable/shape_video_focused.xml index 69be25e27d..59328d5ad3 100644 --- a/app/src/leanback/res/drawable/shape_video_focused.xml +++ b/app/src/leanback/res/drawable/shape_video_focused.xml @@ -1,26 +1,9 @@ - - - + android:width="1.5dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_video_item_focused.xml b/app/src/leanback/res/drawable/shape_video_item_focused.xml index 6acae66a74..afbb33d511 100644 --- a/app/src/leanback/res/drawable/shape_video_item_focused.xml +++ b/app/src/leanback/res/drawable/shape_video_item_focused.xml @@ -1,12 +1,4 @@ - @@ -19,7 +11,7 @@ android:top="8dp" /> diff --git a/app/src/leanback/res/drawable/shape_vod_focused.xml b/app/src/leanback/res/drawable/shape_vod_focused.xml index 5047f9fa2e..1d4d00e4b7 100644 --- a/app/src/leanback/res/drawable/shape_vod_focused.xml +++ b/app/src/leanback/res/drawable/shape_vod_focused.xml @@ -1,37 +1,11 @@ - - + + android:width="1.5dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_vod_name.xml b/app/src/leanback/res/drawable/shape_vod_name.xml index 398caba748..d341a9d804 100644 --- a/app/src/leanback/res/drawable/shape_vod_name.xml +++ b/app/src/leanback/res/drawable/shape_vod_name.xml @@ -1,15 +1,4 @@ - @@ -17,6 +6,6 @@ android:bottomLeftRadius="8dp" android:bottomRightRadius="8dp" /> - + - + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_vod_oval_focused.xml b/app/src/leanback/res/drawable/shape_vod_oval_focused.xml index 405c3b1fd5..0ef858270f 100644 --- a/app/src/leanback/res/drawable/shape_vod_oval_focused.xml +++ b/app/src/leanback/res/drawable/shape_vod_oval_focused.xml @@ -1,14 +1,9 @@ - + android:width="2dp" + android:color="@color/white" /> - + \ No newline at end of file diff --git a/app/src/leanback/res/layout/activity_crash.xml b/app/src/leanback/res/layout/activity_crash.xml index 77262ae0c3..e18dca5e92 100644 --- a/app/src/leanback/res/layout/activity_crash.xml +++ b/app/src/leanback/res/layout/activity_crash.xml @@ -1,124 +1,46 @@ - + android:gravity="center" + android:orientation="vertical"> - + + + android:lineSpacingExtra="4dp" + android:text="@string/crash_info" + android:textColor="@color/white" + android:textSize="16sp" /> - + - + - - - - - - - - - - - - - - - - - - - + diff --git a/app/src/leanback/res/layout/activity_following.xml b/app/src/leanback/res/layout/activity_following.xml deleted file mode 100644 index 39f8392753..0000000000 --- a/app/src/leanback/res/layout/activity_following.xml +++ /dev/null @@ -1,155 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/leanback/res/layout/activity_home.xml b/app/src/leanback/res/layout/activity_home.xml index ee8985222f..77cb69997b 100644 --- a/app/src/leanback/res/layout/activity_home.xml +++ b/app/src/leanback/res/layout/activity_home.xml @@ -112,7 +112,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="@color/white" - android:textSize="16sp" + android:textSize="24sp" tools:text="09:20:00" /> diff --git a/app/src/leanback/res/layout/activity_search.xml b/app/src/leanback/res/layout/activity_search.xml index cba47bb1c5..b511ce3195 100644 --- a/app/src/leanback/res/layout/activity_search.xml +++ b/app/src/leanback/res/layout/activity_search.xml @@ -31,45 +31,25 @@ android:nextFocusDown="@id/keyboard" android:src="@drawable/ic_search_mic" /> - - - - - - + android:background="@null" + android:focusable="true" + android:focusableInTouchMode="true" + android:hint="@string/search_keyword" + android:imeOptions="actionDone" + android:inputType="textCapWords|textAutoCorrect|textAutoComplete" + android:letterSpacing="0.02" + android:maxLength="255" + android:nextFocusDown="@id/keyboard" + android:singleLine="true" + android:textColor="@color/white" + android:textColorHint="@color/white" + android:textCursorDrawable="@drawable/shape_cursor" + android:textSize="24sp" /> @@ -140,7 +120,7 @@ android:layout_height="wrap_content" android:layout_marginBottom="14dp" android:text="@string/search_record" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="24sp" /> - - @@ -105,7 +100,7 @@ android:layout_marginEnd="16dp" android:layout_weight="0.3" android:text="@string/setting_live" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -170,7 +165,7 @@ android:layout_marginEnd="16dp" android:layout_weight="0.3" android:text="@string/setting_wall" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -211,10 +206,6 @@ - - @@ -254,17 +241,13 @@ android:background="@drawable/selector_item" android:focusable="true" android:focusableInTouchMode="true" - android:nextFocusLeft="@id/enhance" - android:nextFocusRight="@id/danmaku" - android:nextFocusUp="@id/wall" - android:nextFocusDown="@id/ad" android:orientation="horizontal"> @@ -277,17 +260,13 @@ android:background="@drawable/selector_item" android:focusable="true" android:focusableInTouchMode="true" - android:nextFocusLeft="@id/player" - android:nextFocusRight="@id/danmaku" - android:nextFocusUp="@id/wallRefresh" - android:nextFocusDown="@id/ad" android:orientation="horizontal"> @@ -301,10 +280,6 @@ android:background="@drawable/selector_item" android:focusable="true" android:focusableInTouchMode="true" - android:nextFocusLeft="@id/ad" - android:nextFocusRight="@id/ad" - android:nextFocusUp="@id/player" - android:nextFocusDown="@id/subtitle" android:orientation="horizontal"> - - - - - - - - @@ -389,22 +333,17 @@ android:id="@+id/ai" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="0dp" android:layout_weight="1" android:background="@drawable/selector_item" android:focusable="true" android:focusableInTouchMode="true" - android:nextFocusLeft="@id/tmdb" - android:nextFocusRight="@id/ai" - android:nextFocusUp="@id/subtitle" - android:nextFocusDown="@id/personal" android:orientation="horizontal"> @@ -419,15 +358,14 @@ android:background="@drawable/selector_item" android:focusable="true" android:focusableInTouchMode="true" - android:orientation="horizontal" - android:visibility="gone"> + android:orientation="horizontal"> @@ -451,7 +389,7 @@ android:orientation="horizontal"> @@ -482,26 +416,18 @@ android:background="@drawable/selector_item" android:focusable="true" android:focusableInTouchMode="true" - android:nextFocusLeft="@id/appearance" - android:nextFocusRight="@id/personal" - android:nextFocusUp="@id/ai" - android:nextFocusDown="@id/doh" android:orientation="horizontal"> - - @@ -547,15 +469,11 @@ android:id="@+id/doh" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="0dp" + android:layout_marginEnd="16dp" android:layout_weight="1" android:background="@drawable/selector_item" android:focusable="true" android:focusableInTouchMode="true" - android:nextFocusLeft="@id/incognito" - android:nextFocusRight="@id/doh" - android:nextFocusUp="@id/personal" - android:nextFocusDown="@id/restore" android:orientation="horizontal"> + + + + + + @@ -615,26 +560,20 @@ android:id="@+id/restore" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="0dp" android:layout_weight="1" android:background="@drawable/selector_item" android:focusable="true" android:focusableInTouchMode="true" - android:nextFocusLeft="@id/backup" - android:nextFocusRight="@id/restore" - android:nextFocusUp="@id/doh" - android:nextFocusDown="@id/version" android:orientation="horizontal"> - @@ -681,15 +616,10 @@ android:id="@+id/version" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="0dp" android:layout_weight="1" android:background="@drawable/selector_item" android:focusable="true" android:focusableInTouchMode="true" - android:nextFocusLeft="@id/cache" - android:nextFocusRight="@id/version" - android:nextFocusUp="@id/restore" - android:nextFocusDown="@id/version" android:orientation="horizontal"> - diff --git a/app/src/leanback/res/layout/activity_setting_ad.xml b/app/src/leanback/res/layout/activity_setting_ad.xml index c5495e918f..8c5176b088 100644 --- a/app/src/leanback/res/layout/activity_setting_ad.xml +++ b/app/src/leanback/res/layout/activity_setting_ad.xml @@ -27,13 +27,13 @@ android:layout_marginEnd="16dp" android:layout_weight="1" android:text="@string/setting_ad" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="24sp" android:textStyle="bold" /> - + @@ -80,7 +80,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_ai_ad_detection" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> - + @@ -141,12 +141,253 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/ad_block_stats" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - diff --git a/app/src/leanback/res/layout/activity_setting_ai.xml b/app/src/leanback/res/layout/activity_setting_ai.xml index 2a0a2d9c81..56325fc7ab 100644 --- a/app/src/leanback/res/layout/activity_setting_ai.xml +++ b/app/src/leanback/res/layout/activity_setting_ai.xml @@ -27,7 +27,7 @@ android:layout_marginEnd="16dp" android:layout_weight="1" android:text="@string/setting_ai" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="24sp" android:textStyle="bold" /> @@ -39,7 +39,7 @@ - + @@ -83,7 +83,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_personal_recommendation" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -111,7 +111,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_recommendation_feedback" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -148,7 +148,7 @@ - + @@ -202,7 +202,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_ai_max_concurrency" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -231,7 +231,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_ai_chunk_count" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_appearance.xml b/app/src/leanback/res/layout/activity_setting_appearance.xml deleted file mode 100644 index 5f4886bb00..0000000000 --- a/app/src/leanback/res/layout/activity_setting_appearance.xml +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/leanback/res/layout/activity_setting_danmaku.xml b/app/src/leanback/res/layout/activity_setting_danmaku.xml index 61245c6733..08d6b4bf92 100644 --- a/app/src/leanback/res/layout/activity_setting_danmaku.xml +++ b/app/src/leanback/res/layout/activity_setting_danmaku.xml @@ -27,7 +27,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_load" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -58,7 +58,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_api" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -91,7 +91,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_auto_load" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -122,7 +122,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_spider_first" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_enhance.xml b/app/src/leanback/res/layout/activity_setting_enhance.xml index 1e22e3ae12..1c0b67fc6a 100644 --- a/app/src/leanback/res/layout/activity_setting_enhance.xml +++ b/app/src/leanback/res/layout/activity_setting_enhance.xml @@ -27,7 +27,7 @@ android:layout_marginEnd="16dp" android:layout_weight="1" android:text="@string/setting_enhance" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="24sp" android:textStyle="bold" /> @@ -35,24 +35,24 @@ android:id="@+id/githubRepo" android:layout_width="44dp" android:layout_height="44dp" - android:background="@drawable/selector_item" + android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="@string/repo_github" android:focusable="true" android:padding="10dp" android:src="@drawable/ic_repo_github" - app:tint="?attr/webhtvColorOnWallpaper" /> + app:tint="@color/white" /> + app:tint="@color/white" /> @@ -71,7 +71,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_lab" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -100,7 +100,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_drive_check" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -129,7 +129,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_site_name" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -157,7 +157,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_audio_source" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -186,7 +186,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_short_drama_source" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -218,7 +218,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_debug_log" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -247,7 +247,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_manage_page" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -276,7 +276,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_remote_trust" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -307,7 +307,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_git_cloud" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -338,7 +338,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_site_health_sort" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -367,7 +367,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_shell_proxy" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -397,7 +397,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_shell_proxy_config" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -428,7 +428,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_custom_csp" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -459,7 +459,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_extension" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -488,7 +488,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_theme" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -519,7 +519,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_fullscreen" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -548,7 +548,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_csp_warmup" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -577,7 +577,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_playback_artwork_wall" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -606,7 +606,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_viewing_record_sync" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> - - - - - - - - @@ -694,7 +666,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_one_key_sync" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_personal.xml b/app/src/leanback/res/layout/activity_setting_personal.xml index 1c2a8bcbaf..08d4c50985 100644 --- a/app/src/leanback/res/layout/activity_setting_personal.xml +++ b/app/src/leanback/res/layout/activity_setting_personal.xml @@ -25,7 +25,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_home_vod_auto_load" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -54,7 +54,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_home_site_lock" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> - - - - - - - - @@ -141,7 +112,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_home_buttons" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -170,7 +141,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_fullscreen_menu_key" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -199,7 +170,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_home_menu_key" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -228,7 +199,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_play_back_to_detail" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> - - - - - - - - @@ -315,7 +257,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_global_history" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> - - - - - - - - @@ -374,7 +287,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_play_speed" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -406,7 +319,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_group_rule" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -435,7 +348,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_home_history" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -465,7 +378,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_thread" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -494,7 +407,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_ui" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -523,7 +436,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_result_sort" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -553,7 +466,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_column" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -582,7 +495,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_reset_app" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -601,7 +514,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_app_branding" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> - - - - - - - diff --git a/app/src/leanback/res/layout/activity_setting_player.xml b/app/src/leanback/res/layout/activity_setting_player.xml index 8bab2325b6..be253d28d2 100644 --- a/app/src/leanback/res/layout/activity_setting_player.xml +++ b/app/src/leanback/res/layout/activity_setting_player.xml @@ -27,7 +27,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_render" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -56,7 +56,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_button_config" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -87,7 +87,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_exo_4k_compat" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -118,7 +118,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_kernel" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -148,7 +148,7 @@ android:layout_marginEnd="16dp" android:singleLine="true" android:text="@string/player_mpv_config" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -180,7 +180,7 @@ android:layout_marginEnd="16dp" android:singleLine="true" android:text="@string/player_mpv_render" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> - - - - - - - - - @@ -269,7 +240,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_lut" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -300,7 +271,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_caption" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -329,7 +300,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_osd" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> - - - - - - - - @@ -409,7 +349,7 @@ android:layout_height="wrap_content" android:layout_marginStart="4dp" android:text="@string/times" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -429,7 +369,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_buffer_bytes" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -460,7 +400,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_back_buffer" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -491,7 +431,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_cache" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -522,7 +462,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -553,7 +493,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_threads" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -584,7 +524,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_size" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -615,7 +555,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_time" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -646,7 +586,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_ahead" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -677,7 +617,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_pause" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -708,7 +648,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_auto_play" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -739,7 +679,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_auto_change" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> - - - - - - - - - - - - - - - - @@ -832,7 +710,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_failure_fallback" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -863,7 +741,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_speed" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -881,7 +759,7 @@ android:layout_height="wrap_content" android:layout_marginStart="4dp" android:text="@string/times" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -901,7 +779,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_tunnel" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -930,7 +808,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_music_notification" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -959,7 +837,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_book_notification" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -988,7 +866,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_decode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -1017,7 +895,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_passthrough" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -1046,7 +924,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_video_decode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> + + + + + + + + @@ -1104,7 +1011,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_background" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -1133,7 +1040,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_ua" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_subtitle.xml b/app/src/leanback/res/layout/activity_setting_subtitle.xml index 53ce2addb9..41ee34bf04 100644 --- a/app/src/leanback/res/layout/activity_setting_subtitle.xml +++ b/app/src/leanback/res/layout/activity_setting_subtitle.xml @@ -25,7 +25,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_auto_match" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -54,7 +54,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_language" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -84,8 +84,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" - android:text="@string/player_subtitle_source" - android:textColor="?attr/webhtvColorOnWallpaper" + android:text="@string/player_subtitle_assrt_token" + android:textColor="@color/white" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_tmdb.xml b/app/src/leanback/res/layout/activity_setting_tmdb.xml index 73363b6582..7ae2792c73 100644 --- a/app/src/leanback/res/layout/activity_setting_tmdb.xml +++ b/app/src/leanback/res/layout/activity_setting_tmdb.xml @@ -27,7 +27,7 @@ android:layout_marginEnd="16dp" android:layout_weight="1" android:text="@string/setting_tmdb" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="24sp" android:textStyle="bold" /> @@ -53,7 +53,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_source" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -85,7 +85,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_detail_open_mode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -115,7 +115,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_detail_theme_mode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -146,7 +146,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_match_mode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -175,7 +175,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_episode_file_size" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> @@ -205,7 +205,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_history_aggregation" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_video.xml b/app/src/leanback/res/layout/activity_video.xml index 39a536ec04..aa089799ca 100644 --- a/app/src/leanback/res/layout/activity_video.xml +++ b/app/src/leanback/res/layout/activity_video.xml @@ -62,18 +62,6 @@ android:layout_height="match_parent" app:surface_type="none" /> - - - @@ -592,7 +580,7 @@ android:layout_marginEnd="24dp" android:layout_toEndOf="@+id/video" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="24sp" tools:text="慶餘年第二季" /> @@ -606,7 +594,7 @@ android:layout_marginTop="6dp" android:layout_marginEnd="24dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="更新到第二季" /> @@ -627,7 +615,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="站源:泥巴" /> @@ -638,7 +626,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="年份:2022" /> @@ -649,7 +637,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="地區:台灣" /> @@ -659,7 +647,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="類型:科幻" /> @@ -676,24 +664,17 @@ android:layout_marginEnd="24dp" android:nextFocusDown="@id/actor" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="導演:FongMi" /> - + @@ -720,7 +701,7 @@ android:ellipsize="end" android:maxLines="2" android:nextFocusDown="@id/content" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="演員:FongMi" /> @@ -757,7 +738,7 @@ android:nextFocusDown="@id/tmdbCast" android:singleLine="true" android:text="@string/detail_desc" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" app:drawableStartCompat="@drawable/ic_detail_desc" /> @@ -776,7 +757,7 @@ android:nextFocusDown="@id/flag" android:singleLine="true" android:text="@string/play_short_display" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" app:drawableStartCompat="@drawable/ic_detail_short_display" /> @@ -796,7 +777,7 @@ android:nextFocusDown="@id/flag" android:singleLine="true" android:text="@string/play_search" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" app:drawableStartCompat="@drawable/ic_action_search" /> @@ -816,7 +797,7 @@ android:nextFocusDown="@id/tmdbCast" android:singleLine="true" android:text="@string/play_change" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" android:visibility="gone" app:drawableStartCompat="@drawable/ic_detail_change" /> @@ -837,32 +818,10 @@ android:nextFocusDown="@id/tmdbCast" android:singleLine="true" android:text="@string/keep" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" app:drawableStartCompat="@drawable/ic_detail_keep_off" /> - - @@ -904,51 +863,6 @@ android:layout_height="wrap_content" android:orientation="vertical"> - - - - - - - - - - - @@ -1004,7 +918,7 @@ android:id="@+id/episodeTitle" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:background="@drawable/selector_video_item" + android:background="@drawable/selector_button" android:clickable="true" android:focusable="true" android:focusableInTouchMode="false" @@ -1015,7 +929,7 @@ android:paddingStart="12dp" android:paddingEnd="12dp" android:text="@string/detail_episode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" /> @@ -1028,14 +942,14 @@ android:id="@+id/episodeReverse" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:background="@drawable/selector_video_item" + android:background="@drawable/selector_button" android:focusable="true" android:focusableInTouchMode="true" android:gravity="center" android:minWidth="76dp" android:nextFocusLeft="@id/episodeTitle" android:text="@string/detail_episode_reverse" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -1111,14 +1025,14 @@ + android:indeterminateTint="#FFFFFF" /> @@ -1134,7 +1048,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_ratings_label" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1158,7 +1072,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_cast_label" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1186,7 +1100,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_photos_label" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1215,7 +1129,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_posters_label" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1244,7 +1158,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_related_videos_label" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1261,9 +1175,7 @@ android:nextFocusDown="@id/tmdbCrew" android:nextFocusUp="@id/tmdbPosters" android:paddingStart="24dp" - android:paddingTop="6dp" android:paddingEnd="24dp" - android:paddingBottom="6dp" android:visibility="gone" /> @@ -1277,7 +1189,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_crew_label" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1306,7 +1218,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_recommendations_label" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1335,7 +1247,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_personal_recommendations_tmdb_label" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1364,7 +1276,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_personal_recommendations_douban_label" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1393,7 +1305,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_personal_recommendations_ai_label" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1423,7 +1335,7 @@ android:ellipsize="end" android:lineSpacingExtra="2dp" android:maxLines="2" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="#CCFFFFFF" android:textSize="15sp" android:visibility="gone" /> @@ -1434,7 +1346,8 @@ android:layout_marginBottom="12dp" android:clipChildren="false" android:clipToPadding="false" - android:paddingStart="24dp" android:paddingEnd="24dp" + android:paddingStart="24dp" + android:paddingEnd="24dp" android:visibility="gone" /> - - - - - - - - - - - - - - - diff --git a/app/src/leanback/res/layout/adapter_ad_rule.xml b/app/src/leanback/res/layout/adapter_ad_rule.xml index ff67727190..c6627dd8de 100644 --- a/app/src/leanback/res/layout/adapter_ad_rule.xml +++ b/app/src/leanback/res/layout/adapter_ad_rule.xml @@ -40,7 +40,7 @@ android:layout_marginTop="2dp" android:ellipsize="end" android:singleLine="true" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textSize="13sp" tools:text="AI 生成 · 域名 2 · 广告规则 1" /> @@ -49,7 +49,7 @@ android:layout_height="wrap_content" android:layout_marginTop="6dp" android:text="@string/ad_rule_detail_hint" - android:textColor="?attr/colorPrimary" + android:textColor="#174EA6" android:textSize="12sp" /> @@ -77,6 +77,6 @@ android:focusableInTouchMode="true" android:padding="12dp" android:src="@drawable/ic_setting_delete" - app:tint="@color/fixed_light_focus_icon" /> + app:tint="@color/dialog_outlined_button_text" /> diff --git a/app/src/leanback/res/layout/adapter_ad_stats_item.xml b/app/src/leanback/res/layout/adapter_ad_stats_item.xml index 352778c049..b78158553c 100644 --- a/app/src/leanback/res/layout/adapter_ad_stats_item.xml +++ b/app/src/leanback/res/layout/adapter_ad_stats_item.xml @@ -2,16 +2,14 @@ + android:paddingBottom="8dp"> + android:textColor="@color/dialog_outlined_button_text" + android:textSize="14sp" /> @@ -44,7 +42,7 @@ android:minWidth="48dp" android:text="0" android:textAlignment="textEnd" - android:textColor="@color/fixed_light_focus_text" + android:textColor="@color/dialog_outlined_button_text" android:textSize="14sp" android:textStyle="bold" /> diff --git a/app/src/leanback/res/layout/adapter_array.xml b/app/src/leanback/res/layout/adapter_array.xml index 48d56feb43..9c50bbcda0 100644 --- a/app/src/leanback/res/layout/adapter_array.xml +++ b/app/src/leanback/res/layout/adapter_array.xml @@ -10,6 +10,6 @@ android:gravity="center" android:nextFocusUp="@id/quality" android:nextFocusDown="@id/episode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="11" /> diff --git a/app/src/leanback/res/layout/adapter_channel.xml b/app/src/leanback/res/layout/adapter_channel.xml index 9dc76f6573..6b1356acf9 100644 --- a/app/src/leanback/res/layout/adapter_channel.xml +++ b/app/src/leanback/res/layout/adapter_channel.xml @@ -16,7 +16,7 @@ android:layout_marginEnd="12dp" android:duplicateParentState="true" android:fontFeatureSettings="tnum" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="16sp" tools:text="01" /> @@ -34,7 +34,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="16sp" tools:text="CNN" /> diff --git a/app/src/leanback/res/layout/adapter_config.xml b/app/src/leanback/res/layout/adapter_config.xml index ac1a4d329c..9afb031239 100644 --- a/app/src/leanback/res/layout/adapter_config.xml +++ b/app/src/leanback/res/layout/adapter_config.xml @@ -4,55 +4,29 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:descendantFocusability="afterDescendants" android:orientation="horizontal"> - - - + android:background="#FF4A8DFF" /> @@ -33,7 +33,7 @@ android:layout_width="wrap_content" android:layout_height="match_parent" android:gravity="center_vertical" - android:textColor="@color/webhtv_color_on_surface_variant" + android:textColor="@color/white_60" android:textSize="13sp" tools:text="3" /> diff --git a/app/src/leanback/res/layout/adapter_device.xml b/app/src/leanback/res/layout/adapter_device.xml index efb66e766a..e967577855 100644 --- a/app/src/leanback/res/layout/adapter_device.xml +++ b/app/src/leanback/res/layout/adapter_device.xml @@ -34,7 +34,7 @@ android:layout_height="wrap_content" android:layout_marginTop="2dp" android:singleLine="true" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="?attr/colorOnSurface" android:textSize="12sp" tools:text="127.0.0.1" /> diff --git a/app/src/leanback/res/layout/adapter_doh.xml b/app/src/leanback/res/layout/adapter_doh.xml index d0877b1735..9c9d1ab02a 100644 --- a/app/src/leanback/res/layout/adapter_doh.xml +++ b/app/src/leanback/res/layout/adapter_doh.xml @@ -9,6 +9,6 @@ android:focusableInTouchMode="true" android:gravity="center" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="18sp" tools:text="Google" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_epg_data.xml b/app/src/leanback/res/layout/adapter_epg_data.xml index 33a1694270..3a14a3b7eb 100644 --- a/app/src/leanback/res/layout/adapter_epg_data.xml +++ b/app/src/leanback/res/layout/adapter_epg_data.xml @@ -15,7 +15,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="16sp" tools:text="七龍珠" /> @@ -26,7 +26,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="14sp" tools:text="18:00 ~ 19:00" /> diff --git a/app/src/leanback/res/layout/adapter_episode.xml b/app/src/leanback/res/layout/adapter_episode.xml index a50285b7e7..c712b8495e 100644 --- a/app/src/leanback/res/layout/adapter_episode.xml +++ b/app/src/leanback/res/layout/adapter_episode.xml @@ -19,7 +19,7 @@ android:nextFocusDown="@id/part" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="16sp" tools:text="第20集" /> diff --git a/app/src/leanback/res/layout/adapter_episode_card.xml b/app/src/leanback/res/layout/adapter_episode_card.xml index d96cd43bd6..dc658bd516 100644 --- a/app/src/leanback/res/layout/adapter_episode_card.xml +++ b/app/src/leanback/res/layout/adapter_episode_card.xml @@ -6,14 +6,14 @@ android:id="@+id/cardContainer" android:layout_width="280dp" android:layout_height="160dp" - android:layout_marginEnd="@dimen/webhtv_size_tv_card_gap" + android:layout_marginEnd="12dp" android:defaultFocusHighlightEnabled="false" android:focusable="true" android:focusableInTouchMode="true" android:foreground="@drawable/selector_episode_card" android:stateListAnimator="@null" - app:cardBackgroundColor="?attr/colorSurfaceContainerHigh" - app:cardCornerRadius="@dimen/webhtv_card_radius_default" + app:cardBackgroundColor="#141A20" + app:cardCornerRadius="8dp" app:cardElevation="0dp"> @@ -52,7 +52,7 @@ android:paddingEnd="10dp" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="13sp" android:textStyle="bold" android:visibility="gone" @@ -77,7 +77,7 @@ android:paddingEnd="10dp" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="13sp" android:textStyle="bold" android:visibility="gone" @@ -97,7 +97,7 @@ android:minWidth="52dp" android:paddingStart="10dp" android:paddingEnd="10dp" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="13sp" android:textStyle="bold" android:visibility="gone" @@ -122,7 +122,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" tools:text="1. 楚朝遭灭门傅九舍命" /> @@ -135,7 +135,7 @@ android:ellipsize="end" android:lineSpacingExtra="2dp" android:maxLines="2" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="#E6FFFFFF" android:textSize="15sp" android:visibility="gone" tools:text="楚朝是大将军楚岑之女,跟着父亲在大楚边境的云中郡长大。" diff --git a/app/src/leanback/res/layout/adapter_episode_dialog.xml b/app/src/leanback/res/layout/adapter_episode_dialog.xml index 99248a0ac9..c539b11480 100644 --- a/app/src/leanback/res/layout/adapter_episode_dialog.xml +++ b/app/src/leanback/res/layout/adapter_episode_dialog.xml @@ -14,6 +14,6 @@ android:focusableInTouchMode="true" android:gravity="center" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="第08集 权术之介" /> diff --git a/app/src/leanback/res/layout/adapter_episode_page.xml b/app/src/leanback/res/layout/adapter_episode_page.xml index c82406d416..7d0a9c3b25 100644 --- a/app/src/leanback/res/layout/adapter_episode_page.xml +++ b/app/src/leanback/res/layout/adapter_episode_page.xml @@ -15,7 +15,7 @@ android:paddingStart="18dp" android:paddingEnd="18dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="15sp" android:textStyle="bold" tools:text="1 - 12" /> diff --git a/app/src/leanback/res/layout/adapter_file.xml b/app/src/leanback/res/layout/adapter_file.xml index d0cbc8f4be..7083edc1a8 100644 --- a/app/src/leanback/res/layout/adapter_file.xml +++ b/app/src/leanback/res/layout/adapter_file.xml @@ -27,7 +27,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="Download" /> diff --git a/app/src/leanback/res/layout/adapter_filter.xml b/app/src/leanback/res/layout/adapter_filter.xml index 5dfa463be4..f4ed870390 100644 --- a/app/src/leanback/res/layout/adapter_filter.xml +++ b/app/src/leanback/res/layout/adapter_filter.xml @@ -7,6 +7,6 @@ android:background="@drawable/selector_item_round" android:focusable="true" android:focusableInTouchMode="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="16sp" tools:text="全部" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_flag.xml b/app/src/leanback/res/layout/adapter_flag.xml index eb300eb1bb..a2d068ddea 100644 --- a/app/src/leanback/res/layout/adapter_flag.xml +++ b/app/src/leanback/res/layout/adapter_flag.xml @@ -14,6 +14,6 @@ android:nextFocusDown="@id/episode" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="16sp" tools:text="泥巴" /> diff --git a/app/src/leanback/res/layout/adapter_func.xml b/app/src/leanback/res/layout/adapter_func.xml index 9fdc2bfe6b..454ed9ca64 100644 --- a/app/src/leanback/res/layout/adapter_func.xml +++ b/app/src/leanback/res/layout/adapter_func.xml @@ -23,7 +23,7 @@ android:layout_height="wrap_content" android:layout_marginStart="8dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" tools:text="點播" /> diff --git a/app/src/leanback/res/layout/adapter_group.xml b/app/src/leanback/res/layout/adapter_group.xml index 4d649154c2..239141dedc 100644 --- a/app/src/leanback/res/layout/adapter_group.xml +++ b/app/src/leanback/res/layout/adapter_group.xml @@ -16,7 +16,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="16sp" tools:text="收藏" /> diff --git a/app/src/leanback/res/layout/adapter_header.xml b/app/src/leanback/res/layout/adapter_header.xml index cf1e700d51..797a3e0de3 100644 --- a/app/src/leanback/res/layout/adapter_header.xml +++ b/app/src/leanback/res/layout/adapter_header.xml @@ -4,6 +4,6 @@ android:id="@+id/text" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="24sp" tools:text="@string/home_recommend" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_home_button.xml b/app/src/leanback/res/layout/adapter_home_button.xml index 5d69116ba7..2d35314fcf 100644 --- a/app/src/leanback/res/layout/adapter_home_button.xml +++ b/app/src/leanback/res/layout/adapter_home_button.xml @@ -37,7 +37,7 @@ android:layout_marginStart="10dp" android:layout_weight="1" android:gravity="center_vertical" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" tools:text="Search" /> diff --git a/app/src/leanback/res/layout/adapter_home_menu.xml b/app/src/leanback/res/layout/adapter_home_menu.xml index 6bac3ad3b7..b385e000b2 100644 --- a/app/src/leanback/res/layout/adapter_home_menu.xml +++ b/app/src/leanback/res/layout/adapter_home_menu.xml @@ -17,7 +17,7 @@ android:focusableInTouchMode="true" android:gravity="center" android:singleLine="true" - android:textColor="@color/home_menu_text" + android:textColor="@color/config_history_text" android:textSize="18sp" app:autoSizeMaxTextSize="18sp" app:autoSizeMinTextSize="14sp" diff --git a/app/src/leanback/res/layout/adapter_keyboard_text.xml b/app/src/leanback/res/layout/adapter_keyboard_text.xml index 40e22975ab..9ecfa90818 100644 --- a/app/src/leanback/res/layout/adapter_keyboard_text.xml +++ b/app/src/leanback/res/layout/adapter_keyboard_text.xml @@ -8,6 +8,6 @@ android:focusable="true" android:focusableInTouchMode="true" android:gravity="center" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="0" /> diff --git a/app/src/leanback/res/layout/adapter_live.xml b/app/src/leanback/res/layout/adapter_live.xml index c5f37c29b2..0d0745125f 100644 --- a/app/src/leanback/res/layout/adapter_live.xml +++ b/app/src/leanback/res/layout/adapter_live.xml @@ -16,7 +16,7 @@ android:focusableInTouchMode="true" android:gravity="center" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="18sp" tools:text="https://fongmi.github.io/live.json" /> diff --git a/app/src/leanback/res/layout/adapter_parse.xml b/app/src/leanback/res/layout/adapter_parse.xml index b3683e770c..60d1cf455a 100644 --- a/app/src/leanback/res/layout/adapter_parse.xml +++ b/app/src/leanback/res/layout/adapter_parse.xml @@ -8,6 +8,6 @@ android:focusable="true" android:focusableInTouchMode="true" android:gravity="center" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="14sp" tools:text="解析" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_part.xml b/app/src/leanback/res/layout/adapter_part.xml index f4ebc8a131..05bf625436 100644 --- a/app/src/leanback/res/layout/adapter_part.xml +++ b/app/src/leanback/res/layout/adapter_part.xml @@ -11,6 +11,6 @@ android:gravity="center" android:nextFocusUp="@id/array" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="分詞" /> diff --git a/app/src/leanback/res/layout/adapter_player_osd.xml b/app/src/leanback/res/layout/adapter_player_osd.xml index f24f3d33cd..afdba9bcd8 100644 --- a/app/src/leanback/res/layout/adapter_player_osd.xml +++ b/app/src/leanback/res/layout/adapter_player_osd.xml @@ -7,7 +7,7 @@ android:layout_height="48dp" android:layout_marginBottom="8dp" android:addStatesFromChildren="true" - android:background="@drawable/selector_player_settings_row" + android:background="@drawable/selector_git_cloud_card" android:clickable="true" android:descendantFocusability="afterDescendants" android:gravity="center_vertical" @@ -22,7 +22,7 @@ android:layout_weight="1" android:ellipsize="end" android:singleLine="true" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="15sp" tools:text="Playback params" /> @@ -30,12 +30,12 @@ android:id="@+id/visible" android:layout_width="40dp" android:layout_height="36dp" - android:background="@drawable/selector_player_settings_switch" + android:background="@drawable/selector_dialog_switch" android:contentDescription="@string/setting_show" android:focusable="true" android:padding="8dp" android:scaleType="center" android:src="@drawable/ic_player_button_visible" - app:tint="@color/player_settings_icon_tint" /> + app:tint="@color/dialog_tonal_button_text" /> diff --git a/app/src/leanback/res/layout/adapter_quality.xml b/app/src/leanback/res/layout/adapter_quality.xml index 94cf6c5c1f..bd9ccf4c66 100644 --- a/app/src/leanback/res/layout/adapter_quality.xml +++ b/app/src/leanback/res/layout/adapter_quality.xml @@ -12,6 +12,6 @@ android:nextFocusUp="@id/flag" android:nextFocusDown="@id/episode" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="16sp" tools:text="FHD" /> diff --git a/app/src/leanback/res/layout/adapter_quick.xml b/app/src/leanback/res/layout/adapter_quick.xml index 993ca02493..aa97af8deb 100644 --- a/app/src/leanback/res/layout/adapter_quick.xml +++ b/app/src/leanback/res/layout/adapter_quick.xml @@ -13,7 +13,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="蜘蛛人" /> diff --git a/app/src/leanback/res/layout/adapter_recommendation_feedback.xml b/app/src/leanback/res/layout/adapter_recommendation_feedback.xml index 7a92a293f9..980d9d2399 100644 --- a/app/src/leanback/res/layout/adapter_recommendation_feedback.xml +++ b/app/src/leanback/res/layout/adapter_recommendation_feedback.xml @@ -19,7 +19,7 @@ android:layout_height="wrap_content" android:ellipsize="end" android:singleLine="true" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="16sp" android:textStyle="bold" tools:text="芸汐传" /> @@ -31,7 +31,7 @@ android:layout_marginTop="2dp" android:ellipsize="end" android:singleLine="true" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textSize="12sp" tools:text="剧集 · 2018 · 智能推荐 · 剩余约 89 天 · 点击恢复" /> diff --git a/app/src/leanback/res/layout/adapter_search.xml b/app/src/leanback/res/layout/adapter_search.xml index 0f50ebd6ad..38b728ff5e 100644 --- a/app/src/leanback/res/layout/adapter_search.xml +++ b/app/src/leanback/res/layout/adapter_search.xml @@ -11,7 +11,7 @@ android:id="@+id/image" android:layout_width="match_parent" android:layout_height="0dp" - android:background="?attr/colorSurfaceContainer" + android:background="@color/black_20" android:scaleType="centerCrop" app:shapeAppearanceOverlay="@style/Vod.Grid" /> @@ -26,7 +26,7 @@ android:marqueeRepeatLimit="marquee_forever" android:maxWidth="96dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="13sp" android:visibility="gone" /> @@ -41,7 +41,7 @@ android:ellipsize="marquee" android:marqueeRepeatLimit="marquee_forever" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="14sp" /> diff --git a/app/src/leanback/res/layout/adapter_search_hot_word.xml b/app/src/leanback/res/layout/adapter_search_hot_word.xml index c7155b0220..fd1ff3214f 100644 --- a/app/src/leanback/res/layout/adapter_search_hot_word.xml +++ b/app/src/leanback/res/layout/adapter_search_hot_word.xml @@ -12,6 +12,6 @@ android:paddingStart="10dp" android:paddingEnd="10dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="15sp" tools:text="熱搜詞" /> diff --git a/app/src/leanback/res/layout/adapter_search_list.xml b/app/src/leanback/res/layout/adapter_search_list.xml index 60f43a12c9..62c42236b2 100644 --- a/app/src/leanback/res/layout/adapter_search_list.xml +++ b/app/src/leanback/res/layout/adapter_search_list.xml @@ -17,7 +17,7 @@ android:layout_alignParentStart="true" android:layout_alignParentTop="true" android:layout_alignParentBottom="true" - android:background="?attr/colorSurfaceContainer" + android:background="@color/black_20" android:scaleType="centerCrop" app:shapeAppearanceOverlay="@style/Vod.Grid.Large" /> @@ -32,7 +32,7 @@ android:marqueeRepeatLimit="marquee_forever" android:maxWidth="80dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="12sp" android:visibility="gone" tools:text="Site" @@ -54,7 +54,7 @@ android:ellipsize="marquee" android:marqueeRepeatLimit="marquee_forever" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" tools:text="Movie title" /> @@ -66,7 +66,7 @@ android:ellipsize="marquee" android:marqueeRepeatLimit="marquee_forever" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="15sp" tools:text="1080P | 125 min" /> diff --git a/app/src/leanback/res/layout/adapter_search_record.xml b/app/src/leanback/res/layout/adapter_search_record.xml index ebb54fcc42..1858d6bce2 100644 --- a/app/src/leanback/res/layout/adapter_search_record.xml +++ b/app/src/leanback/res/layout/adapter_search_record.xml @@ -10,6 +10,6 @@ android:focusable="true" android:focusableInTouchMode="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="歷史" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_search_word.xml b/app/src/leanback/res/layout/adapter_search_word.xml index 4a1f9d24bf..b578762642 100644 --- a/app/src/leanback/res/layout/adapter_search_word.xml +++ b/app/src/leanback/res/layout/adapter_search_word.xml @@ -10,6 +10,6 @@ android:focusable="true" android:focusableInTouchMode="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="推薦" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_site.xml b/app/src/leanback/res/layout/adapter_site.xml index 486e624004..941f748e87 100644 --- a/app/src/leanback/res/layout/adapter_site.xml +++ b/app/src/leanback/res/layout/adapter_site.xml @@ -1,31 +1,19 @@ - + android:orientation="horizontal"> + android:textColor="@color/white" + android:textSize="18sp" /> + app:buttonTint="@color/white" /> diff --git a/app/src/leanback/res/layout/adapter_site_switch.xml b/app/src/leanback/res/layout/adapter_site_switch.xml index af199e2f36..06e1d1e097 100644 --- a/app/src/leanback/res/layout/adapter_site_switch.xml +++ b/app/src/leanback/res/layout/adapter_site_switch.xml @@ -1,27 +1,18 @@ - + android:orientation="horizontal"> + android:textColor="@color/white" + android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/adapter_theme.xml b/app/src/leanback/res/layout/adapter_theme.xml deleted file mode 100644 index c0ec2f48a5..0000000000 --- a/app/src/leanback/res/layout/adapter_theme.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - diff --git a/app/src/leanback/res/layout/adapter_type.xml b/app/src/leanback/res/layout/adapter_type.xml index a408523f06..6c7281d4ab 100644 --- a/app/src/leanback/res/layout/adapter_type.xml +++ b/app/src/leanback/res/layout/adapter_type.xml @@ -7,6 +7,6 @@ android:background="@drawable/selector_item_round" android:focusable="true" android:focusableInTouchMode="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/text" android:textSize="16sp" tools:text="電影" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_vod.xml b/app/src/leanback/res/layout/adapter_vod.xml index f0808c33ca..ae54994088 100644 --- a/app/src/leanback/res/layout/adapter_vod.xml +++ b/app/src/leanback/res/layout/adapter_vod.xml @@ -12,7 +12,7 @@ android:id="@+id/image" android:layout_width="match_parent" android:layout_height="80dp" - android:background="?attr/colorSurfaceContainer" + android:background="@color/black_20" android:scaleType="centerCrop" app:shapeAppearanceOverlay="@style/Vod.Grid" /> @@ -24,7 +24,7 @@ android:layout_alignTop="@+id/image" android:layout_alignEnd="@+id/image" android:layout_alignBottom="@+id/image" - android:background="@color/webhtv_color_player_scrim" + android:background="@color/black_50" android:scaleType="center" android:src="@drawable/ic_vod_delete" android:visibility="gone" @@ -41,7 +41,7 @@ android:ellipsize="end" android:maxWidth="104dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="14sp" android:visibility="gone" tools:text="2022" @@ -58,7 +58,7 @@ android:ellipsize="end" android:maxWidth="116dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="14sp" android:visibility="gone" tools:text="瓜子[优](DS)" @@ -74,7 +74,7 @@ android:ellipsize="end" android:maxWidth="132dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="14sp" tools:text="00:32 / 13:08" /> @@ -85,8 +85,8 @@ android:layout_below="@+id/image" android:layout_alignStart="@+id/image" android:layout_alignEnd="@+id/image" - android:background="?attr/colorSurfaceContainer" - app:indicatorColor="?attr/colorPrimary" + android:background="@color/black_20" + app:indicatorColor="@color/white" app:trackColor="@color/transparent" app:trackStopIndicatorSize="0dp" app:trackThickness="2dp" /> @@ -116,7 +116,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="云秀行" /> @@ -131,7 +131,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaperVariant" + android:textColor="@color/white_70" android:textSize="14sp" tools:text="第01集. 海边散步" /> diff --git a/app/src/leanback/res/layout/adapter_vod_list.xml b/app/src/leanback/res/layout/adapter_vod_list.xml index 7df65f0890..608cca4b61 100644 --- a/app/src/leanback/res/layout/adapter_vod_list.xml +++ b/app/src/leanback/res/layout/adapter_vod_list.xml @@ -34,7 +34,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="蜘蛛人" /> @@ -44,7 +44,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="14sp" tools:text="1080p" /> diff --git a/app/src/leanback/res/layout/adapter_vod_oval.xml b/app/src/leanback/res/layout/adapter_vod_oval.xml index 7a739136bb..f059d22608 100644 --- a/app/src/leanback/res/layout/adapter_vod_oval.xml +++ b/app/src/leanback/res/layout/adapter_vod_oval.xml @@ -12,7 +12,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="8dp" - android:background="?attr/colorSurfaceContainer" + android:background="@color/black_20" app:shapeAppearanceOverlay="@style/Vod.Circle" /> diff --git a/app/src/leanback/res/layout/adapter_vod_rect.xml b/app/src/leanback/res/layout/adapter_vod_rect.xml index 4b5ebca736..3847c13395 100644 --- a/app/src/leanback/res/layout/adapter_vod_rect.xml +++ b/app/src/leanback/res/layout/adapter_vod_rect.xml @@ -12,7 +12,7 @@ android:id="@+id/image" android:layout_width="match_parent" android:layout_height="80dp" - android:background="?attr/colorSurfaceContainer" + android:background="@color/black_20" app:shapeAppearanceOverlay="@style/Vod.Grid" /> @@ -63,7 +63,7 @@ android:gravity="center" android:padding="6dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="蜘蛛人" /> diff --git a/app/src/leanback/res/layout/dialog_ad_block_stats.xml b/app/src/leanback/res/layout/dialog_ad_block_stats.xml index 6e493ac2fc..66f936e7fa 100644 --- a/app/src/leanback/res/layout/dialog_ad_block_stats.xml +++ b/app/src/leanback/res/layout/dialog_ad_block_stats.xml @@ -1,72 +1,54 @@ - + android:fillViewport="true"> - + android:orientation="vertical" + android:padding="24dp"> - - - - - + android:layout_height="wrap_content" + android:text="@string/ad_block_stats" + android:textColor="@color/dialog_outlined_button_text" + android:textSize="17sp" + android:textStyle="bold" /> + + + + android:padding="16dp"> + android:orientation="vertical"> + + + android:textColor="#5F6368" + android:textSize="12sp" /> + + android:orientation="vertical"> + + + android:textColor="#5F6368" + android:textSize="12sp" /> + + android:orientation="vertical"> + + + android:textColor="#5F6368" + android:textSize="12sp" /> + + - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="44dp" + android:layout_marginEnd="12dp" + android:minWidth="112dp" + android:text="@string/ad_stats_reset" + android:textSize="14sp" /> + + + - - - - - - - + + diff --git a/app/src/leanback/res/layout/dialog_ad_rule_edit.xml b/app/src/leanback/res/layout/dialog_ad_rule_edit.xml index 8238a9a4ec..375d65a8b0 100644 --- a/app/src/leanback/res/layout/dialog_ad_rule_edit.xml +++ b/app/src/leanback/res/layout/dialog_ad_rule_edit.xml @@ -20,7 +20,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/ad_rule_edit_title" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="20sp" android:textStyle="bold" /> @@ -29,10 +29,10 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:hint="@string/ad_rule_field_name" - android:textColorHint="?attr/colorOnSurfaceVariant" + android:textColorHint="#5F6368" app:boxBackgroundMode="outline" - app:boxStrokeColor="?attr/colorOutline" - app:hintTextColor="?attr/colorOnSurfaceVariant"> + app:boxStrokeColor="@color/dialog_outlined_button_stroke" + app:hintTextColor="#5F6368"> @@ -52,13 +52,13 @@ android:layout_height="wrap_content" android:layout_marginTop="14dp" android:hint="@string/ad_rule_field_hosts" - android:textColorHint="?attr/colorOnSurfaceVariant" + android:textColorHint="#5F6368" app:boxBackgroundMode="outline" - app:boxStrokeColor="?attr/colorOutline" + app:boxStrokeColor="@color/dialog_outlined_button_stroke" app:helperText="@string/ad_rule_field_hosts_hint" app:helperTextEnabled="true" - app:helperTextTextColor="?attr/colorOnSurfaceVariant" - app:hintTextColor="?attr/colorOnSurfaceVariant"> + app:helperTextTextColor="#5F6368" + app:hintTextColor="#5F6368"> @@ -79,13 +79,13 @@ android:layout_height="wrap_content" android:layout_marginTop="14dp" android:hint="@string/ad_rule_field_regex" - android:textColorHint="?attr/colorOnSurfaceVariant" + android:textColorHint="#5F6368" app:boxBackgroundMode="outline" - app:boxStrokeColor="?attr/colorOutline" + app:boxStrokeColor="@color/dialog_outlined_button_stroke" app:helperText="@string/ad_rule_field_regex_hint" app:helperTextEnabled="true" - app:helperTextTextColor="?attr/colorOnSurfaceVariant" - app:hintTextColor="?attr/colorOnSurfaceVariant"> + app:helperTextTextColor="#5F6368" + app:hintTextColor="#5F6368"> @@ -106,13 +106,13 @@ android:layout_height="wrap_content" android:layout_marginTop="14dp" android:hint="@string/ad_rule_field_exclude" - android:textColorHint="?attr/colorOnSurfaceVariant" + android:textColorHint="#5F6368" app:boxBackgroundMode="outline" - app:boxStrokeColor="?attr/colorOutline" + app:boxStrokeColor="@color/dialog_outlined_button_stroke" app:helperText="@string/ad_rule_field_exclude_hint" app:helperTextEnabled="true" - app:helperTextTextColor="?attr/colorOnSurfaceVariant" - app:hintTextColor="?attr/colorOnSurfaceVariant"> + app:helperTextTextColor="#5F6368" + app:hintTextColor="#5F6368"> @@ -137,7 +137,7 @@ + app:backgroundTint="@color/dialog_primary_button_bg" /> diff --git a/app/src/leanback/res/layout/dialog_ad_rule_manage.xml b/app/src/leanback/res/layout/dialog_ad_rule_manage.xml index 631010cb56..cade28fdf5 100644 --- a/app/src/leanback/res/layout/dialog_ad_rule_manage.xml +++ b/app/src/leanback/res/layout/dialog_ad_rule_manage.xml @@ -10,7 +10,7 @@ - - - - - - - - @@ -110,7 +81,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="10dp" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textSize="13sp" tools:text="来自配置 · 共 12 条(只读)" /> diff --git a/app/src/leanback/res/layout/dialog_ad_rule_preview.xml b/app/src/leanback/res/layout/dialog_ad_rule_preview.xml index 5f958dd069..1f6e927a55 100644 --- a/app/src/leanback/res/layout/dialog_ad_rule_preview.xml +++ b/app/src/leanback/res/layout/dialog_ad_rule_preview.xml @@ -17,7 +17,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/ad_rule_preview_title" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="17sp" android:textStyle="bold" /> @@ -26,7 +26,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="14sp" tools:text="置信度:75%" /> @@ -38,7 +38,7 @@ android:background="@drawable/shape_warning_background" android:padding="10dp" android:text="@string/ad_rule_low_confidence_warning" - android:textColor="?attr/colorError" + android:textColor="#B3261E" android:textSize="13sp" android:visibility="gone" tools:visibility="visible" /> @@ -48,7 +48,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textSize="13sp" tools:text="检测到 DISCONTINUITY 标记和跨域切片,典型广告特征" /> @@ -57,7 +57,7 @@ android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/ad_rule_preview_hosts" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="14sp" android:textStyle="bold" /> @@ -67,7 +67,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:fontFamily="monospace" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textIsSelectable="true" android:textSize="12sp" tools:text="ad.example.com\nadservice.provider.net" /> @@ -77,7 +77,7 @@ android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/ad_rule_preview_regex" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="14sp" android:textStyle="bold" /> @@ -87,7 +87,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:fontFamily="monospace" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textIsSelectable="true" android:textSize="12sp" tools:text=".*\\.mp4\\?ad=.*\n.*\\/preroll\\/.*" /> @@ -97,7 +97,7 @@ android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/ad_rule_preview_exclude" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="14sp" android:textStyle="bold" /> @@ -107,7 +107,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:fontFamily="monospace" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textIsSelectable="true" android:textSize="12sp" tools:text=".*trailer.*\n.*preview.*" /> @@ -121,7 +121,7 @@ + app:backgroundTint="@color/dialog_primary_button_bg" /> diff --git a/app/src/leanback/res/layout/dialog_config.xml b/app/src/leanback/res/layout/dialog_config.xml index aa4f9428ee..67367111e6 100644 --- a/app/src/leanback/res/layout/dialog_config.xml +++ b/app/src/leanback/res/layout/dialog_config.xml @@ -24,28 +24,20 @@ - + android:visibility="gone" /> - - + app:strokeColor="@color/dialog_outlined_button_stroke" + app:strokeWidth="1dp" /> diff --git a/app/src/leanback/res/layout/dialog_content.xml b/app/src/leanback/res/layout/dialog_content.xml index 04caa6f1c3..13a36e5b0b 100644 --- a/app/src/leanback/res/layout/dialog_content.xml +++ b/app/src/leanback/res/layout/dialog_content.xml @@ -11,7 +11,7 @@ android:letterSpacing="0.05" android:lineSpacingExtra="8dp" android:padding="24dp" - android:textColor="?attr/colorOnSurface" + android:textColor="@color/white" android:textSize="16sp" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/dialog_control.xml b/app/src/leanback/res/layout/dialog_control.xml index 9db7c6bf40..d08143e463 100644 --- a/app/src/leanback/res/layout/dialog_control.xml +++ b/app/src/leanback/res/layout/dialog_control.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/shape_dialog_control_glass_panel" - tools:background="?attr/colorSurfaceContainerLow"> + tools:background="#1F1F1F"> - - - - - - - - - + android:minHeight="0dp" + android:minWidth="0dp" + android:scaleX="0.72" + android:scaleY="0.72" /> diff --git a/app/src/leanback/res/layout/dialog_danmaku_setting.xml b/app/src/leanback/res/layout/dialog_danmaku_setting.xml index e80c732395..9e4c84e7e2 100644 --- a/app/src/leanback/res/layout/dialog_danmaku_setting.xml +++ b/app/src/leanback/res/layout/dialog_danmaku_setting.xml @@ -22,7 +22,7 @@ android:layout_weight="1" android:text="@string/danmaku_setting" android:textAppearance="?attr/textAppearanceHeadlineSmall" - android:textColor="?attr/webhtvColorOnWallpaper" /> + android:textColor="@color/white" /> + android:textColor="@color/white" /> diff --git a/app/src/leanback/res/layout/dialog_episode.xml b/app/src/leanback/res/layout/dialog_episode.xml index adc69f51ff..269eecbb2e 100644 --- a/app/src/leanback/res/layout/dialog_episode.xml +++ b/app/src/leanback/res/layout/dialog_episode.xml @@ -21,7 +21,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/detail_episode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="#EAF2F8" android:textSize="18sp" /> + app:tint="#EAF2F8" /> diff --git a/app/src/leanback/res/layout/dialog_episode_detail.xml b/app/src/leanback/res/layout/dialog_episode_detail.xml index 04976d757a..e58038e48f 100644 --- a/app/src/leanback/res/layout/dialog_episode_detail.xml +++ b/app/src/leanback/res/layout/dialog_episode_detail.xml @@ -6,7 +6,7 @@ android:id="@+id/root" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?attr/colorSurfaceContainerLowest"> + android:background="@color/black"> @@ -41,7 +40,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:adjustViewBounds="true" - android:background="?attr/colorSurfaceContainerLowest" + android:background="#1A1A1A" android:contentDescription="@null" android:maxHeight="280dp" android:scaleType="centerCrop" /> @@ -54,7 +53,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" - android:textColor="?attr/colorOnSurface" + android:textColor="#000000" android:textSize="22sp" android:textStyle="bold" tools:text="第1集 - 去有鳳凰的地方" /> @@ -65,7 +64,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="6dp" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#666666" android:textIsSelectable="true" android:textSize="14sp" android:visibility="gone" @@ -83,7 +82,7 @@ android:id="@+id/date" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#555555" android:textSize="15sp" tools:text="播出日期: 2024-01-01" /> @@ -92,7 +91,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="20dp" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#555555" android:textSize="15sp" tools:text="时长: 45 分钟" /> @@ -104,7 +103,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="6dp" - android:textColor="@color/webhtv_color_warning" + android:textColor="#F57C00" android:textSize="17sp" tools:text="★ 8.5" /> @@ -115,7 +114,7 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="剧情简介" - android:textColor="?attr/colorOnSurface" + android:textColor="#1A1A1A" android:textSize="18sp" android:textStyle="bold" /> @@ -126,7 +125,7 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:lineSpacingExtra="4dp" - android:textColor="?attr/colorOnSurface" + android:textColor="#333333" android:textSize="15sp" tools:text="这是剧集的详细简介内容..." /> @@ -137,7 +136,7 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="本集图片" - android:textColor="?attr/colorOnSurface" + android:textColor="#1A1A1A" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -162,7 +161,7 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="@string/detail_episode_guests" - android:textColor="?attr/colorOnSurface" + android:textColor="#1A1A1A" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> diff --git a/app/src/leanback/res/layout/dialog_episode_list.xml b/app/src/leanback/res/layout/dialog_episode_list.xml index e16ea5e119..1f4ca03dc7 100644 --- a/app/src/leanback/res/layout/dialog_episode_list.xml +++ b/app/src/leanback/res/layout/dialog_episode_list.xml @@ -9,8 +9,7 @@ android:clipToPadding="false" android:fillViewport="true" android:paddingStart="20dp" - android:paddingEnd="20dp" - android:theme="@style/ThemeOverlay.WebHTV.GlassFocusRings"> + android:paddingEnd="20dp"> @@ -44,7 +43,7 @@ android:layout_marginTop="8dp" android:gravity="center_vertical" android:text="@string/detail_season" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" @@ -67,7 +66,7 @@ android:layout_marginTop="8dp" android:gravity="center_vertical" android:text="@string/detail_episode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" /> diff --git a/app/src/leanback/res/layout/dialog_exit_confirm.xml b/app/src/leanback/res/layout/dialog_exit_confirm.xml index efb38f1bf6..e1c504cc38 100644 --- a/app/src/leanback/res/layout/dialog_exit_confirm.xml +++ b/app/src/leanback/res/layout/dialog_exit_confirm.xml @@ -25,7 +25,7 @@ android:gravity="center" android:includeFontPadding="false" android:text="!" - android:textColor="?attr/colorPrimary" + android:textColor="#174EA6" android:textSize="24sp" android:textStyle="bold" /> @@ -42,7 +42,7 @@ android:layout_height="wrap_content" android:singleLine="true" android:text="@string/exit_confirm_title" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="22sp" android:textStyle="bold" tools:text="退出应用" /> @@ -54,7 +54,7 @@ android:layout_marginTop="8dp" android:lineSpacingExtra="4dp" android:text="@string/exit_confirm_message" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textSize="14sp" tools:text="应用将关闭,播放和后台服务也会停止。" /> @@ -67,7 +67,7 @@ android:layout_height="1dp" android:layout_marginTop="22dp" android:layout_marginBottom="18dp" - android:background="?attr/colorSurfaceContainer" /> + android:background="#E8EDF5" /> diff --git a/app/src/leanback/res/layout/dialog_history.xml b/app/src/leanback/res/layout/dialog_history.xml index 8cf65af91d..c1f5cab81b 100644 --- a/app/src/leanback/res/layout/dialog_history.xml +++ b/app/src/leanback/res/layout/dialog_history.xml @@ -1,36 +1,13 @@ - - - - - - - + android:clipChildren="false" + android:clipToPadding="false" + android:overScrollMode="never" + android:padding="24dp" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + app:maxHeight="352dp" /> diff --git a/app/src/leanback/res/layout/dialog_home_button.xml b/app/src/leanback/res/layout/dialog_home_button.xml index 54fbc3ffe0..e4577bdd80 100644 --- a/app/src/leanback/res/layout/dialog_home_button.xml +++ b/app/src/leanback/res/layout/dialog_home_button.xml @@ -17,7 +17,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_home_buttons" - android:textColor="?attr/colorOnSurface" + android:textColor="@color/white" android:textSize="22sp" /> diff --git a/app/src/leanback/res/layout/dialog_home_menu.xml b/app/src/leanback/res/layout/dialog_home_menu.xml index f0fe75455d..7295c969f9 100644 --- a/app/src/leanback/res/layout/dialog_home_menu.xml +++ b/app/src/leanback/res/layout/dialog_home_menu.xml @@ -13,5 +13,5 @@ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" app:maxHeight="352dp" app:spanCount="3" - tools:itemCount="11" + tools:itemCount="9" tools:listitem="@layout/adapter_home_menu" /> diff --git a/app/src/leanback/res/layout/dialog_offset.xml b/app/src/leanback/res/layout/dialog_offset.xml index 5a5705a7aa..0d3b14750d 100644 --- a/app/src/leanback/res/layout/dialog_offset.xml +++ b/app/src/leanback/res/layout/dialog_offset.xml @@ -23,7 +23,7 @@ android:layout_weight="1" android:text="@string/offset_setting" android:textAppearance="?attr/textAppearanceHeadlineSmall" - android:textColor="?attr/webhtvColorOnWallpaper" /> + android:textColor="@color/white" /> + android:textColor="@color/white" /> @@ -65,7 +65,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?attr/textAppearanceBodyMedium" - android:textColor="?attr/webhtvColorOnWallpaper" /> + android:textColor="@color/white" /> @@ -111,7 +111,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?attr/textAppearanceBodyMedium" - android:textColor="?attr/webhtvColorOnWallpaper" /> + android:textColor="@color/white" /> diff --git a/app/src/leanback/res/layout/dialog_pass.xml b/app/src/leanback/res/layout/dialog_pass.xml index 41b1b28a25..093baaf0b8 100644 --- a/app/src/leanback/res/layout/dialog_pass.xml +++ b/app/src/leanback/res/layout/dialog_pass.xml @@ -30,7 +30,7 @@ android:gravity="center" android:singleLine="true" android:text="@string/dialog_positive" - android:textColor="?attr/colorOnSurface" + android:textColor="@color/white" android:textSize="14sp" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/dialog_player_osd.xml b/app/src/leanback/res/layout/dialog_player_osd.xml index 11c618c08d..6e259a2290 100644 --- a/app/src/leanback/res/layout/dialog_player_osd.xml +++ b/app/src/leanback/res/layout/dialog_player_osd.xml @@ -19,7 +19,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/player_osd" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="18sp" android:textStyle="bold" /> diff --git a/app/src/leanback/res/layout/dialog_quick_search.xml b/app/src/leanback/res/layout/dialog_quick_search.xml index 4b47ec0cc2..577e0253af 100644 --- a/app/src/leanback/res/layout/dialog_quick_search.xml +++ b/app/src/leanback/res/layout/dialog_quick_search.xml @@ -10,8 +10,7 @@ android:clipToPadding="false" android:orientation="vertical" android:paddingStart="16dp" - android:paddingEnd="16dp" - android:theme="@style/ThemeOverlay.WebHTV.GlassFocusRings"> + android:paddingEnd="16dp"> @@ -38,7 +37,7 @@ android:focusableInTouchMode="true" android:gravity="center" android:text="@string/search_loading" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white_80" android:textSize="18sp" /> @@ -52,7 +52,7 @@ - - - - - - - - - + android:padding="12dp"> @@ -70,8 +30,8 @@ android:paddingStart="16dp" android:paddingEnd="16dp" android:singleLine="true" - android:textColor="?attr/colorOnSurface" - android:textColorHint="?attr/colorOnSurfaceVariant" + android:textColor="@color/white" + android:textColorHint="@color/text" android:textSize="16sp" app:drawableStartCompat="@drawable/ic_site_search" /> @@ -88,7 +48,7 @@ android:id="@+id/groupScroll" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="14dp" + android:layout_marginBottom="6dp" android:clipToPadding="false" android:overScrollMode="never" android:scrollbars="none" @@ -106,38 +66,40 @@ android:id="@+id/groupHint" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="8dp" + android:layout_marginBottom="10dp" android:accessibilityLiveRegion="polite" android:importantForAccessibility="yes" android:text="@string/site_group_sort_tv_hint" - android:textColor="?attr/colorOnSurfaceVariant" - android:textSize="13sp" + android:textColor="@color/text" + android:textSize="14sp" android:visibility="gone" /> + app:tint="@color/site_action_icon" /> + app:tint="@color/site_action_icon" /> + app:tint="@color/site_action_icon" /> + app:tint="@color/site_action_icon" /> + app:tint="@color/site_action_icon" /> diff --git a/app/src/leanback/res/layout/dialog_theme.xml b/app/src/leanback/res/layout/dialog_theme.xml deleted file mode 100644 index 173d12d1c1..0000000000 --- a/app/src/leanback/res/layout/dialog_theme.xml +++ /dev/null @@ -1,11 +0,0 @@ - - diff --git a/app/src/leanback/res/layout/dialog_timer.xml b/app/src/leanback/res/layout/dialog_timer.xml index 909a4b4ae7..48750c4eed 100644 --- a/app/src/leanback/res/layout/dialog_timer.xml +++ b/app/src/leanback/res/layout/dialog_timer.xml @@ -20,7 +20,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/play_timer" - android:textColor="@color/webhtv_color_player_control_muted" + android:textColor="@color/white_70" android:textSize="16sp" /> @@ -98,7 +98,7 @@ android:layout_height="wrap_content" android:layout_marginTop="24dp" android:layout_marginBottom="24dp" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="48sp" tools:text="5:00" /> @@ -110,9 +110,9 @@ android:layout_marginBottom="8dp" android:singleLine="true" android:text="@string/timer_delay" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" app:backgroundTint="@color/selector_dialog_glass_button_tint" - app:rippleColor="@color/webhtv_color_overlay_light" /> + app:rippleColor="#33FFFFFF" /> + android:textColor="@color/white_90" + app:strokeColor="#66FFFFFF" /> diff --git a/app/src/leanback/res/layout/dialog_title.xml b/app/src/leanback/res/layout/dialog_title.xml index 14edfbed3c..1bffd8c47e 100644 --- a/app/src/leanback/res/layout/dialog_title.xml +++ b/app/src/leanback/res/layout/dialog_title.xml @@ -22,7 +22,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/dialog_select_title" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> diff --git a/app/src/leanback/res/layout/dialog_track.xml b/app/src/leanback/res/layout/dialog_track.xml index 93eec633c1..1331be4004 100644 --- a/app/src/leanback/res/layout/dialog_track.xml +++ b/app/src/leanback/res/layout/dialog_track.xml @@ -21,7 +21,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="選擇字幕" /> @@ -29,12 +29,12 @@ android:id="@+id/secondary" android:layout_width="wrap_content" android:layout_height="36dp" - android:background="@drawable/selector_glass_icon_action" + android:background="?attr/selectableItemBackgroundBorderless" android:focusable="true" android:gravity="center" android:paddingHorizontal="12dp" android:text="@string/play_track_secondary_subtitle" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="13sp" android:visibility="gone" tools:visibility="visible" /> @@ -43,9 +43,8 @@ android:id="@+id/choose" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:background="@drawable/selector_glass_icon_action" + android:background="?attr/selectableItemBackgroundBorderless" android:focusable="true" - android:padding="6dp" android:src="@drawable/ic_action_choose" android:visibility="gone" tools:visibility="visible" /> @@ -55,10 +54,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" - android:background="@drawable/selector_glass_icon_action" + android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="@string/subtitle_manual_search" android:focusable="true" - android:padding="6dp" android:src="@drawable/ic_action_search" android:visibility="gone" tools:visibility="visible" /> @@ -68,10 +66,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" - android:background="@drawable/selector_glass_icon_action" + android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="@string/subtitle_ai_translate_action" android:focusable="true" - android:padding="6dp" android:src="@drawable/ic_action_ai" android:visibility="gone" tools:visibility="visible" /> @@ -81,10 +78,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" - android:background="@drawable/selector_glass_icon_action" + android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="@string/subtitle_realtime_action" android:focusable="true" - android:padding="6dp" android:src="@drawable/ic_action_realtime_subtitle" android:visibility="gone" tools:visibility="visible" /> @@ -94,10 +90,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" - android:background="@drawable/selector_glass_icon_action" + android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="@string/subtitle_ai_regenerate_action" android:focusable="true" - android:padding="6dp" android:src="@drawable/ic_action_refresh" android:visibility="gone" tools:visibility="visible" /> @@ -107,9 +102,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" - android:background="@drawable/selector_glass_icon_action" + android:background="?attr/selectableItemBackgroundBorderless" android:focusable="true" - android:padding="6dp" android:src="@drawable/ic_action_subtitle" android:visibility="gone" tools:visibility="visible" /> @@ -119,50 +113,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" - android:background="@drawable/selector_glass_icon_action" + android:background="?attr/selectableItemBackgroundBorderless" android:focusable="true" - android:padding="6dp" android:src="@drawable/ic_action_offset" android:visibility="gone" tools:visibility="visible" /> - - - - - - - - @@ -40,8 +40,8 @@ android:inputType="text" android:nextFocusDown="@id/positive" android:singleLine="true" - android:textColor="?attr/colorOnSurface" - android:textColorHint="?attr/colorOnSurfaceVariant" + android:textColor="#202124" + android:textColorHint="#8A8F98" android:textSize="18sp" /> + app:backgroundTint="@color/dialog_primary_button_bg" /> + app:strokeColor="@color/dialog_outlined_button_stroke" /> diff --git a/app/src/leanback/res/layout/dialog_update.xml b/app/src/leanback/res/layout/dialog_update.xml index 8da7903180..f6b527eecb 100644 --- a/app/src/leanback/res/layout/dialog_update.xml +++ b/app/src/leanback/res/layout/dialog_update.xml @@ -23,7 +23,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/update_title" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="22sp" android:textStyle="bold" /> @@ -37,7 +37,7 @@ android:nextFocusDown="@id/stableItem" android:padding="10dp" android:src="@drawable/ic_dialog_close" - app:tint="@color/fixed_light_focus_icon" /> + app:tint="#5F6368" /> @@ -47,7 +47,7 @@ android:layout_height="wrap_content" android:layout_marginTop="6dp" android:text="@string/update_hint" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textSize="14sp" /> @@ -89,9 +88,8 @@ android:id="@+id/stableName" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:duplicateParentState="true" android:text="@string/update_channel_stable" - android:textColor="@color/update_version_item_title" + android:textColor="#202124" android:textSize="18sp" android:textStyle="bold" /> @@ -106,7 +104,7 @@ android:paddingTop="5dp" android:paddingEnd="9dp" android:paddingBottom="5dp" - android:textColor="?attr/colorPrimary" + android:textColor="#174EA6" android:textSize="13sp" tools:text="可更新" /> @@ -116,9 +114,8 @@ android:layout_height="wrap_content" android:layout_marginStart="12dp" android:layout_weight="1" - android:duplicateParentState="true" android:gravity="end" - android:textColor="@color/update_version_item_accent" + android:textColor="#174EA6" android:textSize="14sp" tools:text="收起" /> @@ -129,9 +126,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:duplicateParentState="true" android:maxLines="2" - android:textColor="@color/update_version_item_meta" + android:textColor="#5F6368" android:textSize="14sp" tools:text="5.6.0-202608101200" /> @@ -140,7 +136,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="14dp" - android:duplicateParentState="true" android:orientation="vertical" tools:visibility="visible"> @@ -148,9 +143,8 @@ android:id="@+id/stableDesc" android:layout_width="match_parent" android:layout_height="wrap_content" - android:duplicateParentState="true" android:lineSpacingExtra="3dp" - android:textColor="@color/update_version_item_title" + android:textColor="#303134" android:textSize="16sp" tools:text="### 更新内容 - 稳定版更新说明" /> @@ -169,9 +163,7 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="15sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" - app:strokeColor="@color/focus_ring_primary_container" - app:strokeWidth="@dimen/webhtv_focus_ring_width" /> + app:cornerRadius="8dp" /> @@ -193,7 +185,6 @@ @@ -201,9 +192,8 @@ android:id="@+id/betaName" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:duplicateParentState="true" android:text="@string/update_channel_beta" - android:textColor="@color/update_version_item_title" + android:textColor="#202124" android:textSize="18sp" android:textStyle="bold" /> @@ -218,7 +208,7 @@ android:paddingTop="5dp" android:paddingEnd="9dp" android:paddingBottom="5dp" - android:textColor="?attr/colorPrimary" + android:textColor="#174EA6" android:textSize="13sp" tools:text="可更新" /> @@ -228,9 +218,8 @@ android:layout_height="wrap_content" android:layout_marginStart="12dp" android:layout_weight="1" - android:duplicateParentState="true" android:gravity="end" - android:textColor="@color/update_version_item_accent" + android:textColor="#174EA6" android:textSize="14sp" tools:text="展开" /> @@ -241,9 +230,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:duplicateParentState="true" android:maxLines="2" - android:textColor="@color/update_version_item_meta" + android:textColor="#5F6368" android:textSize="14sp" tools:text="5.6.0-beta-202608101200" /> @@ -252,7 +240,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="14dp" - android:duplicateParentState="true" android:orientation="vertical" tools:visibility="gone"> @@ -260,9 +247,8 @@ android:id="@+id/betaDesc" android:layout_width="match_parent" android:layout_height="wrap_content" - android:duplicateParentState="true" android:lineSpacingExtra="3dp" - android:textColor="@color/update_version_item_title" + android:textColor="#303134" android:textSize="16sp" tools:text="### 更新内容 - 测试版更新说明" /> @@ -281,9 +267,7 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="15sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" - app:strokeColor="@color/focus_ring_primary_container" - app:strokeWidth="@dimen/webhtv_focus_ring_width" /> + app:cornerRadius="8dp" /> @@ -306,7 +290,7 @@ android:id="@+id/progressText" android:layout_width="match_parent" android:layout_height="wrap_content" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textSize="14sp" tools:text="正在下载 46% · 3.2 MB/s · 已用 00:12" /> @@ -336,8 +320,6 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="15sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" - app:strokeColor="@color/focus_ring_primary_container" - app:strokeWidth="@dimen/webhtv_focus_ring_width" /> + app:cornerRadius="8dp" /> diff --git a/app/src/leanback/res/layout/view_control_cast.xml b/app/src/leanback/res/layout/view_control_cast.xml index c031bf46eb..0c8043f7e6 100644 --- a/app/src/leanback/res/layout/view_control_cast.xml +++ b/app/src/leanback/res/layout/view_control_cast.xml @@ -4,7 +4,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" - android:background="@color/transparent" + android:background="@drawable/shape_controller_scrim" android:orientation="vertical" android:paddingStart="24dp" android:paddingTop="24dp" diff --git a/app/src/leanback/res/layout/view_control_live.xml b/app/src/leanback/res/layout/view_control_live.xml index b5e5ae2d7d..2900b2bfd9 100644 --- a/app/src/leanback/res/layout/view_control_live.xml +++ b/app/src/leanback/res/layout/view_control_live.xml @@ -4,7 +4,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" - android:background="@color/transparent" + android:background="@drawable/shape_controller_scrim" android:orientation="vertical" android:paddingStart="24dp" android:paddingTop="24dp" diff --git a/app/src/leanback/res/layout/view_control_live_action.xml b/app/src/leanback/res/layout/view_control_live_action.xml index 227743c5da..cff1739e81 100644 --- a/app/src/leanback/res/layout/view_control_live_action.xml +++ b/app/src/leanback/res/layout/view_control_live_action.xml @@ -6,9 +6,7 @@ android:fillViewport="true" android:scrollbars="none"> - - - - - - + android:textColor="@color/white" + android:textSize="16sp" /> - - - - - + \ No newline at end of file diff --git a/app/src/leanback/res/layout/view_progress.xml b/app/src/leanback/res/layout/view_progress.xml index 3c50d77a2a..b75a215528 100644 --- a/app/src/leanback/res/layout/view_progress.xml +++ b/app/src/leanback/res/layout/view_progress.xml @@ -5,17 +5,12 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + app:indicatorColor="@color/white" /> @@ -77,33 +76,6 @@ android:textSize="16sp" tools:text="@string/error_play_url" /> - - - - - - - - - - - - - - - - - - - diff --git a/app/src/leanback/res/mipmap-anydpi-v26/ic_banner.xml b/app/src/leanback/res/mipmap-anydpi-v26/ic_banner.xml new file mode 100644 index 0000000000..41fb9f5711 --- /dev/null +++ b/app/src/leanback/res/mipmap-anydpi-v26/ic_banner.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/leanback/res/mipmap-hdpi/ic_banner.png b/app/src/leanback/res/mipmap-hdpi/ic_banner.png deleted file mode 100644 index a539020936..0000000000 Binary files a/app/src/leanback/res/mipmap-hdpi/ic_banner.png and /dev/null differ diff --git a/app/src/leanback/res/mipmap-mdpi/ic_banner.png b/app/src/leanback/res/mipmap-mdpi/ic_banner.png deleted file mode 100644 index 35b6959e02..0000000000 Binary files a/app/src/leanback/res/mipmap-mdpi/ic_banner.png and /dev/null differ diff --git a/app/src/leanback/res/mipmap-xhdpi/ic_banner.png b/app/src/leanback/res/mipmap-xhdpi/ic_banner.png deleted file mode 100644 index 5924b902b6..0000000000 Binary files a/app/src/leanback/res/mipmap-xhdpi/ic_banner.png and /dev/null differ diff --git a/app/src/leanback/res/mipmap-xxhdpi/ic_banner.png b/app/src/leanback/res/mipmap-xxhdpi/ic_banner.png deleted file mode 100644 index a2e51d1107..0000000000 Binary files a/app/src/leanback/res/mipmap-xxhdpi/ic_banner.png and /dev/null differ diff --git a/app/src/leanback/res/mipmap-xxxhdpi/ic_banner.png b/app/src/leanback/res/mipmap-xxxhdpi/ic_banner.png deleted file mode 100644 index 1de2ee5dfc..0000000000 Binary files a/app/src/leanback/res/mipmap-xxxhdpi/ic_banner.png and /dev/null differ diff --git a/app/src/leanback/res/values-zh-rCN/strings.xml b/app/src/leanback/res/values-zh-rCN/strings.xml index b65ba7f22c..542ad4a341 100644 --- a/app/src/leanback/res/values-zh-rCN/strings.xml +++ b/app/src/leanback/res/values-zh-rCN/strings.xml @@ -9,9 +9,6 @@ 推送 投屏 设置 - 站点注入 - 站点注入:开 - 站点注入:关 倍速 缩放 长标题列数 @@ -47,5 +44,4 @@ 删除全部记录? 观看记录将会全部删除。 - 已重置筛选 diff --git a/app/src/leanback/res/values-zh-rTW/strings.xml b/app/src/leanback/res/values-zh-rTW/strings.xml index 6bc0a80ad7..f57ba8eeb1 100644 --- a/app/src/leanback/res/values-zh-rTW/strings.xml +++ b/app/src/leanback/res/values-zh-rTW/strings.xml @@ -9,9 +9,6 @@ 推送 投屏 設定 - 站點注入 - 站點注入:開 - 站點注入:關 倍速 縮放 長標題列數 @@ -43,5 +40,4 @@ 1 小時 延長 5 分鐘 取消定時器 - 已重置篩選 diff --git a/app/src/leanback/res/values/strings.xml b/app/src/leanback/res/values/strings.xml index f3f44c4062..a68415a13e 100644 --- a/app/src/leanback/res/values/strings.xml +++ b/app/src/leanback/res/values/strings.xml @@ -9,9 +9,6 @@ Push Cast Setting - Site injection - Site injection: On - Site injection: Off Speed Scale Long title columns @@ -47,5 +44,4 @@ Delete all records? All history records will be removed. - Filters cleared diff --git a/app/src/leanback/res/values/styles.xml b/app/src/leanback/res/values/styles.xml index 1e32296eb7..3a6c5f39b4 100644 --- a/app/src/leanback/res/values/styles.xml +++ b/app/src/leanback/res/values/styles.xml @@ -1,57 +1,14 @@ - - - - @@ -84,7 +41,7 @@ true true @id/timeBar - @color/player_control_text + @color/text 14sp @@ -93,7 +50,7 @@ wrap_content 13dp 7dp - @color/webhtv_color_player_control_muted + @color/white_70 13sp bold @@ -117,7 +74,7 @@ 1 ?attr/selectableItemBackgroundBorderless center - @color/webhtv_color_player_control + @color/white 12sp true @@ -129,7 +86,7 @@ true 8dp center - @color/webhtv_color_player_control + @color/white - - - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e0d9917a78..02de062278 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,13 +22,36 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - @@ -162,12 +159,6 @@ - - - - diff --git a/app/src/main/assets/VodPlus/EnvFiles/T4Proxy.js b/app/src/main/assets/VodPlus/EnvFiles/T4Proxy.js deleted file mode 100644 index cceff0f862..0000000000 --- a/app/src/main/assets/VodPlus/EnvFiles/T4Proxy.js +++ /dev/null @@ -1,450 +0,0 @@ -#!/usr/bin/env node -// ============================================================================ -// WebHTV 实验室 · T4 代理服务器(Node 版,参考实现) -// ============================================================================ -// -// 用途:把壳子当前加载的所有站源(jar / js / py / php 以及 t1 / t4 远端接口) -// 统一暴露成苹果CMS V10(T4)接口,供 T4 客户端直接订阅。 -// -// 实现方式:调用 App 内置 /vod/api(默认 http://127.0.0.1:9978/vod/api)。 -// 转换只在 App 端维护,Node 入口不再重复走旧 /spider 的 T3-only/无分页路径。 -// Node 运行时仅用于保留实验室入口;不安装 Node 也能直接使用 App 内置网关。 -// -// 与 App 内置 /vod/api 的关系: -// - App 内置 /vod/api(见 app/src/main/java/.../server/process/VodApi.java) -// 在同一进程里直接调用爬虫,不需要安装任何实验室包,是首选方式; -// - 本脚本是不装 Node 也能读的参考实现,也是「实验室 → Node.js → T4代理服务器」 -// 命令实际执行的脚本,端口默认 10998。 -// -// 用法: -// node T4Proxy.js [--port 10998] [--host http://127.0.0.1:9978] [--key 站点key] -// -// 接口: -// 配置接口: /?ac=config -// 首页接口: /站源key -// 分类接口: /站源key?t=分类ID&pg=页码 -// 详情接口: /站源key?ids=视频ID -// 搜索接口: /站源key?wd=关键词&pg=页码 -// 播放接口: /站源key?flag=播放源&id=播放ID -// 筛选参数: area=地区&year=年份&type=类型&class=剧情&lang=语言 -// -// 提示:ac 参数可省略,系统会根据其他参数自动识别接口类型。 -// ============================================================================ - -const http = require('http'); -const https = require('https'); -const { URL } = require('url'); -const os = require('os'); - -// ============================================================================ -// 配置常量 -// ============================================================================ -const DEFAULT_SPIDER_API_HOST = 'http://127.0.0.1:9978'; -const DEFAULT_PORT = 10998; -const REQUEST_TIMEOUT = 30000; // 毫秒 -const FILTER_FIELDS = ['area', 'year', 'type', 'class', 'lang']; - -// ============================================================================ -// Spider API 客户端 -// ============================================================================ -class SpiderApiClient { - constructor(host, timeout = REQUEST_TIMEOUT) { - this.host = host.replace(/\/$/, ''); - this.timeout = timeout; - } - - async configAction() { - const data = await this.post('/vod/api', { ac: 'config' }); - return Array.isArray(data.sites) ? { code: 0, data } : data; - } - - async execute(key, method, params = {}) { - const input = { key, ...params }; - if (method === 'categoryContent') input.t = params.tid; - if (method === 'playerContent') input.play = params.id; - const data = await this.post('/vod/api', input); - return data.code === -1 ? data : { code: 0, data }; - } - - homeContent(key, filter = true) { - return this.execute(key, 'homeContent', { filter }); - } - - categoryContent(key, tid, page = 1, filter = true, extend = {}) { - return this.execute(key, 'categoryContent', { tid, pg: page, filter, extend }); - } - - detailContent(key, ids) { - return this.execute(key, 'detailContent', { ids: Array.isArray(ids) ? ids : [ids] }); - } - - searchContent(key, keyword, page = 1, quick = false) { - return this.execute(key, 'searchContent', { wd: keyword, pg: page, quick }); - } - - playerContent(key, flag, id, flags = []) { - return this.execute(key, 'playerContent', { flag, id, flags }); - } - - post(endpoint, data) { - return new Promise((resolve) => { - let parsedUrl; - try { - parsedUrl = new URL(this.host + endpoint); - } catch { - resolve({ code: -1, msg: `无效的 Spider API 地址: ${this.host}` }); - return; - } - if (!['http:', 'https:'].includes(parsedUrl.protocol)) { - resolve({ code: -1, msg: '仅支持 HTTP/HTTPS 网关地址' }); - return; - } - const isHttps = parsedUrl.protocol === 'https:'; - const client = isHttps ? https : http; - const postData = JSON.stringify(data); - const options = { - hostname: parsedUrl.hostname, - port: parsedUrl.port || (isHttps ? 443 : 80), - path: parsedUrl.pathname + parsedUrl.search, - method: 'POST', - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'Content-Length': Buffer.byteLength(postData) - }, - timeout: this.timeout - }; - - const req = client.request(options, (res) => { - let body = ''; - res.setEncoding('utf8'); - let bytes = 0; - res.on('data', (chunk) => { - bytes += Buffer.byteLength(chunk); - if (bytes > 16 * 1024 * 1024) req.destroy(new Error('Response too large')); - else body += chunk; - }); - res.on('error', () => resolve({ code: -1, msg: 'Response interrupted' })); - res.on('aborted', () => resolve({ code: -1, msg: 'Response interrupted' })); - res.on('end', () => { - if (res.statusCode < 200 || res.statusCode >= 300) { - resolve({ code: -1, msg: `Gateway HTTP ${res.statusCode}` }); - return; - } - try { - const data = JSON.parse(body); - resolve(data && typeof data === 'object' && !Array.isArray(data) ? data : { code: -1, msg: 'Invalid JSON object' }); - } catch { - resolve({ code: -1, msg: 'Invalid JSON response' }); - } - }); - }); - - req.on('error', (e) => resolve({ code: -1, msg: `Request failed: ${e.message}` })); - req.on('timeout', () => { - req.destroy(); - resolve({ code: -1, msg: 'Request timeout' }); - }); - - req.write(postData); - req.end(); - }); - } -} - -// ============================================================================ -// Type 4 代理 -// ============================================================================ -class SpiderApiProxy { - constructor(client, defaultKey = '') { - this.client = client; - this.defaultKey = defaultKey; - } - - async handle(req, res) { - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, HEAD, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - - if (req.method === 'OPTIONS') { - res.statusCode = 204; - res.end(); - return; - } - - if (!['GET', 'POST', 'HEAD'].includes(req.method)) { - res.statusCode = 405; - res.end(JSON.stringify({ code: -1, msg: 'Unsupported HTTP method' })); - return; - } - try { - res.end(JSON.stringify(this.externalize(await this.dispatch(req), req), null, 2)); - } catch (e) { - res.end(JSON.stringify({ code: -1, msg: e.message })); - } - } - - async dispatch(req) { - const input = await this.getInput(req); - - let key = this.getKeyFromPath(req); - if (!key) key = input.key || this.defaultKey; - - // 兼容处理:play 参数作为 id 的别名 - if (input.play && !input.id) input.id = input.play; - - const ac = this.route(input); - if (ac === 'config' || (!key && Object.keys(input).length === 0)) return await this.getProxiedConfig(req); - - if (!key) return { code: -1, msg: '缺少 key 参数' }; - - const pg = Math.max(1, parseInt(input.pg, 10) || 1); - let apiResult = null; - - switch (ac) { - case 'search': - apiResult = await this.client.searchContent(key, input.wd, pg, ['1', 'true', 'yes'].includes(String(input.quick).toLowerCase())); - break; - - case 'detail': { - const ids = Array.isArray(input.ids) ? input.ids : String(input.ids || '').split(',').filter(Boolean); - if (!ids.length) return { code: -1, msg: '缺少 ids 参数' }; - apiResult = await this.client.detailContent(key, ids); - break; - } - - case 'play': { - if (!input.flag || !input.id) return { code: -1, msg: '缺少 flag 或 id 参数' }; - const flags = input.flags ? String(input.flags).split(',') : []; - apiResult = await this.client.playerContent(key, input.flag, input.id, flags); - // 播放接口直接返回原始数据,不做苹果CMS 信封包装 - if (apiResult && apiResult.code === 0) return apiResult.data || apiResult; - return { code: -1, msg: (apiResult && apiResult.msg) || '请求失败' }; - } - - case 'list': - default: { - const t = input.t || ''; - const filterValue = input.filter === undefined ? input.f : input.filter; - const filter = filterValue === undefined || ['1', 'true', 'yes'].includes(String(filterValue).toLowerCase()); - if (!t) { - apiResult = await this.client.homeContent(key, filter); - } else { - const extend = Object.create(null); - const merge = (value) => { - if (value && typeof value === 'object' && !Array.isArray(value)) Object.assign(extend, value); - }; - try { if (input.ext) merge(JSON.parse(Buffer.from(String(input.ext), 'base64').toString('utf8'))); } catch { } - try { merge(typeof input.extend === 'string' ? JSON.parse(input.extend) : input.extend); } catch { } - FILTER_FIELDS.forEach((field) => { - if (input[field]) extend[field] = input[field]; - }); - apiResult = await this.client.categoryContent(key, t, pg, filter, extend); - } - break; - } - } - - if (apiResult && apiResult.code === 0) return this.formatMacCmsResponse(apiResult.data, pg); - return { code: -1, msg: (apiResult && apiResult.msg) || '请求失败' }; - } - - /** ac=detail 也用于分类;保持与 App T4 客户端一致。 */ - route(input) { - if (input.ac === 'config' || input.ac === 'site') return 'config'; - if (input.wd) return 'search'; - if (input.ids !== undefined) return 'detail'; - if (input.ac === 'play' || input.play || (input.flag && input.id)) return 'play'; - return 'list'; - } - - getKeyFromPath(req) { - const pathParts = req.url.split('?')[0].split('/').filter((p) => p); - if (pathParts.length === 0) return ''; - try { - return decodeURIComponent(pathParts[0]); - } catch { - return pathParts[0]; - } - } - - formatMacCmsResponse(data, currentPage = 1) { - const list = Array.isArray(data && data.list) ? data.list : []; - const listCount = list.length; - const page = data && data.page ? parseInt(data.page) : currentPage; - const limit = data && data.limit ? parseInt(data.limit) : (listCount > 0 ? listCount : 20); - // 与 App 内置 /vod/api 一致:站源没给总页数时按 9999 处理 - const pagecount = data && data.pagecount ? parseInt(data.pagecount) : (listCount ? 9999 : page); - const total = data && data.total !== undefined ? parseInt(data.total) : pagecount * limit; - - const response = { page, pagecount, limit, total, list }; - if (data && data.class) response.class = data.class; - if (data && data.filters) response.filters = data.filters; - return response; - } - - async getProxiedConfig(req) { - const status = await this.client.configAction('status'); - if (status.code !== 0 || !status.data || !status.data.sites) { - return { code: -1, msg: '获取配置失败:请运行含 /vod/api 的 WebHTV,并加载有效点播源' }; - } - - const baseUrl = this.getBaseUrl(req); - const sites = status.data.sites.map((site) => ({ - key: site.key, - name: site.name, - api: `${baseUrl}/?key=${encodeURIComponent(site.key)}`, - type: '4', - searchable: site.searchable ?? 1, - quickSearch: site.quickSearch ?? 1, - filterable: site.filterable ?? 1 - })); - - return { - spider: '', - wallpaper: '', - warningText: '资源来自网络,仅供学习使用', - sites, - doh: status.data.doh || [], - rules: status.data.rules || [], - lives: [] - }; - } - - getBaseUrl(req) { - const protocol = req.connection && req.connection.encrypted ? 'https' : 'http'; - return `${protocol}://${req.headers.host || 'localhost'}`; - } - - // Node 和 App 是两个端口。客户端仍需能访问 App 的媒体代理端口。 - externalize(data, req) { - let upstream; - let publicHost; - try { - upstream = new URL(this.client.host); - publicHost = new URL(this.getBaseUrl(req)).hostname; - } catch { - throw new Error('无效的网关或 Host 地址'); - } - if (!['127.0.0.1', 'localhost', '[::1]'].includes(upstream.hostname)) return data; - const rewrite = (value) => { - if (typeof value === 'string') { - try { - const url = new URL(value); - if (['http:', 'https:'].includes(url.protocol) - && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) - && (url.port || '80') === (upstream.port || '80')) { - url.hostname = publicHost; - return url.toString(); - } - } catch { } - return value; - } - if (Array.isArray(value)) return value.map(rewrite); - if (value && typeof value === 'object') { - for (const field of ['url', 'v', 'values', 'server']) if (value[field] !== undefined) value[field] = rewrite(value[field]); - } - return value; - }; - for (const field of ['url', 'urls', 'playUrl', 'subs', 'drm']) if (data[field] !== undefined) data[field] = rewrite(data[field]); - for (const vod of data.list || []) if (vod && vod.vod_pic) vod.vod_pic = rewrite(vod.vod_pic); - return data; - } - - async getInput(req) { - const input = Object.create(null); - const queryIndex = req.url.indexOf('?'); - if (queryIndex !== -1) { - for (const [key, value] of new URLSearchParams(req.url.slice(queryIndex + 1))) { - input[key] = value; - } - } - if (req.method === 'POST') { - const body = await this.readBody(req); - let parsed; - try { parsed = JSON.parse(body); } catch { throw new Error('POST body 必须是有效 JSON'); } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('POST body 必须是 JSON 对象'); - Object.assign(input, parsed); - } - return input; - } - - readBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - let bytes = 0; - req.on('data', (chunk) => { - bytes += chunk.length; - if (bytes > 1024 * 1024) reject(new Error('Request too large')); - else body += chunk; - }); - req.on('end', () => resolve(body)); - req.on('error', reject); - req.on('aborted', () => reject(new Error('Request interrupted'))); - }); - } -} - -// ============================================================================ -// 主入口 -// ============================================================================ -function parseArgs(argv) { - const args = { port: DEFAULT_PORT, host: DEFAULT_SPIDER_API_HOST, key: '' }; - for (let i = 0; i < argv.length; i++) { - const value = argv[i + 1]; - if ((argv[i] === '--port' || argv[i] === '-p') && value) args.port = parseInt(value, 10); - else if ((argv[i] === '--host' || argv[i] === '-H') && value) args.host = value; - else if ((argv[i] === '--key' || argv[i] === '-k') && value) args.key = value; - } - return args; -} - -function lanAddresses() { - const result = []; - const interfaces = os.networkInterfaces(); - for (const name of Object.keys(interfaces)) { - for (const net of interfaces[name]) { - if (net.family === 'IPv4' && !net.internal) result.push(net.address); - } - } - return result; -} - -function main() { - const args = parseArgs(process.argv.slice(2)); - const client = new SpiderApiClient(args.host, REQUEST_TIMEOUT); - const proxy = new SpiderApiProxy(client, args.key); - const server = http.createServer((req, res) => proxy.handle(req, res)); - - server.listen(args.port, '0.0.0.0', () => { - console.log('Spider API Type 4 代理服务器已启动'); - console.log(`监听地址: http://0.0.0.0:${args.port}`); - lanAddresses().forEach((ip) => console.log(` http://${ip}:${args.port}`)); - console.log(`Spider API: ${args.host}`); - console.log(''); - console.log('使用示例:'); - console.log(` 配置接口: http://localhost:${args.port}?ac=config`); - console.log(` 首页接口: http://localhost:${args.port}/站源key`); - console.log(` 分类接口: http://localhost:${args.port}/站源key?t=分类ID&pg=页码`); - console.log(` 详情接口: http://localhost:${args.port}/站源key?ids=视频ID`); - console.log(` 搜索接口: http://localhost:${args.port}/站源key?wd=关键词&pg=页码`); - console.log(` 播放接口: http://localhost:${args.port}/站源key?flag=播放源&id=播放ID`); - console.log(''); - console.log('提示: ac参数可省略,系统会根据其他参数自动识别接口类型'); - console.log('筛选参数: area=地区&year=年份&type=类型&class=剧情&lang=语言'); - console.log('App 内置同能力接口(无需安装本脚本): /vod/api'); - }); - - server.on('error', (e) => { - if (e.code === 'EADDRINUSE') { - console.error(`错误: 端口 ${args.port} 已被占用,请强制结束app或更换端口后重新运行`); - } else { - console.error('服务器错误:', e.message); - } - process.exit(1); - }); -} - -if (require.main === module) main(); - -module.exports = { SpiderApiClient, SpiderApiProxy, parseArgs }; diff --git a/app/src/main/assets/css/ui.css b/app/src/main/assets/css/ui.css index 8b730f3050..a12f04498c 100644 --- a/app/src/main/assets/css/ui.css +++ b/app/src/main/assets/css/ui.css @@ -1,121 +1,55 @@ :root { - --webhtv-primary: #0B57D0; - --webhtv-on-primary: #FFFFFF; - --webhtv-primary-container: #D3E3FD; - --webhtv-on-primary-container: #041E49; - --webhtv-secondary-container: #DFE2E6; - --webhtv-on-secondary-container: #191C1E; - --webhtv-surface: #F8FAFD; - --webhtv-surface-variant: #E3E3DC; - --webhtv-surface-container: #ECEEF4; - --webhtv-surface-container-high: #E7E8EF; - --webhtv-on-surface: #1A1C1E; - --webhtv-on-surface-variant: #44474F; - --webhtv-outline: #74777F; - --webhtv-outline-variant: #C4C6CF; - --webhtv-error: #B3261E; - --webhtv-on-error: #FFFFFF; - --webhtv-error-container: #F9DEDC; - --webhtv-on-error-container: #410E0B; - --webhtv-success: #146C2E; - --webhtv-warning: #7A5900; - --webhtv-warning-container: #FFDF9A; - --webhtv-on-warning-container: #261A00; - --webhtv-focus: #0B57D0; - --webhtv-inverse-surface: #2F3033; - --webhtv-inverse-on-surface: #F1F0F4; - --webhtv-scrim: rgba(0,0,0,0.32); - --webhtv-shadow: rgba(0,0,0,0.20); - --webhtv-shape-small: 8px; - --webhtv-shape-medium: 12px; - --webhtv-shape-large: 16px; - --webhtv-space-1: 4px; - --webhtv-space-2: 8px; - --webhtv-space-3: 12px; - --webhtv-space-4: 16px; - - --md-primary: var(--webhtv-primary); - --md-on-primary: var(--webhtv-on-primary); - --md-primary-container: var(--webhtv-primary-container); - --md-on-primary-container: var(--webhtv-on-primary-container); - --md-secondary-container: var(--webhtv-secondary-container); - --md-on-secondary-container: var(--webhtv-on-secondary-container); - --md-error: var(--webhtv-error); - --md-on-error: var(--webhtv-on-error); - --md-error-container: var(--webhtv-error-container); - --md-on-error-container: var(--webhtv-on-error-container); - --md-surface: var(--webhtv-surface); - --md-on-surface: var(--webhtv-on-surface); - --md-surface-variant: var(--webhtv-surface-variant); - --md-on-surface-variant: var(--webhtv-on-surface-variant); - --md-outline: var(--webhtv-outline); - --md-outline-variant: var(--webhtv-outline-variant); - --md-surface-container: var(--webhtv-surface-container); - --md-surface-container-high: var(--webhtv-surface-container-high); - --md-inverse-surface: var(--webhtv-inverse-surface); - --md-inverse-on-surface: var(--webhtv-inverse-on-surface); - --md-scrim: var(--webhtv-scrim); - --md-shadow: var(--webhtv-shadow); + --md-primary: #006C4C; + --md-on-primary: #FFFFFF; + --md-primary-container: #89F7C6; + --md-on-primary-container: #00210F; + --md-secondary-container: #C8E6D5; + --md-on-secondary-container: #082015; + --md-error: #BA1A1A; + --md-on-error: #FFFFFF; + --md-error-container: #FFDAD6; + --md-on-error-container: #410002; + --md-surface: #FFFFFF; + --md-on-surface: #15140E; + --md-surface-variant: #E3E3DC; + --md-on-surface-variant: #343430; + --md-outline: #575752; + --md-outline-variant: #C7C7C0; + --md-surface-container: #FFFFFF; + --md-surface-container-high: #F0EFE9; + --md-inverse-surface: #2A2A25; + --md-inverse-on-surface: #F2F1EB; + --md-scrim: rgba(0,0,0,0.32); + --md-shadow: rgba(0,0,0,0.20); } @media (prefers-color-scheme: dark) { :root { - --webhtv-primary: #A8C7FA; - --webhtv-on-primary: #062E6F; - --webhtv-primary-container: #0842A0; - --webhtv-on-primary-container: #D3E3FD; - --webhtv-secondary-container: #44474A; - --webhtv-on-secondary-container: #E2E2E6; - --webhtv-surface: #101418; - --webhtv-surface-variant: #414942; - --webhtv-surface-container: #1F2428; - --webhtv-surface-container-high: #2A2F34; - --webhtv-on-surface: #E2E2E9; - --webhtv-on-surface-variant: #C4C6D0; - --webhtv-outline: #8E9099; - --webhtv-outline-variant: #44474F; - --webhtv-error: #FFB4AB; - --webhtv-on-error: #690005; - --webhtv-error-container: #93000A; - --webhtv-on-error-container: #FFDAD6; - --webhtv-success: #8EDB9F; - --webhtv-warning: #F2C66D; - --webhtv-warning-container: #5C4200; - --webhtv-on-warning-container: #FFDF9A; - --webhtv-focus: #A8C7FA; - --webhtv-inverse-surface: #E2E2E9; - --webhtv-inverse-on-surface: #2F3033; - --webhtv-scrim: rgba(0,0,0,0.54); - --webhtv-shadow: rgba(0,0,0,0.50); - - --md-primary: var(--webhtv-primary); - --md-on-primary: var(--webhtv-on-primary); - --md-primary-container: var(--webhtv-primary-container); - --md-on-primary-container: var(--webhtv-on-primary-container); - --md-secondary-container: var(--webhtv-secondary-container); - --md-on-secondary-container: var(--webhtv-on-secondary-container); - --md-error: var(--webhtv-error); - --md-on-error: var(--webhtv-on-error); - --md-error-container: var(--webhtv-error-container); - --md-on-error-container: var(--webhtv-on-error-container); - --md-surface: var(--webhtv-surface); - --md-on-surface: var(--webhtv-on-surface); - --md-surface-variant: var(--webhtv-surface-variant); - --md-on-surface-variant: var(--webhtv-on-surface-variant); - --md-outline: var(--webhtv-outline); - --md-outline-variant: var(--webhtv-outline-variant); - --md-surface-container: var(--webhtv-surface-container); - --md-surface-container-high: var(--webhtv-surface-container-high); - --md-inverse-surface: var(--webhtv-inverse-surface); - --md-inverse-on-surface: var(--webhtv-inverse-on-surface); - --md-scrim: var(--webhtv-scrim); - --md-shadow: var(--webhtv-shadow); + --md-primary: #6CDBA4; + --md-on-primary: #003824; + --md-primary-container: #005237; + --md-on-primary-container: #89F7C6; + --md-secondary-container: #354B40; + --md-on-secondary-container: #CFE9D8; + --md-error: #FFB4AB; + --md-on-error: #690005; + --md-error-container: #93000A; + --md-on-error-container: #FFDAD6; + --md-surface: #101510; + --md-on-surface: #DEE4DF; + --md-surface-variant: #414942; + --md-on-surface-variant: #C0C9BF; + --md-outline: #8B9389; + --md-outline-variant: #414942; + --md-surface-container: #1C211D; + --md-surface-container-high: #262B27; + --md-inverse-surface: #DEE4DF; + --md-inverse-on-surface: #181D19; + --md-scrim: rgba(0,0,0,0.5); + --md-shadow: rgba(0,0,0,0.5); } } -:root[data-theme="light"] { color-scheme: light; } -:root[data-theme="dark"] { color-scheme: dark; } - /* ══ Reset ══ */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } @@ -128,27 +62,14 @@ html, body { } body.manage-page { ---webhtv-primary: #2563EB; ---webhtv-on-primary: #FFFFFF; ---webhtv-secondary-container: #E8F0FE; ---webhtv-on-secondary-container: #174EA6; ---webhtv-surface: #F6F7FB; ---webhtv-surface-container: #FFFFFF; ---webhtv-surface-container-high: #F9FAFC; ---webhtv-outline: #8B95A7; ---webhtv-outline-variant: #E3E7EF; ---webhtv-success: #11845B; ---webhtv-warning: #B7791F; ---md-primary: var(--webhtv-primary); ---md-on-primary: var(--webhtv-on-primary); ---md-secondary-container: var(--webhtv-secondary-container); ---md-on-secondary-container: var(--webhtv-on-secondary-container); ---md-surface: var(--webhtv-surface); ---md-surface-container: var(--webhtv-surface-container); ---md-surface-container-high: var(--webhtv-surface-container-high); ---md-outline: var(--webhtv-outline); ---md-outline-variant: var(--webhtv-outline-variant); ---webhtv-soft-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); + --md-primary: #1A62D0; + --md-on-primary: #FFFFFF; + --md-secondary-container: #D9E7FF; + --md-on-secondary-container: #0D2B57; + --md-surface: #F4F6FA; + --md-surface-container: #FFFFFF; + --md-surface-container-high: #F8FAFD; + --md-outline-variant: #DDE3EE; } body.manage-page.local-mode .remote-only { @@ -157,28 +78,15 @@ body.manage-page.local-mode .remote-only { @media (prefers-color-scheme: dark) { body.manage-page { - --webhtv-primary: #8AB4FF; - --webhtv-on-primary: #0B1B3A; - --webhtv-secondary-container: #24344E; - --webhtv-on-secondary-container: #D6E3FF; - --webhtv-surface: #101418; - --webhtv-surface-container: #171C22; - --webhtv-surface-container-high: #20262E; - --webhtv-outline: #8C96A8; - --webhtv-outline-variant: #303946; - --webhtv-success: #6DD6A2; - --webhtv-warning: #F2C66D; - --md-primary: var(--webhtv-primary); - --md-on-primary: var(--webhtv-on-primary); - --md-secondary-container: var(--webhtv-secondary-container); - --md-on-secondary-container: var(--webhtv-on-secondary-container); - --md-surface: var(--webhtv-surface); - --md-surface-container: var(--webhtv-surface-container); - --md-surface-container-high: var(--webhtv-surface-container-high); - --md-outline: var(--webhtv-outline); - --md-outline-variant: var(--webhtv-outline-variant); - --webhtv-soft-shadow: none; -} + --md-primary: #8DB8FF; + --md-on-primary: #052B61; + --md-secondary-container: #1D3557; + --md-on-secondary-container: #D9E7FF; + --md-surface: #10151D; + --md-surface-container: #171D27; + --md-surface-container-high: #1F2632; + --md-outline-variant: #354052; + } } /* ══ App Shell ══ */ @@ -298,8 +206,8 @@ body.manage-page.local-mode .remote-only { } .status-pill.warn { - background: var(--webhtv-on-warning-container); - color: var(--webhtv-on-warning-container); + background: #FFE08A; + color: #312300; } .md-btn-compact { @@ -315,9 +223,9 @@ body.manage-page.local-mode .remote-only { .keepalive-card { display: none; margin: 10px 16px 0; - border-color: var(--webhtv-warning); - background: var(--webhtv-warning-container); - color: var(--webhtv-on-warning-container); + border-color: #D19A00; + background: #FFF4CE; + color: #312300; } .md-btn:disabled, @@ -367,9 +275,9 @@ body.manage-page.local-mode .remote-only { @media (prefers-color-scheme: dark) { .status-pill.warn, .keepalive-card { - background: var(--webhtv-warning-container); - color: var(--webhtv-on-warning-container); - border-color: var(--webhtv-warning); + background: #4D3900; + color: #FFE08A; + border-color: #B88A00; } } @@ -1537,53 +1445,35 @@ body.manage-page.local-mode .remote-only { } body.manage-page { ---webhtv-primary: #2563EB; ---webhtv-on-primary: #FFFFFF; ---webhtv-secondary-container: #E8F0FE; ---webhtv-on-secondary-container: #174EA6; ---webhtv-surface: #F6F7FB; ---webhtv-surface-container: #FFFFFF; ---webhtv-surface-container-high: #F9FAFC; ---webhtv-outline: #8B95A7; ---webhtv-outline-variant: #E3E7EF; ---webhtv-success: #11845B; ---webhtv-warning: #B7791F; ---md-primary: var(--webhtv-primary); ---md-on-primary: var(--webhtv-on-primary); ---md-secondary-container: var(--webhtv-secondary-container); ---md-on-secondary-container: var(--webhtv-on-secondary-container); ---md-surface: var(--webhtv-surface); ---md-surface-container: var(--webhtv-surface-container); ---md-surface-container-high: var(--webhtv-surface-container-high); ---md-outline: var(--webhtv-outline); ---md-outline-variant: var(--webhtv-outline-variant); ---webhtv-soft-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); + --md-primary: #2563EB; + --md-on-primary: #FFFFFF; + --md-secondary-container: #E8F0FE; + --md-on-secondary-container: #174EA6; + --md-surface: #F6F7FB; + --md-surface-container: #FFFFFF; + --md-surface-container-high: #F9FAFC; + --md-outline: #8B95A7; + --md-outline-variant: #E3E7EF; + --manage-success: #11845B; + --manage-warn: #B7791F; + --manage-soft-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); } @media (prefers-color-scheme: dark) { body.manage-page { - --webhtv-primary: #8AB4FF; - --webhtv-on-primary: #0B1B3A; - --webhtv-secondary-container: #24344E; - --webhtv-on-secondary-container: #D6E3FF; - --webhtv-surface: #101418; - --webhtv-surface-container: #171C22; - --webhtv-surface-container-high: #20262E; - --webhtv-outline: #8C96A8; - --webhtv-outline-variant: #303946; - --webhtv-success: #6DD6A2; - --webhtv-warning: #F2C66D; - --md-primary: var(--webhtv-primary); - --md-on-primary: var(--webhtv-on-primary); - --md-secondary-container: var(--webhtv-secondary-container); - --md-on-secondary-container: var(--webhtv-on-secondary-container); - --md-surface: var(--webhtv-surface); - --md-surface-container: var(--webhtv-surface-container); - --md-surface-container-high: var(--webhtv-surface-container-high); - --md-outline: var(--webhtv-outline); - --md-outline-variant: var(--webhtv-outline-variant); - --webhtv-soft-shadow: none; -} + --md-primary: #8AB4FF; + --md-on-primary: #0B1B3A; + --md-secondary-container: #24344E; + --md-on-secondary-container: #D6E3FF; + --md-surface: #101418; + --md-surface-container: #171C22; + --md-surface-container-high: #20262E; + --md-outline: #8C96A8; + --md-outline-variant: #303946; + --manage-success: #6DD6A2; + --manage-warn: #F2C66D; + --manage-soft-shadow: none; + } } .manage-page .manage-top { @@ -1625,7 +1515,7 @@ body.manage-page { border: 1px solid var(--md-outline-variant); border-radius: 8px; background: var(--md-surface-container); - box-shadow: var(--webhtv-soft-shadow); + box-shadow: var(--manage-soft-shadow); } .workspace-panel { @@ -1651,8 +1541,8 @@ body.manage-page { padding: 10px 14px; border: 1px solid rgba(183, 121, 31, 0.36); border-radius: 8px; - background: var(--webhtv-warning-container); - color: var(--webhtv-on-warning-container); + background: #FFF7E6; + color: #5B3B00; } .keepalive-text { @@ -1676,23 +1566,23 @@ body.manage-page { height: 10px; margin-top: 6px; border-radius: 50%; - background: var(--webhtv-success); + background: var(--manage-success); box-shadow: 0 0 0 4px rgba(17, 132, 91, 0.16); flex: 0 0 auto; } .warn-dot { - background: var(--webhtv-warning); + background: var(--manage-warn); box-shadow: 0 0 0 4px rgba(183, 121, 31, 0.16); } .ok-dot { - background: var(--webhtv-success); + background: var(--manage-success); box-shadow: 0 0 0 4px rgba(17, 132, 91, 0.16); } .pending-dot { - background: var(--webhtv-warning); + background: var(--manage-warn); box-shadow: 0 0 0 4px rgba(183, 121, 31, 0.16); } @@ -1711,7 +1601,7 @@ body.manage-page { border: 1px solid var(--md-outline-variant); border-radius: 8px; background: var(--md-surface-container); - box-shadow: var(--webhtv-soft-shadow); + box-shadow: var(--manage-soft-shadow); } .target-strip.remote-offline { @@ -1807,13 +1697,13 @@ body.manage-page { width: 9px; height: 9px; border-radius: 50%; - background: var(--webhtv-warning); + background: var(--manage-warn); box-shadow: 0 0 0 3px rgba(183, 121, 31, 0.14); flex: 0 0 auto; } .device-item.online .device-dot { - background: var(--webhtv-success); + background: var(--manage-success); box-shadow: 0 0 0 3px rgba(17, 132, 91, 0.14); } @@ -2055,11 +1945,11 @@ body.manage-page { .login-file-row.pending { border-color: rgba(191, 116, 0, 0.36); - background: var(--webhtv-warning-container); + background: #FFF8ED; } .login-file-row.finding { - background: var(--webhtv-secondary-container); + background: #F1FAF4; } .login-file-row.missing { @@ -2120,14 +2010,14 @@ body.manage-page { .file-tag.high, .file-tag.selected { - background: var(--webhtv-secondary-container); - color: var(--webhtv-success); + background: #DDF4E7; + color: #0B6B42; } .file-tag.medium, .file-tag.pending { - background: var(--webhtv-warning-container); - color: var(--webhtv-on-warning-container); + background: #FFE6BF; + color: #7A4300; } .file-tag.low { @@ -2275,14 +2165,14 @@ body.manage-page { } .login-state-badge.selected { - background: var(--webhtv-secondary-container); - color: var(--webhtv-success); + background: #DDF4E7; + color: #0B6B42; } .login-state-badge.pending, .login-state-badge.partial { - background: var(--webhtv-warning-container); - color: var(--webhtv-on-warning-container); + background: #FFE6BF; + color: #7A4300; } .login-state-badge.missing { @@ -2291,13 +2181,13 @@ body.manage-page { } .login-state-badge.file-type.text { - background: var(--webhtv-secondary-container); - color: var(--webhtv-success); + background: #E5F5EC; + color: #206844; } .login-state-badge.file-type.binary { - background: var(--webhtv-error-container); - color: var(--webhtv-on-error-container); + background: #FCE8EE; + color: #8A1C3D; } .card-actions { @@ -3156,9 +3046,9 @@ body.manage-page { @media (prefers-color-scheme: dark) { .keepalive-card { - background: var(--webhtv-warning-container); - color: var(--webhtv-warning); - border-color: var(--webhtv-warning); + background: #3A2A0A; + color: #F2C66D; + border-color: #6F4C16; } } diff --git a/app/src/main/assets/favicon.ico b/app/src/main/assets/favicon.ico index 85465d3bb9..21bc2e34c3 100644 Binary files a/app/src/main/assets/favicon.ico and b/app/src/main/assets/favicon.ico differ diff --git a/app/src/main/assets/lab_template.json b/app/src/main/assets/lab_template.json index d4a3e7df55..d55f2cff91 100644 --- a/app/src/main/assets/lab_template.json +++ b/app/src/main/assets/lab_template.json @@ -1309,15 +1309,15 @@ { "id": "php_version", "name": "查看版本", - "command": "proot -0 -r {envRootPath}/rootfs -b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -w /root --kill-on-exit /usr/bin/env -i HOME=/root TERM=xterm-256color LANG=C.UTF-8 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /bin/bash --login -c \"php8.3 -v\"", + "command": "proot -0 -r {envRootPath}/rootfs -b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -w /root --kill-on-exit /usr/bin/env -i HOME=/root TERM=xterm-256color LANG=C.UTF-8 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /bin/bash --login -c \"php -v\"", "auto_execute": false, "show_output": true }, { "id": "php_serve", "name": "启动内置服务", - "description": "php8.3 -S 监听指定端口,站点根目录在 rootfs 内", - "command": "proot -0 -r {envRootPath}/rootfs -b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -w /root --kill-on-exit /usr/bin/env -i HOME=/root TERM=xterm-256color LANG=C.UTF-8 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /bin/bash --login -c \"cd /root && php8.3 -S 0.0.0.0:{port}\"", + "description": "php -S 监听指定端口,站点根目录在 rootfs 内", + "command": "proot -0 -r {envRootPath}/rootfs -b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -w /root --kill-on-exit /usr/bin/env -i HOME=/root TERM=xterm-256color LANG=C.UTF-8 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /bin/bash --login -c \"cd /root && php -S 0.0.0.0:{port}\"", "auto_execute": false, "show_output": true, "variables": [ diff --git a/app/src/main/assets/plugin/README.md b/app/src/main/assets/plugin/README.md deleted file mode 100644 index 8d2a5b463e..0000000000 --- a/app/src/main/assets/plugin/README.md +++ /dev/null @@ -1,248 +0,0 @@ -# WebHTV 插件脚本契约(SDK 1) - -本目录包含内置示例插件。插件是外部 `js` / `py` / `html` 文件,形态与站源、字幕源一致。 - -## 1. 脚本约束 - -- **处理器必须是同步函数。** QuickJS wrapper 没有内建 Promise 事件循环,返回 Promise - 会被宿主当作"不支持"处理(引导脚本会把它替换成 `__PROMISE_UNSUPPORTED__` 标记串)。 - 需要异步数据请用同步方式获取,或等待后续切片提供受控的异步能力。 -- **脚本异常不会崩溃应用。** 抛错会让该插件被隔离(禁用),并在审计日志留下记录。 -- **脚本体积上限 512 KiB。** 超过则拒绝加载。 - -## 2. 宿主对象 - -插件脚本执行时,全局已有 `webhtv` 对象: - -```js -webhtv.version // 宿主 SDK 版本,当前 1 -webhtv.on(event, fn) // 订阅事件 -webhtv.action(name, fn) // 注册 UI 槽位点击动作 -webhtv.log(...) // 输出到诊断日志 -webhtv.config // 本插件的参数对象(清单 params 经用户填写的值替换后) -webhtv.request(...) // 受控 HTTP 请求(需 net.request 权限) -``` - -`fn(payload)` 的 `payload` 是 JSON 字符串,需要自行 `JSON.parse`。 - -## 3. 可用事件(需要清单声明 + 对应权限) - -| 事件 | payload | 所需权限 | -| --- | --- | --- | -| `app.foreground` | `{}` | `plugin.events` | -| `app.background` | `{}` | `plugin.events` | -| `activity.created` | `{"activity":"VideoActivity"}` | `plugin.events` | -| `activity.destroyed` | `{"activity":"VideoActivity"}` | `plugin.events` | -| `ui.contentShown` | `{"activity":"TmdbDetailActivity"}` | `plugin.events` 或 `ui.floating` / `ui.toolbar` / `plugin.data.episode` | -| `playback.ready` | `{"activity":"VideoActivity"}` | `plugin.events` | -| `playback.playing` | `{"activity":"VideoActivity","playing":"true"}` | `plugin.events` | -| `playback.state` | `{"activity":"VideoActivity","state":"3"}` | `plugin.events` | -| `playback.pause` / `playback.resume` | `{"activity":"VideoActivity"}` | `plugin.events` | -| `playback.fullscreen` | `{"activity":"VideoActivity","fullscreen":"true"}` | `plugin.events` | -| `episode.changed` | `{"activity":"VideoActivity","episode":"{...}"}` | `plugin.data.episode` | - -> `ui.contentShown` 被多个页面共用(首页、文件、历史、收藏、详情、播放), -> **必须按 payload 里的 `activity` 自行过滤**,不能假设它来自详情页。 -> `playback.*` 来自任意播放页(点播 / 直播 / 音频 / 投屏 / 详情),同样必须按 `activity` -> 自行过滤,不要假设是某一个页面。 - -`app.*` / `activity.*` / `ui.contentShown` / `playback.*` 都是纯生命周期信号,payload 里只有 `activity` -或再加一个播放态字段。业务数据(片名、集号、海报等)**只出现在 `episode.changed` 的 `episode` 键** -(与 `action` 的 `context` 同源同一份 JSON,需要 `plugin.data.episode` 权限)。 - -`playback.*` 的取值口径: - -- `playing` 只有 `"true"` / `"false"` 两个值,来自播放器回调,**不是**页面可见性。 -- `state` 是 Media3 `Player.STATE_*` 的十进制字符串(1 idle / 2 buffering / 3 ready / 4 ended)。 -- `playback.pause` / `playback.resume` 挂的是**播放页 `onPause` / `onResume`(页面可见性)**; - 后台播放、画中画、跳转都可能让播放继续而页面已不可见。要播放器语义请订阅 `playback.playing`。 -- `playback.fullscreen` 的 `fullscreen` 只有 `"true"` / `"false"`,进全屏与退全屏各发一次。 - 取值与入口隐藏判据**同一份口径**:**画中画不算全屏**(PiP 下用户仍在与本页交互,入口保留), - 所以进入/退出画中画也会各发一次——处于全屏时进 PiP 发 `"false"`、退出 PiP 发 `"true"`; - 本来就不是全屏的,进/出 PiP 发出的仍是 `"false"`。 -- 这个事件**手机端才有**:手机播放页与手机内嵌播放的详情页都是发送方;电视上播放页本身就是全屏常态, - 没有"内嵌 ⇄ 全屏"这个切换,电视端不发这个事件(与 §5 `surfaces` 里 `fullscreen` 的适用范围一致)。 - 订阅方不要用 `activity` 名字去推形态,进/退画中画时同一页会连发两次。 - 手机端的**直播页、音频页、投屏页**不是这个事件的发送方(直播没有"内嵌 ⇄ 全屏"这个形态切换), - 而本节上半部分的其它 `playback.*` 事件仍可能来自这些播放页——两者的发送方范围不同,不要混着推。 -- 所有事件在没有订阅者时**零行为**:宿主先问一句"有人听吗",再决定是否构造 payload。 - -## 4. 清单示例 - -App **不再内置任何面板/联网插件**,本目录下只剩一个示例: - -- `example/manifest.json` + `example/hello.js`:**最小可用清单**。一个 floating 槽位、一个事件订阅,用来确认装载、事件派发与故障隔离这三条链路是通的。 - -**完整示例在仓库之外**:[`webhtv-plugin-douban`](https://github.com/Silent1566/webhtv-plugin-douban) 是一个真实可用的独立插件包(豆瓣短评),演示 `panel` 渲染 + `plugin.data.episode` 上下文 + `net.request` 联网三件事凑在一起:点「豆瓣」chip,脚本用片名搜豆瓣条目、取第一条匹配的 subject id、再拉短评列表,最后把结果拼成一段自包含 HTML 交给宿主面板,并渲染 1–5 星打分比例。**读它的价值在于看错误路径怎么处理**——每一步失败都返回一句可读的提示 HTML,而不是抛异常(抛异常会让整个插件被隔离)。 - -最小可用清单: - -```json -{ - "protocol": "webhtv-plugin", - "version": 1, - "id": "example.hello", - "name": "示例插件", - "sdk": 1, - "runtime": { "api": "js", "entry": "assets://plugin/example/hello.js" }, - "events": ["ui.contentShown"], - "ui": [ - { "slot": "floating", "label": "示例", "action": "hello.show", "event": "ui.contentShown" } - ], - "permissions": ["plugin.events", "ui.floating"] -} -``` - -## 5. 槽位与面板渲染 - -清单的 `ui[]` 决定插件在界面上以什么形态出现。SDK 1 渲染三种: - -| 槽位 | 权限 | 宿主的落点 | -| --- | --- | --- | -| `floating` | `ui.floating` | 手机端悬浮按钮,浮在内容之上 | -| `toolbar` | `ui.toolbar` | 播放页控制条末尾追加一枚 chip,电视端的主要入口 | -| `panel` | `ui.panel` | 点 chip 后由宿主打开的侧滑 WebView 面板 | -| `page` | `ui.page` | 整页插件,SDK 1 未渲染 | - -每个 `ui[]` 项还可以声明 **`surfaces`**:这个槽位只在哪些页面上出现。 - -| `surfaces` | 含义 | -| --- | --- | -| 不写这个键 | 空集 = **所有页面都显示**(早期清单的默认行为,不会因为升级而消失) | -| `home` / `detail` / `playback` / `other` | 只在对应页面显示,可多选 | -| `fullscreen` | **只在该页进入全屏播放时显示** | -| 宿主不认识的值(含拼错) | 永不显示——不报错,也不拒绝整份清单 | - -**`fullscreen` 是排他的。** 进入全屏后,当前生效范围集合里只有 `fullscreen`,不再包含 -`playback` 或 `detail`。所以: - -- 只写了 `["detail","playback"]` 的插件(例如豆瓣短评),进全屏后**自动隐藏**、退出全屏自动回来; -- 想在"内嵌小窗 + 全屏"两种形态都显示,写成 `["playback","fullscreen"]`; -- 只在全屏用,写 `["fullscreen"]`; -- 不写 `surfaces` 的老清单在任何形态都显示(向后兼容约定不变)。 - -这条规则只对**手机端**成立:电视上播放页本身就是全屏常态,没有"内嵌 ⇄ 全屏"这个切换, -`fullscreen` 在电视端不会出现。需要感知全屏进出的无界面逻辑,请订阅 `playback.fullscreen` 事件。 - -**电视端的 `detail` 与 `playback` 在同一页上等价。** 电视端没有独立的详情页:详情内容就在播放页里, -宿主按类型把该页判为 `playback`(判据是 `instanceof PlaybackActivity`,类名只用来区分 `Detail` 与 `Home`), -同时把这一页也算作 `detail`。电视端另有一个站点内容列表页(`VodActivity`),它既不是详情页也不是播放页, -被判为 `other`,没有插件入口行。 -因此 `surfaces:["detail"]` 与 `surfaces:["playback"]` 在电视端落在同一页上,写哪一个都能显示; -手机端两者才是两个不同的页面(`TmdbDetailActivity` 的详情页与播放页),不能互相替代。 -需要"只在详情页出现"的槽位请两个都写:`["detail","playback"]`——它在手机端覆盖详情与播放两页, -在电视端覆盖那一页,语义一致。 - -至多 8 个元素、单元素至多 16 字符;比对前统一转小写,写 `"Detail"` 与写 `"detail"` 等价。 - -```json -"ui": [ - { - "slot": "panel", - "label": "豆瓣", - "action": "douban.comments", - "event": "ui.contentShown", - "surfaces": ["detail", "playback"] - } -] -``` - -**`panel` 的完整链路**:用户点 chip → 宿主调用插件的 `action` → **脚本返回的字符串被宿主当作 HTML** → 宿主用侧滑 WebView 面板渲染它。标题栏与关闭按钮由宿主提供,脚本不需要也不应该自己画;电视端方向键可以直接滚动面板内容。由此有四条约束: - -- `action` 处理器必须**同步返回字符串**。返回 `undefined` / `null` / 非字符串时面板是空的,且不会报错。 -- 返回的 HTML 必须自包含:不保证能加载外部资源,样式请内联在 ` + + + - - + +

- -
-
+ +
- -
-
- -
阅读
- - - + +
+
+
▶️
+
📋
+
🎨
+
+
+
+ + + -
- - -
- - - 设置自动阅读 ▷ - - -
- - -
-
- -
-
-
上一章
-
1/1
-
下一章
-
-
- 背景 -
- 更多 › -
-
- 翻页 -
-
无动画
-
平移
-
覆盖
-
仿真
-
上下
-
-
-
-
▶ 自动阅读
-
🔊 朗读
-
⚙ 更多设置
-
-
- - -
-
✕
-
更多设置
-
- 亮度 - - 100% -
-
- 字号 - - 18 - -
-
- 段距 - - 8 - -
-
- 行距 - - 1.9 - -
-
- 字距 - - 0.0 - -
-
- 字重 -
-
细
-
常规
-
微粗
-
中粗
-
-
-
- 字体 - - -
-
- - -
-
✕
-
语音朗读
-
点击播放开始朗读
-
-
系统
-
微软
-
百度
-
在线
-
-
- 音色 - -
-
- - - -
-
- 语速 - - 1.0x -
-
- 音调 - - 1.0 -
-
- 定时 -
-
关
-
10分
-
20分
-
30分
-
60分
-
-
-
- 跟随 -
开
-
- -
-
停止朗读
-
-
- - -
-
✕
-
自动阅读
-
- 模式 -
-
上下滚屏
-
左右翻页
-
-
-
- 速度 - - 5 -
-
-
退出自动阅读
-
-
- - -
- -
- -
0/0
- -
-
- - - - -
-
- - -
- -
-
浏览设置
-
翻页方向
-
-
← 左右 →
-
↑ 上下 ↓
-
-
分界图高度
-
-
全屏
-
半屏
-
-
- - -
-
-
-
目录
-
书签
-
详情
-
-
-
-
- - - -
-
-
-
-
暂无书签
长按正文段落可添加书签
-
-
-
-
- -
-
-
-
-
-
简介
-
-
-
-
-
正在切换章节...
+ + diff --git a/app/src/main/assets/rules/hls_rules.json b/app/src/main/assets/rules/hls_rules.json index 4fe7882d4b..89dfab758d 100644 --- a/app/src/main/assets/rules/hls_rules.json +++ b/app/src/main/assets/rules/hls_rules.json @@ -2,5 +2,39 @@ "schemaVersion": 2, "packageId": "webhtv-builtin-hls", "version": 1, - "rules": [] + "rules": [ + { + "id": "builtin.hls.baofeng.preroll", + "name": "暴风片头广告(实验)", + "version": 1, + "enabledByDefault": false, + "playlistHostRegex": ["(?:^|\\.)s5\\.bfzycdn|bfzy"], + "minDuration": 2.99, + "maxDuration": 3.01, + "requireDiscontinuity": true, + "minimumSignals": 2 + }, + { + "id": "builtin.hls.liangzi.preroll", + "name": "量子片头广告(实验)", + "version": 1, + "enabledByDefault": false, + "playlistHostRegex": ["vip\\.lz|hd\\.lz|v\\.cdnlz|cdnlz"], + "minDuration": 6.42, + "maxDuration": 6.45, + "requireDiscontinuity": true, + "minimumSignals": 2 + }, + { + "id": "builtin.hls.feifan.preroll", + "name": "非凡片头广告(实验)", + "version": 1, + "enabledByDefault": false, + "playlistHostRegex": ["vip\\.ffzy|hd\\.ffzy|super\\.ffzy"], + "minDuration": 6.65, + "maxDuration": 6.68, + "requireDiscontinuity": true, + "minimumSignals": 2 + } + ] } diff --git a/app/src/main/assets/subtitle_sources/builtin/assrt.py b/app/src/main/assets/subtitle_sources/builtin/assrt.py deleted file mode 100644 index 9c99efddb9..0000000000 --- a/app/src/main/assets/subtitle_sources/builtin/assrt.py +++ /dev/null @@ -1,203 +0,0 @@ -import json -import os -import re -import tempfile -import zipfile -from urllib.parse import urlencode - -import requests - - -API_BASE = "https://api.assrt.net/v1" -REFERER = "https://assrt.net/" -USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" -FORMATS = ("ass", "ssa", "vtt", "srt") - - -def _text(value): - return "" if value is None else str(value).strip() - - -def _first(item, *keys): - if not isinstance(item, dict): - return "" - for key in keys: - value = _text(item.get(key)) - if value: - return value - return "" - - -def _number(value, fallback=0): - try: - return int(value) - except (TypeError, ValueError): - return fallback - - -def _format(subtype, name): - value = (_text(subtype) + " " + _text(name)).lower() - for fmt in FORMATS[:-1]: - if "." + fmt in value or (" " + fmt) in value: - return fmt - return "srt" - - -def _season_episode(value): - text = _text(value) - season = -1 - episode = -1 - match = re.search(r"(?i)\bS(\d{1,2})(?:\s*[-.]?\s*E(\d{1,3}))?\b", text) - if match: - season = _number(match.group(1), -1) - if match.group(2): - episode = _number(match.group(2), -1) - if episode < 0: - match = re.search(r"(?i)\bEP?(?:ISODE)?[ ._-]?(\d{1,3})\b", text) - if match: - episode = _number(match.group(1), -1) - return season, episode - - -def _year(value): - match = re.search(r"\b(19\d{2}|20\d{2})\b", _text(value)) - return _number(match.group(1), 0) if match else 0 - - -def _request(url, **kwargs): - headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Connection": "close"} - headers.update(kwargs.pop("headers", {}) or {}) - return requests.get(url, headers=headers, timeout=15, **kwargs) - - -def _download(url, source_key, candidate_id, filename): - root = os.path.dirname(os.path.abspath(__file__)) - safe_id = re.sub(r"[^A-Za-z0-9_.-]", "_", _text(candidate_id))[:80] or "subtitle" - suffix = os.path.splitext(_text(filename))[1].lower() - if suffix not in (".ass", ".ssa", ".srt", ".vtt", ".zip"): - suffix = ".srt" - target = os.path.join(root, "%s_%s%s" % (source_key, safe_id, suffix)) - temporary = target + ".tmp" - response = _request(url, stream=True) - response.raise_for_status() - try: - with open(temporary, "wb") as output: - for chunk in response.iter_content(16384): - if chunk: - output.write(chunk) - os.replace(temporary, target) - finally: - response.close() - if os.path.exists(temporary): - os.remove(temporary) - return target - - -def _extract_archive(path, source_key, candidate_id): - if not zipfile.is_zipfile(path): - return path - root = os.path.join(os.path.dirname(path), "%s_%s" % (source_key, re.sub(r"[^A-Za-z0-9_.-]", "_", _text(candidate_id))[:80])) - os.makedirs(root, exist_ok=True) - total = 0 - chosen = [] - with zipfile.ZipFile(path) as archive: - for info in archive.infolist(): - name = os.path.normpath(info.filename) - if name in (".", "") or name.startswith("../") or name.startswith("/") or ":" in name.split(os.sep)[0]: - raise ValueError("unsafe subtitle archive path") - if info.is_dir(): - continue - if info.file_size > 32 * 1024 * 1024: - raise ValueError("subtitle archive entry too large") - total += info.file_size - if total > 64 * 1024 * 1024: - raise ValueError("subtitle archive too large") - destination = os.path.join(root, name) - if not os.path.realpath(destination).startswith(os.path.realpath(root) + os.sep): - raise ValueError("unsafe subtitle archive path") - os.makedirs(os.path.dirname(destination), exist_ok=True) - with archive.open(info) as source, open(destination, "wb") as output: - output.write(source.read()) - if destination.lower().endswith(tuple("." + fmt for fmt in FORMATS)): - chosen.append(destination) - if not chosen: - return path - chosen.sort(key=lambda value: (0 if value.lower().endswith(".ass") else 1, value.lower())) - return chosen[0] - - -class Spider: - def __init__(self): - self.config = {} - - def init(self, config=None): - self.config = config if isinstance(config, dict) else {} - return {"code": 0, "data": {}} - - def search(self, request): - query = (request or {}).get("query") or {} - text = _text(query.get("text")) - token = _text(self.config.get("token")) - if not text or not token: - return {"code": 0, "data": {"items": []}} - params = {"token": token, "q": text, "is_file": 1, "cnt": 15} - response = _request(API_BASE + "/sub/search?" + urlencode(params)) - if not response.ok: - return {"code": 0, "data": {"items": []}} - root = response.json() - if _number(root.get("status"), 1) != 0: - return {"code": 0, "data": {"items": []}} - subs = ((root.get("sub") or {}).get("subs") or []) - items = [] - for item in subs: - if not isinstance(item, dict): - continue - candidate_id = _first(item, "id", "fileid") - if not candidate_id: - continue - name = _first(item, "name", "sub_name", "m_version", "m_title") - video_name = _first(item, "videoname", "m_videoname", "video_chinese_name", "m_video_chinese_name") - language = _first(item.get("lang") if isinstance(item.get("lang"), dict) else {}, "desc") or _first(item, "m_lang") - fmt = _format(_first(item, "subtype", "m_subtype"), name) - season, episode = _season_episode(name + " " + video_name) - items.append({ - "id": candidate_id, - "name": name, - "language": language, - "format": fmt, - "releaseInfo": video_name, - "score": 0, - "year": _year(video_name), - "season": season, - "episode": episode, - "matchType": "METADATA_FUZZY", - "requiresResolve": True, - "payload": {"id": candidate_id, "format": fmt}, - }) - return {"code": 0, "data": {"items": items}} - - def resolve(self, request): - candidate = (request or {}).get("candidate") or {} - payload = candidate.get("payload") or {} - candidate_id = _text(payload.get("id")) or _text(candidate.get("id")) - token = _text(self.config.get("token")) - if not candidate_id or not token: - return {"code": 1, "message": "candidate_not_found", "data": {}} - response = _request(API_BASE + "/sub/detail?" + urlencode({"token": token, "id": candidate_id})) - if not response.ok: - return {"code": 1, "message": "detail_http_failed", "data": {}} - root = response.json() - if _number(root.get("status"), 1) != 0: - return {"code": 1, "message": "detail_api_failed", "data": {}} - subs = ((root.get("sub") or {}).get("subs") or []) - first = next((item for item in subs if isinstance(item, dict)), None) - url = _first(first or {}, "url") - if not url: - return {"code": 1, "message": "download_url_missing", "data": {}} - filename = _first(first or {}, "filename") - path = _download(url, "assrt", candidate_id, filename or (candidate.get("name") or "subtitle.srt")) - path = _extract_archive(path, "assrt", candidate_id) - return {"code": 0, "data": {"url": path, "fileName": candidate.get("name", ""), "language": candidate.get("language", ""), "format": candidate.get("format", "srt")}} - - def destroy(self): - self.config = {} diff --git a/app/src/main/assets/subtitle_sources/builtin/manifest.json b/app/src/main/assets/subtitle_sources/builtin/manifest.json deleted file mode 100644 index 6821be4b05..0000000000 --- a/app/src/main/assets/subtitle_sources/builtin/manifest.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "protocol": "webhtv-subtitle", - "version": 1, - "name": "WebHTV built-in Python subtitle sources", - "subtitles": [ - { - "key": "assrt", - "name": "ASSRT", - "type": 3, - "api": "py", - "ext": "assets://subtitle_sources/builtin/assrt.py", - "enabled": 1, - "priority": 30, - "protocolVersion": 1, - "params": {"token": "${ASSRT_TOKEN}"}, - "languages": ["zh", "en"], - "formats": ["srt", "ass", "ssa", "vtt"] - }, - { - "key": "xunlei", - "name": "Xunlei Subtitle", - "type": 3, - "api": "py", - "ext": "assets://subtitle_sources/builtin/xunlei.py", - "enabled": 1, - "priority": 20, - "protocolVersion": 1, - "languages": ["zh", "en"], - "formats": ["srt", "ass", "ssa", "vtt"] - }, - { - "key": "shooter", - "name": "Shooter", - "type": 3, - "api": "py", - "ext": "assets://subtitle_sources/builtin/shooter.py", - "enabled": 1, - "priority": 10, - "queryIndependent": true, - "protocolVersion": 1, - "languages": ["zh", "en"], - "formats": ["srt", "ass", "ssa", "vtt"] - } - ] -} diff --git a/app/src/main/assets/subtitle_sources/builtin/shooter.py b/app/src/main/assets/subtitle_sources/builtin/shooter.py deleted file mode 100644 index f8694cd80a..0000000000 --- a/app/src/main/assets/subtitle_sources/builtin/shooter.py +++ /dev/null @@ -1,129 +0,0 @@ -import hashlib -import os -import re - -import requests - - -API = "https://www.shooter.cn/api/subapi.php" -USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" - - -def _text(value): - return "" if value is None else str(value).strip() - - -def _number(value, fallback=0): - try: - return int(value) - except (TypeError, ValueError): - return fallback - - -def _language(value): - return "eng" if _text(value).lower().startswith(("en", "eng")) else "zh" - - -def _format(ext, url): - value = (_text(ext) or os.path.splitext(_text(url))[1]).lower().replace(".", "") - return value if value in ("ass", "ssa", "srt", "vtt") else "srt" - - -def _hash(path): - if not path or not os.path.isfile(path): - return "" - size = os.path.getsize(path) - if size < 12 * 1024: - return "" - offsets = (4 * 1024, size // 3 * 2, size // 3, size - 8 * 1024) - result = [] - with open(path, "rb") as source: - for offset in offsets: - source.seek(offset) - result.append(hashlib.md5(source.read(4 * 1024)).hexdigest()) - return ";".join(result) - - -def _download(url, candidate_id, fmt): - safe = re.sub(r"[^A-Za-z0-9_.-]", "_", _text(candidate_id))[:80] or "subtitle" - target = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shooter_" + safe + "." + (fmt or "srt")) - temporary = target + ".tmp" - response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=15, stream=True) - response.raise_for_status() - try: - with open(temporary, "wb") as output: - for chunk in response.iter_content(16384): - if chunk: - output.write(chunk) - os.replace(temporary, target) - finally: - response.close() - if os.path.exists(temporary): - os.remove(temporary) - return target - - -class Spider: - def __init__(self): - self.config = {} - - def init(self, config=None): - self.config = config if isinstance(config, dict) else {} - return {"code": 0, "data": {}} - - def search(self, request): - request = request or {} - query = request.get("query") or {} - context = request.get("context") or {} - media_path = _text(context.get("mediaPath")) - file_hash = _hash(media_path) - if not file_hash: - return {"code": 0, "data": {"items": []}} - language = _language(query.get("language") or context.get("preferredLanguage")) - body = {"filehash": file_hash, "pathinfo": media_path, "format": "json", "lang": "eng" if language == "eng" else "chn"} - response = requests.post(API, data=body, headers={"User-Agent": USER_AGENT}, timeout=15) - if not response.ok: - return {"code": 0, "data": {"items": []}} - try: - root = response.json() - except ValueError: - return {"code": 0, "data": {"items": []}} - file_name = os.path.basename(media_path) - items = [] - for group in root if isinstance(root, list) else []: - for item in (group.get("Files") or []) if isinstance(group, dict) else []: - if not isinstance(item, dict): - continue - url = _text(item.get("Link")) - if not url: - continue - fmt = _format(item.get("Ext"), url) - name = (file_name or _text(context.get("canonicalTitle")) or "subtitle") + " | 射手 | " + fmt - items.append({ - "id": url, - "name": name, - "language": language, - "format": fmt, - "releaseInfo": "hash", - "score": 120, - "year": _number(query.get("year"), _number(context.get("year"))), - "season": _number(query.get("season"), _number(context.get("season"), -1)), - "episode": _number(query.get("episode"), _number(context.get("episode"), -1)), - "matchType": "METADATA_STRICT", - "requiresResolve": True, - "payload": {"url": url, "format": fmt}, - }) - return {"code": 0, "data": {"items": items}} - - def resolve(self, request): - candidate = (request or {}).get("candidate") or {} - payload = candidate.get("payload") or {} - url = _text(payload.get("url")) - fmt = _text(payload.get("format")) or _text(candidate.get("format")) or "srt" - if not url: - return {"code": 1, "message": "download_url_missing", "data": {}} - path = _download(url, _text(candidate.get("id")) or url, fmt) - return {"code": 0, "data": {"url": path, "fileName": candidate.get("name", ""), "language": candidate.get("language", ""), "format": fmt}} - - def destroy(self): - self.config = {} diff --git a/app/src/main/assets/subtitle_sources/builtin/xunlei.py b/app/src/main/assets/subtitle_sources/builtin/xunlei.py deleted file mode 100644 index 48654eb917..0000000000 --- a/app/src/main/assets/subtitle_sources/builtin/xunlei.py +++ /dev/null @@ -1,169 +0,0 @@ -import hashlib -import json -import os -import re -from urllib.parse import quote - -import requests - - -API = "https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name=" -REFERER = "https://sl-m-ssl.xunlei.com/" -USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" - - -def _text(value): - return "" if value is None else str(value).strip() - - -def _first(item, *keys): - for key in keys: - value = _text((item or {}).get(key)) - if value: - return value - return "" - - -def _number(value, fallback=0): - try: - return int(value) - except (TypeError, ValueError): - return fallback - - -def _long(value, fallback=0): - try: - return int(value) - except (TypeError, ValueError): - return fallback - - -def _format(ext, name, url): - value = _text(ext) or os.path.splitext(_text(name) or _text(url))[1] - value = value.lower().replace(".", "") - return value if value in ("ass", "ssa", "srt", "vtt") else "srt" - - -def _language(value): - value = _text(value) - lower = value.lower() - if not value or lower == "default" or value == "默认": - return "zh" - if any(token in lower for token in ("中文", "中字", "简", "繁", "chs", "cht", "zh", "english", "eng", "en", "日", "ja", "jp", "韩", "韓", "ko", "kr")): - return value - return "zh" - - -def _release(extra, duration): - result = _text(extra) - if duration > 0: - result = (result + " " if result else "") + str(duration // 60000) + "m" - return result - - -def _content_id(path): - if not path or not os.path.isfile(path): - return "" - size = os.path.getsize(path) - with open(path, "rb") as source: - if size < 0xF000: - data = source.read() - else: - data = source.read(0x5000) - source.seek(size // 3) - data += source.read(0x5000) - source.seek(max(0, size - 0x5000)) - data += source.read(0x5000) - return hashlib.sha1(data).hexdigest().upper() - - -def _download(url, candidate_id, filename, fmt): - safe = re.sub(r"[^A-Za-z0-9_.-]", "_", _text(candidate_id))[:80] or "subtitle" - suffix = os.path.splitext(_text(filename))[1].lower() or "." + (fmt or "srt") - if suffix not in (".ass", ".ssa", ".srt", ".vtt"): - suffix = "." + (fmt or "srt") - target = os.path.join(os.path.dirname(os.path.abspath(__file__)), "xunlei_" + safe + suffix) - temporary = target + ".tmp" - response = requests.get(url, headers={"User-Agent": USER_AGENT, "Referer": REFERER, "Connection": "close"}, timeout=15, stream=True) - response.raise_for_status() - try: - with open(temporary, "wb") as output: - for chunk in response.iter_content(16384): - if chunk: - output.write(chunk) - os.replace(temporary, target) - finally: - response.close() - if os.path.exists(temporary): - os.remove(temporary) - return target - - -class Spider: - def __init__(self): - self.config = {} - - def init(self, config=None): - self.config = config if isinstance(config, dict) else {} - return {"code": 0, "data": {}} - - def search(self, request): - request = request or {} - query = request.get("query") or {} - context = request.get("context") or {} - text = _text(query.get("text")) - if not text: - return {"code": 0, "data": {"items": []}} - content_id = _content_id(_text(context.get("mediaPath"))) - response = requests.get(API + quote(text), headers={"User-Agent": USER_AGENT, "Referer": REFERER, "Connection": "close"}, timeout=15) - if not response.ok: - return {"code": 0, "data": {"items": []}} - root = response.json() - if _number(root.get("code"), 1) != 0 or (_text(root.get("result")) and _text(root.get("result")).lower() != "ok"): - return {"code": 0, "data": {"items": []}} - items = [] - for item in root.get("data") or []: - if not isinstance(item, dict): - continue - url = _text(item.get("url")) - candidate_id = _first(item, "cid", "gcid") or url - if not url or not candidate_id: - continue - name = _text(item.get("name")) or candidate_id - fmt = _format(item.get("ext"), name, url) - language_values = item.get("languages") if isinstance(item.get("languages"), list) else [] - language = _language(next((_text(value) for value in language_values if _text(value)), "")) - extra = _text(item.get("extra_name")) - duration = _long(item.get("duration")) - score = max(_number(item.get("score")), _number(item.get("fingerprintf_score"))) - strict = bool(content_id and content_id.lower() == _text(item.get("cid")).lower()) - if strict: - score += 120 - items.append({ - "id": candidate_id, - "name": name, - "language": language, - "format": fmt, - "releaseInfo": _release(extra, duration), - "score": score, - "year": 0, - "season": -1, - "episode": -1, - "matchType": "METADATA_STRICT" if strict else "METADATA_FUZZY", - "requiresResolve": True, - "payload": {"url": url, "name": name, "format": fmt, "duration": duration, "gcid": _text(item.get("gcid")), "cid": _text(item.get("cid"))}, - }) - return {"code": 0, "data": {"items": items}} - - def resolve(self, request): - candidate = (request or {}).get("candidate") or {} - payload = candidate.get("payload") or {} - url = _text(payload.get("url")) - if not url: - return {"code": 1, "message": "download_url_missing", "data": {}} - fmt = _text(payload.get("format")) or _text(candidate.get("format")) or "srt" - path = _download(url, _text(candidate.get("id")) or url, payload.get("name"), fmt) - return {"code": 0, "data": {"url": path, "fileName": candidate.get("name", ""), "language": candidate.get("language", ""), "format": fmt}} - - def destroy(self): - self.config = {} diff --git a/app/src/main/assets/webhome/eclipse-detail.html b/app/src/main/assets/webhome/eclipse-detail.html index 6b261ec0fe..5ee9e5bc03 100644 --- a/app/src/main/assets/webhome/eclipse-detail.html +++ b/app/src/main/assets/webhome/eclipse-detail.html @@ -5,254 +5,12 @@ Eclipse · 详情 - " - + "

AI诊断

" + + "

AI诊断

返回日志下载日志
" + "
" + escape(result) + "
"; return noCache(NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html), null); } - private boolean originAllowed(IHTTPSession session) { - java.util.Set hosts = new java.util.HashSet<>(); - for (String address : new String[]{Server.get().getAddress("/debug/logs"), Server.get().getAddress(false)}) { - try { hosts.add(java.net.URI.create(address).getRawAuthority().toLowerCase(java.util.Locale.ROOT)); } catch (RuntimeException ignored) {} - } - return DiagnosticAccess.sameOrigin(session.getHeaders().get("origin"), session.getHeaders().get("host"), hosts); - } - - private Response controlRequestError(IHTTPSession session) { - if (session.getMethod() != NanoHTTPD.Method.POST) return diagnosticMessage(Response.Status.METHOD_NOT_ALLOWED, "此操作需要 POST"); - if (!originAllowed(session)) return diagnosticMessage(Response.Status.FORBIDDEN, "请求来源不匹配,请从设备日志页面操作"); - if (!DiagnosticControls.ACCESS.allowAction()) return diagnosticMessage(Response.Status.TOO_MANY_REQUESTS, "操作过快,请稍后重试"); - return null; - } - - private Response diagnosticAction(IHTTPSession session, String url, Map files) { - if (url.equals("/debug/diag/status") && session.getMethod() == NanoHTTPD.Method.GET) - return noCache(NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", DiagnosticControls.status().toString()), null); - Response rejection = controlRequestError(session); - if (rejection != null) return rejection; - try { - if (Long.parseLong(session.getHeaders().getOrDefault("content-length", "0")) > 2048) return diagnosticMessage(Response.Status.BAD_REQUEST, "请求过长"); - String raw = files.getOrDefault("postData", "{}"); - if (raw.length() > 2048) return diagnosticMessage(Response.Status.BAD_REQUEST, "请求过长"); - JsonObject data = JsonParser.parseString(raw.isEmpty() ? "{}" : raw).getAsJsonObject(); - switch (url) { - case "/debug/diag/mark" -> DiagnosticControls.mark(field(data, "symptom")); - case "/debug/diag/deep" -> { - if (!data.has("consent") || !data.get("consent").getAsBoolean()) return diagnosticMessage(Response.Status.BAD_REQUEST, "需要确认限时统计"); - DiagnosticControls.startDepth(data.has("seconds") ? data.get("seconds").getAsInt() : 60); - } - case "/debug/diag/stop" -> DiagnosticCapture.stop("user-stopped"); - case "/debug/diag/fel-bind" -> { - if (!data.has("consent") || !data.get("consent").getAsBoolean()) return diagnosticMessage(Response.Status.BAD_REQUEST, "需要确认暂停播放进行对照"); - DiagnosticControls.startFelBindProbe(); - } - case "/debug/diag/fel-bind-stop" -> DiagnosticControls.stopFelBindProbe(); - case "/debug/diag/export" -> { return archive(); } - default -> { return diagnosticMessage(Response.Status.NOT_FOUND, "未找到操作"); } - } - return diagnosticMessage(Response.Status.OK, "已记录"); - } catch (RuntimeException error) { - return diagnosticMessage(Response.Status.BAD_REQUEST, error instanceof IllegalStateException || error instanceof IllegalArgumentException ? error.getMessage() : "无效请求"); - } - } - - private String field(JsonObject value, String key) { return value.has(key) && value.get(key).isJsonPrimitive() ? value.get(key).getAsString() : ""; } - - private Response diagnosticMessage(Response.Status status, String message) { - JsonObject value = new JsonObject(); value.addProperty("ok", status == Response.Status.OK); value.addProperty("message", message); - return noCache(NanoHTTPD.newFixedLengthResponse(status, "application/json; charset=utf-8", value.toString()), null); - } - - private Response archive() { - if (!DebugLogStore.isEnabled()) return diagnosticMessage(Response.Status.BAD_REQUEST, "请先开启调试日志"); - if (!archiveBusy.compareAndSet(false, true)) return diagnosticMessage(Response.Status.SERVICE_UNAVAILABLE, "已有诊断包正在导出,请稍后重试"); - DiagnosticLogBuffer.Export source = DebugLogStore.export(); - try { - try (java.io.InputStream ignored = source.openAgain()) { /* Reject a non-repeatable snapshot before HTTP 200. */ } - java.io.PipedInputStream input = new java.io.PipedInputStream(64 << 10); - java.io.PipedOutputStream output = new java.io.PipedOutputStream(input); - java.util.concurrent.ScheduledFuture expiry = exportTimer.schedule(() -> { - try { input.close(); output.close(); } catch (java.io.IOException ignored) {} - }, 60, java.util.concurrent.TimeUnit.SECONDS); - Thread worker = new Thread(() -> { - try (source; output) { DiagnosticReport.archive(source, output); } - catch (java.io.IOException | RuntimeException ignored) { DebugLogStore.collectorFailure(); } - finally { expiry.cancel(false); archiveBusy.set(false); } - }, "diagnostic-report-export"); - worker.setDaemon(true); worker.start(); - Response response = NanoHTTPD.newChunkedResponse(Response.Status.OK, "application/zip", input); - response.addHeader("Content-Disposition", "attachment; filename=webhtv-av-report.zip"); - response.addHeader("X-Content-Type-Options", "nosniff"); - return noCache(response, null); - } catch (java.io.IOException | RuntimeException error) { - try { source.close(); } catch (java.io.IOException ignored) {} - archiveBusy.set(false); - return diagnosticMessage(Response.Status.INTERNAL_ERROR, "诊断包生成失败,请重试 TXT 下载"); - } - } - private Response stream(IHTTPSession session) { - if (session.getParms().containsKey("afterSeq")) return incrementalStream(session); long version = DebugLogStore.version(); boolean unchanged = version == paramLong(session, "v", -1); String text = unchanged ? null : DebugLogStore.text(); @@ -184,26 +87,6 @@ public class DebugLogs implements Process { return noCache(response, null); } - private Response incrementalStream(IHTTPSession session) { - DiagnosticLogBuffer.Snapshot snapshot = DebugLogStore.incremental(paramLong(session, "afterSeq", -1), - session.getParms().get("run"), paramLong(session, "generation", -1)); - JsonObject result = new JsonObject(); - result.addProperty("enabled", DebugLogStore.isEnabled()); - if (snapshot == null) { - result.addProperty("reset", true); result.addProperty("text", "调试日志未开启\n"); - result.addProperty("runId", ""); result.addProperty("generation", -1); - result.addProperty("newestSeq", -1); result.addProperty("size", 0); result.addProperty("bytes", 0); - } else { - result.addProperty("runId", snapshot.runId()); result.addProperty("generation", snapshot.generation()); - result.addProperty("version", snapshot.version()); result.addProperty("oldestSeq", snapshot.oldestSeq()); - result.addProperty("newestSeq", snapshot.newestSeq()); result.addProperty("reset", snapshot.reset()); - result.addProperty("gap", snapshot.gap()); result.addProperty("text", snapshot.text()); - result.addProperty("size", snapshot.lines().size()); result.addProperty("bytes", snapshot.health().get("diskBytes").getAsLong()); - result.add("health", snapshot.health()); - } - return noCache(NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", result.toString()), null); - } - private long paramLong(IHTTPSession session, String key, long fallback) { try { return Long.parseLong(session.getParms().get(key)); @@ -224,89 +107,48 @@ public class DebugLogs implements Process { String localUrl = Server.get().getAddress("/debug/logs"); String lanUrl = Server.get().getAddress(false) + "/debug/logs"; boolean enabled = DebugLogStore.isEnabled(); - boolean temporary = DebugLogStore.isCrashSessionTemporary(); - String status = temporary ? "崩溃会话 · 自动开启" : enabled ? "记录中" : "已关闭"; - String clear = temporary ? "" : ""; - String banner = temporary ? "

崩溃会话日志:崩溃界面自动开启,重启后恢复原设置;仅在本界面存活期间可下载。

" : ""; - return "" + return "" + + "" + "" - + "调试日志" - + "

调试日志

" + status + " · " + DebugLogStore.bytes() / 1024 + " KB
" - + "
AI诊断下载" + clear + "
" - + "" - + "
" - + banner + + "调试日志" + + "" + + "

调试日志

刷新下载AI诊断清空" + (enabled ? "关闭" : "开启") + "" + (enabled ? "开启" : "关闭") + " · " + DebugLogStore.size() + " 行 · " + DebugLogStore.bytes() / 1024 + " KB
" + + "
地址和说明

本页显示 App 当前进程内调试日志。开启后记录安卓系统版本、设备型号、WebView 版本、WebHome、SDK、HTTP 服务、爬虫请求和播放链路;关闭会自动清空。

" + + "
" + + "
" + + "
" + "
" + logs + "
" - + diagnosticHtml(localUrl, lanUrl, enabled, temporary) + "" - + ""; + + "" + + ""; } private String css() { - return """ - html{box-sizing:border-box;overflow-x:hidden;background:#f4f6f8;color:#1f2328;font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif} - *,*:before,*:after{box-sizing:inherit;min-width:0}[hidden]{display:none!important} - body{margin:0}body.drawer-open{overflow:hidden}main{width:100%;padding:0 8px 8px} - button,a,input,select{font:inherit}button,a{touch-action:manipulation} - button,a.action{display:inline-flex;align-items:center;justify-content:center;min-height:40px;appearance:none;border:1px solid #d0d7de;border-radius:7px;background:#fff;color:#24292f;padding:6px 12px;cursor:pointer;white-space:nowrap;text-decoration:none} - button:hover,a.action:hover{background:#f1f5f9}button.on,button.filtered{background:#ddf4ff;border-color:#80ccff;color:#0969da} - a:focus-visible,button:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid #0969da;outline-offset:2px} - .console-head{position:sticky;top:0;z-index:20;display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"brand actions" "tabs tabs" "search search";align-items:center;column-gap:8px;padding:4px 8px 0;margin-bottom:8px;background:#fff;border-bottom:1px solid #d8dee4;box-shadow:0 2px 8px #1f232808} - .brand{grid-area:brand;overflow:hidden}h1{margin:0;font-size:16px;line-height:21px;font-weight:650}.meta{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:11px;line-height:16px;color:#656d76}.meta.partial{color:#9a6700} - .primary-actions{grid-area:actions;display:flex;gap:4px}.primary-actions button,.primary-actions a.action,#search-toggle{min-width:44px;min-height:44px;padding:6px 9px;border-color:transparent;font-size:13px}.primary-actions .danger{color:#cf222e} - .count{margin-left:4px;min-width:16px;padding:0 4px;border-radius:8px;background:#0969da;color:#fff;font-size:11px;line-height:16px} - .crash-banner{margin:0 8px 8px;padding:8px 10px;border:1px solid #d0d7de;border-left:4px solid #bf8700;border-radius:7px;background:#fff8c5;color:#7d4e00;font-size:13px} - .category-row{grid-area:tabs;display:flex;align-items:center}.category-row>button{flex:0 0 auto}.tabs{flex:1;display:flex;gap:0;overflow-x:auto;overflow-y:hidden;flex-wrap:nowrap;white-space:nowrap;scrollbar-width:none;-webkit-overflow-scrolling:touch}.tabs::-webkit-scrollbar{display:none} - .tabs .chip{flex:0 0 auto;min-height:44px;padding:7px 12px;border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;color:#57606a;font-size:13px} - .tabs .chip.on{color:#0969da;border-bottom-color:#0969da;font-weight:650}.tabs .chip:focus-visible{outline-offset:-3px} - .search-panel{grid-area:search;display:flex;align-items:center;gap:6px;padding:4px 0 8px} - input:not([type=checkbox]),select{min-width:0;width:100%;max-width:100%;min-height:40px;border:1px solid #d0d7de;border-radius:7px;padding:8px 10px;background:#fff;color:inherit} - #filter{flex:1;width:100%;font-size:16px}.search-panel button{min-height:40px;font-size:13px} - .drawer-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:100;background:#1f232866} - .log-drawer{position:fixed;top:0;right:0;bottom:0;left:auto;z-index:101;display:none;flex-direction:column;width:460px;max-width:100%;height:100%;max-height:100%;margin:0;padding:0;border:0;background:#fff;color:inherit;box-shadow:-8px 0 32px #1f232826} - .log-drawer[open]{display:flex}.log-drawer::backdrop{background:#1f232866} - .drawer-head{display:flex;flex:0 0 auto;align-items:center;justify-content:space-between;gap:8px;padding:10px 16px 4px}.drawer-head h2{margin:0;font-size:18px}.drawer-head button{width:44px;height:44px;padding:0;border:0;font-size:24px} - .tool-tabs{display:flex;flex:0 0 auto;padding:0 16px;border-bottom:1px solid #d8dee4}.tool-tabs button{flex:1;min-height:44px;border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;color:#656d76}.tool-tabs button[aria-selected=true]{border-bottom-color:#0969da;color:#0969da;font-weight:650} - .drawer-body{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:16px}.drawer-body h3{margin:0 0 10px;font-size:14px} - .field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.field{display:flex;flex-direction:column;gap:5px;color:#57606a;font-size:13px} - .field select,.field input{color:#24292f}.form-section+.form-section{margin-top:20px;padding-top:16px;border-top:1px solid #eaeef2} - .action-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:10px}.action-row>input{flex:1;min-width:120px}.action-row>select{flex:1;min-width:100px}.action-row>.primary{background:#0969da;border-color:#0969da;color:white} - .simple{display:flex;align-items:center;gap:9px;min-height:44px;cursor:pointer}.simple input{flex:0 0 18px;width:18px;height:18px;margin:0;padding:0} - .hint{margin:8px 0;color:#656d76;font-size:12px;line-height:1.6}.hint:empty{display:none}.summary{white-space:normal;overflow-wrap:anywhere} - .drawer-feedback{flex:0 0 auto;max-height:100px;overflow:auto;margin:0;padding:10px 16px;color:#0969da;background:#f0f7ff;font-size:13px;line-height:1.5}.drawer-feedback:empty{display:none} - .addr{display:grid;gap:8px;font-size:12px}.addr a{display:block;padding:10px;border-radius:7px;background:#f6f8fa;color:#0969da;overflow-wrap:anywhere;word-break:break-all;text-decoration:none} - .logs{display:grid;gap:6px;width:100%;overflow:hidden}.fallback{margin:0} - .entry,.fallback{width:100%;overflow:hidden;background:#fff;border:1px solid #d8dee4;border-radius:7px;padding:8px 10px} - .entry.ok{border-left:4px solid #1a7f37}.entry.warn{border-left:4px solid #bf8700}.entry.err{border-left:4px solid #cf222e}.entry.raw{border-left:4px solid #8c959f} - .top{display:flex;gap:8px;align-items:center;overflow:hidden}.badge{flex:0 0 auto;border-radius:999px;padding:2px 7px;background:#eaeef2;color:#57606a;font-size:12px} - .entry.ok .badge{background:#dafbe1;color:#116329}.entry.warn .badge{background:#fff8c5;color:#7d4e00}.entry.err .badge{background:#ffebe9;color:#cf222e} - .title{font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.time{margin-left:auto;color:#8c959f;font-size:12px;white-space:nowrap} - .detail,.rawline,.fallback{overflow-wrap:anywhere;word-break:break-all;white-space:pre-wrap}.detail{margin-top:4px;color:#57606a} - .rawline{display:block;margin-top:6px;padding-top:6px;border-top:1px dashed #d8dee4;color:#6e7781} - .rawline,.fallback{font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}body.simple .rawline{display:none} - .backtop{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom));z-index:20;height:38px;min-width:72px;padding:0 13px;border-radius:999px;border-color:#d0d7de;background:rgba(255,255,255,.94);color:#0969da;font-size:14px;font-weight:650;line-height:1;box-shadow:0 6px 18px rgba(31,35,40,.16)}.backtop:active{background:#f6f8fa}.backtop:focus-visible{outline:3px solid rgba(9,105,218,.22);outline-offset:2px} - @media(max-width:680px){.backtop{right:12px;bottom:calc(12px + env(safe-area-inset-bottom));height:36px;min-width:64px;padding:0 11px;font-size:13px}} - @media(min-width:800px){.console-head{grid-template-columns:minmax(0,1fr) 260px auto;grid-template-areas:"brand search actions" "tabs tabs tabs"}.search-panel{padding:0}#search-hide{display:none}.tabs .chip{min-height:36px}.primary-actions button,.primary-actions a.action{min-height:40px}} - @media(max-width:600px){.log-drawer{top:auto;right:0;bottom:0;left:0;width:100%;height:auto;max-height:88vh;max-height:88dvh;border-radius:16px 16px 0 0;box-shadow:0 -8px 32px #1f232826}.drawer-body{padding-bottom:20px;padding-bottom:max(20px,env(safe-area-inset-bottom))}.drawer-body button,.drawer-body a.action,.drawer-body select,.drawer-body input:not([type=checkbox]){min-height:44px}} - @media(max-width:480px){main{padding:0 4px 4px}.console-head{padding:4px 6px 0}.title{white-space:normal}.time{display:none}.entry{padding:7px 8px}.detail{font-size:13px}} - """; + return "html,body{box-sizing:border-box;width:100%;max-width:100%;margin:0;overflow-x:hidden;background:#f4f6f8;color:#1f2328;font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;}*,*:before,*:after{box-sizing:inherit;min-width:0}body{position:relative}" + + ".topbar{box-sizing:border-box;display:flex;flex-wrap:wrap;gap:8px;align-items:center;width:100%;max-width:100%;overflow:hidden;margin:0 0 8px;padding:8px;background:#fff;border:1px solid #d8dee4;border-radius:8px;box-shadow:0 2px 10px rgba(31,35,40,.04)}" + + "h1{margin:0 10px 0 0;font-size:17px;font-weight:650;white-space:nowrap}.meta{margin-left:auto;color:#656d76;font-size:12px;white-space:nowrap}" + + "a,button{appearance:none;border:1px solid #d0d7de;border-radius:7px;background:#fff;color:#24292f;padding:6px 9px;text-decoration:none;font:inherit;cursor:pointer;white-space:nowrap}button.on,.chip.on{background:#0969da;border-color:#0969da;color:#fff}a:active,button:active{background:#eaeef2}main{box-sizing:border-box;width:100%;max-width:1280px;margin:0 auto;padding:8px;overflow-x:hidden}.info{box-sizing:border-box;max-width:100%;overflow:hidden;margin:0 0 8px;padding:7px 9px;background:#fff;border:1px solid #d8dee4;border-radius:8px}.info summary{cursor:pointer;color:#57606a;font-size:12px}.hint{margin:8px 0;color:#656d76;font-size:12px}" + + ".addr{display:grid;grid-template-columns:minmax(0,1fr);gap:6px;margin:0;color:#57606a;font-size:12px}.addr a{display:block;min-width:0;max-width:100%;overflow:hidden;white-space:normal;overflow-wrap:anywhere;word-break:break-all;padding:7px 9px;background:#f6f8fa}" + + ".tools{box-sizing:border-box;width:100%;max-width:100%;overflow:hidden;margin:0 0 8px;padding:8px;background:#fff;border:1px solid #d8dee4;border-radius:8px;box-shadow:0 2px 10px rgba(31,35,40,.04)}.chips{display:flex;flex-wrap:wrap;gap:6px;overflow:hidden;padding-bottom:2px}.chip{flex:0 0 auto}.search{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px;align-items:center;margin-top:6px}input{box-sizing:border-box;width:100%;min-width:0;border:1px solid #d0d7de;border-radius:7px;padding:7px 9px;font:inherit}.simple{display:flex;align-items:center;gap:4px;color:#57606a;font-size:12px;white-space:nowrap}.summary{margin-top:6px;color:#57606a;font-size:12px;white-space:normal;overflow-wrap:anywhere;word-break:break-all}" + + ".logs{box-sizing:border-box;display:grid;gap:8px;width:100%;max-width:100%;min-width:0;overflow:hidden}.fallback{box-sizing:border-box;max-width:100%;overflow:hidden;margin:0;background:#fff;border:1px solid #d8dee4;border-radius:8px;padding:10px;color:#57606a;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-all}.entry{box-sizing:border-box;width:100%;max-width:100%;min-width:0;overflow:hidden;background:#fff;border:1px solid #d8dee4;border-radius:8px;padding:9px 10px}.entry.ok{border-left:4px solid #1a7f37}.entry.warn{border-left:4px solid #bf8700}.entry.err{border-left:4px solid #cf222e}.entry.raw{border-left:4px solid #8c959f}.top{display:flex;gap:8px;align-items:center;max-width:100%;min-width:0;overflow:hidden}.badge{flex:0 0 auto;border-radius:999px;padding:2px 7px;background:#eaeef2;color:#57606a;font-size:12px}.entry.ok .badge{background:#dafbe1;color:#116329}.entry.err .badge{background:#ffebe9;color:#cf222e}.title{min-width:0;font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.time{margin-left:auto;color:#8c959f;font-size:12px;white-space:nowrap}.detail{box-sizing:border-box;max-width:100%;min-width:0;overflow:hidden;margin-top:5px;color:#57606a;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-all}.rawline{display:block;box-sizing:border-box;width:100%;max-width:100%;min-width:0;overflow:hidden;margin-top:6px;padding-top:6px;border-top:1px dashed #d8dee4;color:#6e7781;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-all}body.simple .rawline{display:none}" + + ".backtop{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom));z-index:20;height:38px;min-width:72px;padding:0 13px;border-radius:999px;border-color:#d0d7de;background:rgba(255,255,255,.94);color:#0969da;font-size:14px;font-weight:650;line-height:1;box-shadow:0 6px 18px rgba(31,35,40,.16)}.backtop:active{background:#f6f8fa}.backtop:focus-visible{outline:3px solid rgba(9,105,218,.22);outline-offset:2px}" + + "@media(max-width:680px){.topbar{padding:7px 8px}h1{font-size:16px}.meta{flex-basis:100%;margin-left:0}.addr{grid-template-columns:1fr}.search{grid-template-columns:minmax(0,1fr) auto auto}.title{white-space:normal}.time{display:none}.entry{padding:8px}.detail{font-size:13px}.backtop{right:12px;bottom:calc(12px + env(safe-area-inset-bottom));height:36px;min-width:64px;padding:0 11px;font-size:13px}}"; } - private String scriptEnhanced(boolean temporary) { - // 崩溃会话的标题由页面固定:轮询只刷新字节数,不能把「崩溃会话」改回「记录中」。 - return "const rawEl=document.getElementById('raw'),logs=document.getElementById('logs'),meta=document.getElementById('meta'),summary=document.getElementById('summary'),filter=document.getElementById('filter'),simple=document.getElementById('simple'),pause=document.getElementById('pause'),download=document.getElementById('download'),crashSession=" + (temporary ? "true" : "false") + ";" - + "let raw=rawEl.textContent,mode='all',paused=false,stick=true,lastVersion=Number(meta.dataset.version||0),lastSeq=-1,lastRun='',lastGeneration=-1;simple.checked=false;document.body.classList.remove('simple');addEventListener('scroll',()=>{if(toolsOpen())return;resetX();stick=(innerHeight+scrollY)>=(document.body.scrollHeight-80)},{passive:true});setInterval(resetX,500);" - + "document.querySelectorAll('.chip').forEach(b=>{b.setAttribute('aria-pressed',b.classList.contains('on'));b.tabIndex=b.classList.contains('on')?0:-1;b.onclick=()=>selectCategory(b)});filter.oninput=()=>{render();updateFilterState()};simple.onchange=()=>{document.body.classList.toggle('simple',simple.checked);render();resetX()};pause.onclick=()=>{paused=!paused;pause.textContent=paused?'继续':'暂停';pause.classList.toggle('on',paused);pause.setAttribute('aria-pressed',paused)};" - + "download.onclick=()=>{paused=true;pause.textContent='继续';pause.classList.add('on');pause.setAttribute('aria-pressed','true')};" + private String scriptEnhanced() { + return "const rawEl=document.getElementById('raw'),logs=document.getElementById('logs'),meta=document.getElementById('meta'),summary=document.getElementById('summary'),filter=document.getElementById('filter'),simple=document.getElementById('simple'),pause=document.getElementById('pause'),download=document.getElementById('download'),topBtn=document.getElementById('topBtn');" + + "let raw=rawEl.textContent,mode='all',paused=false,stick=true,lastVersion=Number(meta.dataset.version||0);simple.checked=false;document.body.classList.remove('simple');addEventListener('scroll',()=>{resetX();stick=(innerHeight+scrollY)>=(document.body.scrollHeight-80)},{passive:true});setInterval(resetX,500);" + + "document.querySelectorAll('.chip').forEach(b=>b.onclick=()=>{document.querySelectorAll('.chip').forEach(x=>x.classList.remove('on'));b.classList.add('on');mode=b.dataset.mode;render()});filter.oninput=render;simple.onchange=()=>{document.body.classList.toggle('simple',simple.checked);render();resetX()};pause.onclick=()=>{paused=!paused;pause.textContent=paused?'继续':'暂停';pause.classList.toggle('on',paused)};" + + "download.onclick=()=>{paused=true;pause.textContent='继续';pause.classList.add('on')};topBtn.onclick=scrollToTop;" + "function esc(s){return String(s||'').replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]))}" + "function resetX(){try{const y=scrollY||pageYOffset||0;(document.scrollingElement||document.documentElement).scrollLeft=0;document.documentElement.scrollLeft=0;document.body.scrollLeft=0;if((document.scrollingElement||document.documentElement).scrollLeft||document.body.scrollLeft)scrollTo(0,y)}catch(e){}}" + + "function scrollToTop(){stick=false;try{scrollTo({top:0,behavior:'smooth'})}catch(e){scrollTo(0,0);document.documentElement.scrollTop=0;document.body.scrollTop=0}resetX()}" + "function part(s,k){const i=s.indexOf(k);if(i<0)return'';let v=s.slice(i+k.length),e=v.length;[' ',',',']'].forEach(c=>{const p=v.indexOf(c);if(p>=0&&p','')}" + "function parse(line){const a=line.indexOf(' ['),b=line.indexOf('] ',a+2),c=line.indexOf(': ',b+2);return{line,time:a>0?line.slice(0,a):'',thread:a>0&&b>0?line.slice(a+2,b):'',tag:b>0&&c>0?line.slice(b+2,c):'',msg:c>0?line.slice(c+2):line}}" - + "function base(r){const e={kind:'raw',state:'raw',badge:r.tag||'日志',title:r.tag||'原始日志',detail:r.msg||r.line,raw:r.line,time:r.time};if(r.tag==='av-diag'){try{e.diag=JSON.parse(r.msg)}catch(ignore){}}return e}" - + "function crawlerConsole(r){const name=r.tag==='quickjs'?'QuickJS':r.tag==='python-spider'?'Python':'';if(!name)return null;const e=base(r),match=/^\\[(DEBUG|INFO|WARN|ERROR|STDERR)\\] ?/.exec(r.msg),level=match?match[1]:'';e.kind='console';e.badge=name;e.detail=match?r.msg.slice(match[0].length):r.msg;e.state=level==='ERROR'?'err':level==='WARN'?'warn':level==='STDERR'?'raw':'ok';if(!match)e.state=/error|exception|uncaught/i.test(r.msg)?'err':/warning|warn/i.test(r.msg)?'warn':'raw';e.title=name+(level==='STDERR'?' 标准错误流':level?' · '+level:' 控制台输出');return e}" - + "function explain(r){const crawler=crawlerConsole(r);if(crawler)return crawler;const text=(r.tag+': '+r.msg),low=text.toLowerCase();let e=base(r);if(r.tag==='av-diag'){try{const d=JSON.parse(r.msg);if(d.schemaVersion===1&&typeof d.event==='string'){e.kind='diagnostic';e.state=d.level==='error'?'err':d.level==='warn'?'warn':'raw';e.badge='音视频诊断';e.title=d.event;e.detail=JSON.stringify(d);return e}}catch(ignore){}}if(r.tag==='webview-console'||r.tag==='webhome-console'){e.kind='console';e.state=(low.includes('error')||low.includes('exception')||low.includes('uncaught'))?'err':(low.includes('warning')||low.includes('warn'))?'warn':'ok';e.badge='Console';e.title=r.tag==='webhome-console'?'WebHome 控制台输出':'网页控制台输出';e.detail=r.msg;return e}if(r.tag==='server'){e.kind='server';e.state='raw';e.badge='服务';e.title='App 本机 HTTP 服务收到请求';e.detail=r.msg;return e}if(r.tag&&r.tag.startsWith('ai-')){e.kind='ai';e.state=low.includes('failed')||low.includes('error')||low.includes('success=false')?'err':'ok';e.badge='AI';e.title=r.msg.includes('ai request')?'AI 调用入参':r.msg.includes('ai response')?'AI 调用出参':r.msg.includes('ai error')?'AI 调用异常':'AI 调用状态';e.detail=r.msg;return e}if(low.includes('error')||low.includes('exception')||low.includes('failed')||low.includes('timeout')||low.includes('失败')||low.includes('崩溃')||low.includes('异常')){e.kind='error';e.state='err';e.badge='错误';e.title='发现错误或异常';return e}" + + "function base(r){return{kind:'raw',state:'raw',badge:r.tag||'日志',title:r.tag||'原始日志',detail:r.msg||r.line,raw:r.line,time:r.time}}" + + "function explain(r){const text=(r.tag+': '+r.msg),low=text.toLowerCase();let e=base(r);if(r.tag==='webview-console'||r.tag==='webhome-console'){e.kind='console';e.state=(low.includes('error')||low.includes('exception')||low.includes('uncaught'))?'err':(low.includes('warning')||low.includes('warn'))?'warn':'ok';e.badge='Console';e.title=r.tag==='webhome-console'?'WebHome 控制台输出':'网页控制台输出';e.detail=r.msg;return e}if(r.tag==='server'){e.kind='server';e.state='raw';e.badge='服务';e.title='App 本机 HTTP 服务收到请求';e.detail=r.msg;return e}if(r.tag&&r.tag.startsWith('ai-')){e.kind='ai';e.state=low.includes('failed')||low.includes('error')||low.includes('success=false')?'err':'ok';e.badge='AI';e.title=r.msg.includes('ai request')?'AI 调用入参':r.msg.includes('ai response')?'AI 调用出参':r.msg.includes('ai error')?'AI 调用异常':'AI 调用状态';e.detail=r.msg;return e}if(low.includes('error')||low.includes('exception')||low.includes('failed')||low.includes('timeout')||low.includes('失败')||low.includes('崩溃')||low.includes('异常')){e.kind='error';e.state='err';e.badge='错误';e.title='发现错误或异常';return e}" + "if(r.tag==='startup'){e.kind='startup';e.state='ok';e.badge='启动';e.title='启动阶段耗时';e.detail=r.msg;return e}" + "if(r.tag==='debug'){e.kind='server';e.state='ok';e.badge='调试';e.title=r.msg.includes('ready')?'调试日志服务已准备':'调试日志状态变化';e.detail=r.msg;return e}" + "if(r.tag==='env'){e.kind='startup';e.state='ok';e.badge='环境';e.title='设备和系统环境';e.detail=r.msg;return e}" @@ -328,104 +170,9 @@ public class DebugLogs implements Process { + "if(r.tag==='okhttp-player'&&r.msg.includes('response')){e.kind='player';e.state='ok';e.badge='响应';e.title='播放器收到响应';e.detail='状态 '+(part(r.msg,'code=')||'-')+' · '+(part(r.msg,'contentType=')||'')+' · '+(part(r.msg,'url=')||'');return e}" + "if(r.tag==='okhttp-player'&&r.msg.includes('start')){e.kind='player';e.state='raw';e.badge='播放';e.title='播放器开始请求';e.detail=part(r.msg,'url=')||r.msg;return e}" + "if(['player','player-engine','playback-flow','exo-source'].includes(r.tag)){e.kind='player';e.state=low.includes('error')?'err':'ok';e.badge='播放';e.title=r.tag==='playback-flow'?'播放页面/服务链路':r.tag==='player-engine'?'播放器内核事件':r.tag==='exo-source'?'媒体源创建':'播放解析/状态';e.detail=r.msg;return e}return e}" - + "function pass(e,key){if(!diagPass(e))return false;const all=(e.raw+' '+e.title+' '+e.detail).toLowerCase();if(key&&!all.includes(key))return false;if(mode==='all')return !(e.kind==='server'&&e.state==='raw');if(mode==='error')return e.kind==='error'||e.state==='err'||e.diag&&e.diag.level==='fatal';return e.kind===mode}" - + "function render(){try{const key=filter.value.trim().toLowerCase();const rows=raw.split('\\n').filter(Boolean).map(parse).map(explain);let shown=0,hit=0,err=0,playerProxy=0,webview=0,consoleCount=0,api=0;const html=[];rows.forEach(e=>{if(e.kind==='proxy'&&e.title.includes('命中'))hit++;if(e.kind==='error'||e.state==='err')err++;if(e.kind==='player'&&(e.title.includes('代理')||e.raw.includes('via proxy')))playerProxy++;if(e.kind==='webview')webview++;if(e.kind==='console')consoleCount++;if(e.kind==='api')api++;if(!pass(e,key))return;shown++;html.push('
'+esc(e.badge)+''+esc(e.title)+''+esc(e.time)+'
'+esc(e.detail)+'
'+esc(e.raw)+'
')});logs.innerHTML=html.join('')||'
没有匹配日志
';summary.textContent='显示 '+shown+'/'+rows.length+' 行 · 错误 '+err+' 条 · 代理命中 '+hit+' 次 · 播放代理链路 '+playerProxy+' 次 · Console '+consoleCount+' 条 · WebView '+webview+' 条 · 站源 '+api+' 条';summary.title=summary.textContent;rawEl.hidden=true;resetX()}catch(err){rawEl.hidden=false;logs.innerHTML='
日志页面渲染失败,已显示原始日志:'+esc(err&&err.message?err.message:err)+'
';summary.textContent='渲染失败 · 已显示原始日志';resetX()}}" - + "async function poll(){try{if(!paused){const r=await fetch('/debug/stream?afterSeq='+lastSeq+'&run='+encodeURIComponent(lastRun)+'&generation='+lastGeneration,{cache:'no-store'});const j=await r.json();lastSeq=j.newestSeq;lastRun=j.runId;lastGeneration=j.generation;const h=j.health||{};meta.textContent=(crashSession?'崩溃会话 · 自动开启':h.completeness==='partial'?'日志不完整':j.enabled?'记录中':'已关闭')+' · '+Math.ceil((j.bytes||0)/1024)+' KB';meta.classList.toggle('partial',h.completeness==='partial');meta.title=h.completeness==='partial'?'日志不完整,详见下载说明':meta.textContent;if(j.reset||j.gap||j.text){raw=(j.reset||j.gap?'':raw)+(j.text||'');if(raw.length>524288){const cut=raw.indexOf('\\n',raw.length-524288);raw=cut<0?'':raw.slice(cut+1)}rawEl.textContent=raw;render();if(stick&&!toolsOpen())scrollTo(0,document.body.scrollHeight)}}}catch(e){meta.textContent='日志连接失败,稍后重试'}setTimeout(poll,1500)}render();poll();"; - } - - private String diagnosticHtml(String localUrl, String lanUrl, boolean enabled, boolean temporary) { - String control = temporary - ? "崩溃会话临时日志:本页自动开启,重启后恢复原设置。" - : ""; - return String.format(java.util.Locale.ROOT, """ - - -

日志工具

- -
-
-

音视频筛选

- - - - -

按播放记录和音视频链路缩小范围。

-

显示方式

-
- - -
-

-
- """, control, - escape(localUrl), escape(localUrl), escape(lanUrl), escape(lanUrl)); - } - - private String diagnosticScript() { - return """ - function diagEl(id){return document.getElementById('diag-'+id)} - function diagSay(text){diagEl('feedback').textContent=text;if(!toolsOpen())openTools('diagnostics')} - async function diagPost(path,data,binary){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data||{}),cache:'no-store'});if(!r.ok){let j;try{j=await r.json()}catch(e){}throw Error(j&&j.message||'操作失败,请重试')}return binary?r.blob():r.json()} - function diagRun(action){return Promise.resolve().then(action).catch(e=>diagSay(e.message||'操作失败'))} - diagEl('mark').onclick=()=>diagRun(async()=>{await diagPost('/debug/diag/mark',{symptom:diagEl('symptom').value});diagSay('已标记,继续记录后 15 秒;随后下载可包含故障前后上下文')}); - diagEl('deep').onclick=()=>{if(confirm('开启本次播放的 60 秒深度统计?只记录低分辨率画面和 PCM 数值,不保存图像或声音;到期自动停止。'))diagRun(async()=>{await diagPost('/debug/diag/deep',{seconds:60,consent:true});diagSay('已开启限时统计')})}; - diagEl('stop').onclick=()=>diagRun(async()=>{await diagPost('/debug/diag/stop');diagSay('深度统计已停止')}); - diagEl('fel-bind').onclick=()=>{if(confirm('暂时暂停当前播放,运行最多 30 秒的 FEL 绑定对照?结束后恢复播放,不读取或保存画面。'))diagRun(async()=>{await diagPost('/debug/diag/fel-bind',{consent:true});diagEl('fel-bind').disabled=true;diagSay('已请求绑定对照,结果将写入日志')})}; - diagEl('fel-cancel').onclick=()=>diagRun(async()=>{await diagPost('/debug/diag/fel-bind-stop');diagSay('已请求取消,正在释放诊断资源')}); - diagEl('zip').onclick=()=>diagRun(async()=>{diagSay('正在生成诊断包…');const blob=await diagPost('/debug/diag/export',{},true);if(!blob.size)throw Error('诊断包为空,请重试 TXT 下载');const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='webhtv-av-report.zip';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);diagSay('诊断包已生成,含日志、可读报告、结构化事件和校验信息')}); - document.querySelectorAll('[data-debug-action]').forEach(button=>button.onclick=()=>diagRun(async()=>{const r=await fetch(button.dataset.debugAction,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}',cache:'no-store'});if(!r.ok){const j=await r.json();throw Error(j.message)}location.reload()})); - function diagPass(e){const trace=diagEl('trace').value,attempt=diagEl('attempt').value,domain=diagEl('domain').value,priority=diagEl('priority').value;if(!trace&&!attempt&&!domain&&!priority)return true;const d=e.diag;if(!d)return false;if(trace&&d.trace!==trace||attempt&&String(d.attemptId)!==attempt)return false;const name=d.event||'',p=d.observed&&d.observed.property&&d.observed.property.value||'';if(domain==='video'&&!/video|surface|display|pixel/.test(name+' '+p))return false;if(domain==='audio'&&!/audio|pcm|volume|mute|avsync/.test(name+' '+p))return false;if(priority==='critical'&&d.priority!=='critical'&&!/error|fatal|warn/.test(d.level))return false;if(priority==='error'&&!/error|fatal/.test(d.level))return false;return true} - ['trace','attempt','domain','priority'].forEach(id=>diagEl(id).onchange=()=>{updateFilterState();render()}); - function diagOptions(id,values,label){const el=diagEl(id),selected=el.value,all=Array.from(values).slice(-100);if(selected&&!all.includes(selected))all.push(selected);const signature=all.join('|');if(el.dataset.signature===signature)return;el.dataset.signature=signature;el.textContent='';const first=document.createElement('option');first.value='';first.textContent=label;el.appendChild(first);all.forEach(value=>{const option=document.createElement('option');option.value=value;option.textContent=value;el.appendChild(option)});el.value=selected} - function diagFilters(){const traces=new Set(),attempts=new Set();raw.split('\\n').forEach(line=>{const r=parse(line);if(r.tag!=='av-diag')return;try{const d=JSON.parse(r.msg);if(d.trace&&d.trace!=='none')traces.add(d.trace);if(d.attemptId)attempts.add(String(d.attemptId))}catch(e){}});diagOptions('trace',traces,'所有播放记录');diagOptions('attempt',attempts,'所有尝试')} - async function diagStatus(){try{const r=await fetch('/debug/diag/status',{cache:'no-store'}),j=await r.json();diagEl('status').textContent=(j.engine||'暂无播放')+(j.deepRemainingMs>0?' · 深度统计剩余 '+Math.ceil(j.deepRemainingMs/1000)+' 秒':' · 标准记录');const s=j.felBindProbeState||'unavailable',labels={unavailable:'当前播放不支持对照',ready:'可运行',queued:'等待播放器响应',armed:'等待下一帧',quiescing:'准备暂停播放',running:'正在测量',cancelling:'正在取消',completed:'已完成,结果见日志',cancelled:'已取消',failed:'未完成,请查看日志并确认正在播放', 'timed-out':'已到时间上限,结果见日志'};diagEl('fel-status').textContent=labels[s]||s;diagEl('fel-bind').disabled=s==='unavailable'||j.felBindProbeActive||j.deepRemainingMs>0;diagEl('fel-cancel').disabled=!j.felBindProbeActive;diagEl('deep').disabled=!!j.felBindProbeActive;diagFilters()}catch(e){}setTimeout(diagStatus,3000)}diagStatus(); - """; - } - - private String toolbarScript() { - return """ - const drawer=document.getElementById('log-drawer'),toolsButton=document.getElementById('tools-open'),backdrop=document.getElementById('drawer-backdrop'),searchPanel=document.getElementById('search-panel'),searchToggle=document.getElementById('search-toggle'),wideSearch=matchMedia('(min-width:800px)'); - let drawerOpener=null,activeTool='filters',searchExpanded=false; - function toolsOpen(){return document.getElementById('log-drawer').hasAttribute('open')} - function selectToolTab(name,focus){activeTool=name;document.querySelectorAll('[data-tool]').forEach(button=>{const active=button.dataset.tool===name;button.setAttribute('aria-selected',active);button.tabIndex=active?0:-1;document.getElementById(button.getAttribute('aria-controls')).hidden=!active;if(active&&focus)button.focus()});document.querySelector('.drawer-body').scrollTop=0} - function openTools(name){const opening=!toolsOpen();if(opening){drawerOpener=document.activeElement;document.body.classList.add('drawer-open');if(typeof drawer.showModal==='function')drawer.showModal();else{drawer.setAttribute('open','');backdrop.hidden=false;document.querySelector('main').setAttribute('aria-hidden','true')}}selectToolTab(name||activeTool,opening)} - function afterToolsClose(){document.body.classList.remove('drawer-open');backdrop.hidden=true;document.querySelector('main').removeAttribute('aria-hidden');if(drawerOpener&&document.contains(drawerOpener))drawerOpener.focus({preventScroll:true})} - function closeTools(){if(!toolsOpen())return;if(typeof drawer.close==='function')drawer.close();else{drawer.removeAttribute('open');afterToolsClose()}} - toolsButton.onclick=()=>openTools();document.getElementById('tools-close').onclick=closeTools;backdrop.onclick=closeTools; - drawer.addEventListener('close',afterToolsClose);drawer.addEventListener('cancel',event=>{event.preventDefault();closeTools()}); - drawer.addEventListener('click',event=>{if(event.target===drawer){const r=drawer.getBoundingClientRect();if(event.clientXr.right||event.clientYr.bottom)closeTools()}}); - drawer.addEventListener('keydown',event=>{if(event.key==='Escape'){event.preventDefault();closeTools()}if(event.key==='Tab'){const nodes=Array.from(drawer.querySelectorAll('button,a[href],input,select,summary,[tabindex]')).filter(el=>!el.disabled&&el.tabIndex>=0&&el.getClientRects().length);const first=nodes[0],last=nodes[nodes.length-1];if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus()}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus()}}}); - function arrowItem(event,buttons){const index=buttons.indexOf(event.target);if(index<0)return null;let next;if(event.key==='ArrowRight')next=(index+1)%buttons.length;else if(event.key==='ArrowLeft')next=(index+buttons.length-1)%buttons.length;else if(event.key==='Home')next=0;else if(event.key==='End')next=buttons.length-1;else return null;event.preventDefault();return buttons[next]} - document.querySelectorAll('[data-tool]').forEach(button=>button.onclick=()=>selectToolTab(button.dataset.tool,false)); - document.querySelector('.tool-tabs').onkeydown=event=>{const button=arrowItem(event,Array.from(document.querySelectorAll('[data-tool]')));if(button)selectToolTab(button.dataset.tool,true)}; - function selectCategory(button){document.querySelectorAll('.chip').forEach(item=>{const active=item===button;item.classList.toggle('on',active);item.setAttribute('aria-pressed',active);item.tabIndex=active?0:-1});mode=button.dataset.mode;render();if(!toolsOpen())button.scrollIntoView({block:'nearest',inline:'nearest'})} - document.querySelector('.tabs').onkeydown=event=>{const button=arrowItem(event,Array.from(document.querySelectorAll('.chip')));if(button){button.focus({preventScroll:true});selectCategory(button)}}; - function layoutSearch(){const open=wideSearch.matches||searchExpanded;searchPanel.hidden=!open;searchToggle.hidden=wideSearch.matches;searchToggle.setAttribute('aria-expanded',open)} - function hideSearch(){searchExpanded=false;layoutSearch();searchToggle.focus({preventScroll:true})} - searchToggle.onclick=()=>{searchExpanded=!searchExpanded;layoutSearch();if(searchExpanded)filter.focus()};document.getElementById('search-hide').onclick=hideSearch; - filter.addEventListener('keydown',event=>{if(event.key==='Escape'&&!wideSearch.matches){event.preventDefault();hideSearch()}}); - function scrollToTop(){stick=false;try{scrollTo({top:0,behavior:'smooth'})}catch(e){scrollTo(0,0);document.documentElement.scrollTop=0;document.body.scrollTop=0}resetX()} - topBtn.onclick=scrollToTop; - if(wideSearch.addEventListener)wideSearch.addEventListener('change',layoutSearch);else wideSearch.addListener(layoutSearch); - function updateFilterState(){const count=['trace','attempt','domain','priority'].filter(id=>diagEl(id).value).length+(filter.value.trim()?1:0),badge=document.getElementById('filter-count');badge.textContent=count;badge.hidden=!count;toolsButton.classList.toggle('filtered',count>0);toolsButton.setAttribute('aria-label',count?'日志工具,'+count+'项筛选生效':'日志工具');searchToggle.classList.toggle('on',!!filter.value.trim());searchToggle.title=filter.value.trim()?'搜索:'+filter.value.trim():'搜索日志'} - document.getElementById('filter-reset').onclick=()=>{filter.value='';['trace','attempt','domain','priority'].forEach(id=>diagEl(id).value='');updateFilterState();selectCategory(document.querySelector('[data-mode=all]'))}; - document.getElementById('info-toggle').onclick=()=>{const panel=document.getElementById('address-panel'),button=document.getElementById('info-toggle');panel.hidden=!panel.hidden;button.setAttribute('aria-expanded',!panel.hidden)}; - layoutSearch();updateFilterState(); - """; + + "function pass(e,key){const all=(e.raw+' '+e.title+' '+e.detail).toLowerCase();if(key&&!all.includes(key))return false;if(mode==='all')return !(e.kind==='server'&&e.state==='raw');if(mode==='error')return e.kind==='error'||e.state==='err';return e.kind===mode}" + + "function render(){try{const key=filter.value.trim().toLowerCase();const rows=raw.split('\\n').filter(Boolean).map(parse).map(explain);let shown=0,hit=0,err=0,playerProxy=0,webview=0,consoleCount=0,api=0,ai=0;const html=[];rows.forEach(e=>{if(e.kind==='proxy'&&e.title.includes('命中'))hit++;if(e.kind==='error'||e.state==='err')err++;if(e.kind==='player'&&(e.title.includes('代理')||e.raw.includes('via proxy')))playerProxy++;if(e.kind==='webview')webview++;if(e.kind==='console')consoleCount++;if(e.kind==='api')api++;if(e.kind==='ai')ai++;if(!pass(e,key))return;shown++;html.push('
'+esc(e.badge)+''+esc(e.title)+''+esc(e.time)+'
'+esc(e.detail)+'
'+esc(e.raw)+'
')});logs.innerHTML=html.join('')||'
没有匹配日志
';summary.textContent='显示 '+shown+'/'+rows.length+' 行 · 错误 '+err+' 条 · AI '+ai+' 条 · 代理命中 '+hit+' 次 · 播放代理链路 '+playerProxy+' 次 · Console '+consoleCount+' 条 · WebView '+webview+' 条 · 站源 '+api+' 条';rawEl.hidden=true;resetX()}catch(err){rawEl.hidden=false;logs.innerHTML='
日志页面渲染失败,已显示原始日志:'+esc(err&&err.message?err.message:err)+'
';summary.textContent='渲染失败 · 已显示原始日志';resetX()}}" + + "async function poll(){try{if(!paused){const r=await fetch('/debug/stream?v='+lastVersion+'&_='+Date.now(),{cache:'no-store'});const j=await r.json();lastVersion=j.version||lastVersion;meta.textContent=(j.enabled?'开启':'关闭')+' · '+j.size+' 行 · '+Math.ceil((j.bytes||0)/1024)+' KB';if(j.text!==null&&j.text!==undefined){raw=j.text||'';render();if(stick)scrollTo(0,document.body.scrollHeight)}}}catch(e){}setTimeout(poll,1500)}render();poll();"; } private String json(String text) { diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/M3u8.java b/app/src/main/java/com/fongmi/android/tv/server/process/M3u8.java index 3dbf835832..a83eb632ba 100644 --- a/app/src/main/java/com/fongmi/android/tv/server/process/M3u8.java +++ b/app/src/main/java/com/fongmi/android/tv/server/process/M3u8.java @@ -2,16 +2,12 @@ package com.fongmi.android.tv.server.process; import android.text.TextUtils; -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.server.Nano; import com.fongmi.android.tv.server.impl.Process; import com.fongmi.android.tv.api.config.HlsRuleConfig; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.utils.HlsManifestCleaner; import com.fongmi.android.tv.utils.HlsAdblockPipeline; -import com.fongmi.android.tv.utils.HlsAdblockNotice; -import com.fongmi.android.tv.utils.Notify; import com.github.catvod.crawler.SpiderDebug; import com.github.catvod.net.OkHttp; @@ -91,16 +87,9 @@ public class M3u8 implements Process { if (!upstream.isSuccessful()) return error(status(upstream.code()), "Playlist HTTP " + upstream.code()); String text = body.string(); if (!looksLikePlaylist(text)) return Nano.error(Response.Status.BAD_REQUEST, "Invalid playlist"); - List rules = List.of(); - boolean legacyFallback = false; - if (Setting.isAdblock()) { - rules = hlsRules(); - legacyFallback = HlsRuleConfig.isLegacyFallbackEnabled(); - } HlsAdblockPipeline.Outcome clean = Setting.isAdblock() - ? HlsAdblockPipeline.apply(upstream.request().url().toString(), text, rules, legacyFallback) + ? HlsAdblockPipeline.apply(upstream.request().url().toString(), text, hlsRules(), true) : new HlsAdblockPipeline.Outcome(text, false, false, 0, 0); - recordAndNotify(upstream.request().url(), clean); String rewritten = rewrite(upstream.request().url(), clean.manifest()); byte[] bytes = rewritten.getBytes(StandardCharsets.UTF_8); SpiderDebug.log(TAG, "playlist bytes=%s rewritten=%s removed=%s structured=%s legacy=%s url=%s", @@ -109,17 +98,6 @@ public class M3u8 implements Process { } } - private void recordAndNotify(HttpUrl url, HlsAdblockPipeline.Outcome clean) { - if (!clean.structured() && !clean.legacy()) return; - long fallbackCount = clean.legacy() ? Math.max(1, clean.removedSegments()) : 0; - AdBlockStatsStore.recordBlocks(url.host(), "HLS", clean.ruleCounts(), fallbackCount, - url.host(), clean.removedDurationSec(), clean.removedSegmentDetails()); - if (!HlsAdblockNotice.shouldNotify(url.toString(), System.currentTimeMillis())) return; - int removed = clean.removedSegments() > 0 ? clean.removedSegments() : (int) fallbackCount; - String message = HlsAdblockNotice.message(removed, clean.removedDurationSec()); - App.post(() -> Notify.show(message)); - } - private List hlsRules() { return HlsRuleConfig.getRules(); } diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/Manage.java b/app/src/main/java/com/fongmi/android/tv/server/process/Manage.java index e808555724..a7f7aaa2f1 100644 --- a/app/src/main/java/com/fongmi/android/tv/server/process/Manage.java +++ b/app/src/main/java/com/fongmi/android/tv/server/process/Manage.java @@ -9,8 +9,6 @@ import com.fongmi.android.tv.api.config.LiveConfig; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.Backup; import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.db.AppDatabase; -import com.fongmi.android.tv.setting.ConfigSyncPolicy; import com.fongmi.android.tv.bean.Device; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.bean.SyncOptions; @@ -24,7 +22,6 @@ import com.fongmi.android.tv.setting.CustomCspSetting; import com.fongmi.android.tv.setting.ProxySetting; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.utils.LoginStateSync; -import com.fongmi.android.tv.utils.MpvConfigSync; import com.fongmi.android.tv.utils.ProgressRequestBody; import com.fongmi.android.tv.utils.ScanTask; import com.fongmi.android.tv.utils.SyncFiles; @@ -347,14 +344,9 @@ public class Manage implements Process { int type = intValue(params.get("type"), 0); String url = params.getOrDefault("url", "").trim(); String name = params.getOrDefault("name", "").trim(); - String interfaceKey = params.getOrDefault("interfaceKey", "").trim(); if (TextUtils.isEmpty(url)) return Nano.error(Status.BAD_REQUEST, "Missing url"); - Config config = TextUtils.isEmpty(interfaceKey) - ? Config.find(url, type) - : AppDatabase.get().getConfigDao().findByInterfaceKey(interfaceKey, type); - if (config == null) config = Config.create(type); - config.interfaceKey(interfaceKey).url(url).name(name).save(); - com.fongmi.android.tv.playback.PlaybackIdentityResolver.resolveSaved(config); + Config config = Config.find(url, type).name(name); + config.save(); } JsonObject object = new JsonObject(); JsonArray items = new JsonArray(); @@ -369,39 +361,24 @@ public class Manage implements Process { private Response configUse(Map params) { int type = intValue(params.get("type"), 0); - Config config = findConfig(params, type); - if (config == null || config.isEmpty()) return Nano.error(Status.NOT_FOUND, "Config not found"); + String url = params.getOrDefault("url", "").trim(); + if (TextUtils.isEmpty(url)) return Nano.error(Status.BAD_REQUEST, "Missing url"); + Config config = Config.find(url, type); + if (config.isEmpty()) return Nano.error(Status.NOT_FOUND, "Config not found"); switch (type) { case 1 -> LiveConfig.load(config, new Callback()); case 2 -> WallConfig.load(config, new Callback()); - default -> { - String previousVodUrl = VodConfig.getUrl(); - VodConfig.load(config, new Callback()); - if (ConfigSyncPolicy.shouldSyncLive(previousVodUrl, LiveConfig.getUrl())) { - Config liveConfig = AppDatabase.get().getConfigDao().find(config.getUrl(), 1); - if (liveConfig != null) LiveConfig.load(liveConfig, new Callback()); - } - } + default -> VodConfig.load(config, new Callback()); } return configs(java.util.Collections.emptyMap()); } private Response configDelete(Map params) { int type = intValue(params.get("type"), 0); - Config config = findConfig(params, type); - if (config == null || config.isEmpty()) return Nano.error(Status.NOT_FOUND, "Config not found"); - config.delete(); - return configs(java.util.Collections.emptyMap()); - } - - private Config findConfig(Map params, int type) { - String interfaceKey = params.getOrDefault("interfaceKey", "").trim(); - if (!TextUtils.isEmpty(interfaceKey)) { - Config config = AppDatabase.get().getConfigDao().findByInterfaceKey(interfaceKey, type); - if (config != null) return config; - } String url = params.getOrDefault("url", "").trim(); - return TextUtils.isEmpty(url) ? null : AppDatabase.get().getConfigDao().find(url, type); + if (TextUtils.isEmpty(url)) return Nano.error(Status.BAD_REQUEST, "Missing url"); + Config.find(url, type).delete(); + return configs(java.util.Collections.emptyMap()); } private JsonObject configObject(Config config, boolean forceActive) { @@ -410,11 +387,6 @@ public class Manage implements Process { item.addProperty("typeName", configTypeName(config.getType())); item.addProperty("name", config.getName()); item.addProperty("url", config.getUrl()); - item.addProperty("interfaceKey", config.ensureInterfaceKey()); - item.add("urls", App.gson().toJsonTree(config.getUrls())); - item.add("legacyConfigKeys", App.gson().toJsonTree(config.getLegacyConfigKeys())); - item.add("addressMatchAliases", App.gson().toJsonTree(config.getAddressMatchAliases())); - item.addProperty("identityResolutionState", config.getIdentityResolutionState()); item.addProperty("desc", config.getDesc()); item.addProperty("time", config.getTime()); item.addProperty("active", forceActive || isCurrentConfig(config)); @@ -424,14 +396,14 @@ public class Manage implements Process { private boolean containsConfig(JsonArray items, Config config) { for (int i = 0; i < items.size(); i++) { JsonObject item = items.get(i).getAsJsonObject(); - if (item.get("type").getAsInt() == config.getType() && item.get("interfaceKey").getAsString().equals(config.ensureInterfaceKey())) return true; + if (item.get("type").getAsInt() == config.getType() && item.get("url").getAsString().equals(config.getUrl())) return true; } return false; } private boolean isCurrentConfig(Config config) { Config current = currentConfig(config.getType()); - return current.getType() == config.getType() && current.ensureInterfaceKey().equals(config.ensureInterfaceKey()); + return current.getUrl().equals(config.getUrl()); } private Config currentConfig(int type) { @@ -583,13 +555,11 @@ public class Manage implements Process { SyncOptions options = SyncOptions.objectFrom(params.get("options")); if (params.containsKey("paths")) options.paths(params.get("paths")); SyncFiles.Archive archive = null; - MpvConfigSync.Archive mpvArchive = null; LoginStateSync.Archive loginArchive = null; try { if (!pull && SyncFiles.hasPaths(options)) archive = SyncFiles.createArchive(SyncFiles.getPaths(options)); - if (!pull && options.isMpvConfig()) mpvArchive = MpvConfigSync.createArchive(); if (!pull && options.isLoginState()) loginArchive = LoginStateSync.createArchive(); - RequestBody body = buildSyncBody(pull, options, archive, mpvArchive, loginArchive); + RequestBody body = buildSyncBody(pull, options, archive, loginArchive); String remote = device.replaceAll("/+$", "") + "/action?do=sync&mode=" + (pull ? "2" : "1") + "&type=backup"; SpiderDebug.log("sync", "manage start direction=%s device=%s options=%s archive=%s", pull ? "pull" : "push", device, options, archive == null ? "none" : archive.getFile().getAbsolutePath()); try (okhttp3.Response response = OkHttp.client(Constant.TIMEOUT_SYNC_TRANSFER).newCall(new Request.Builder().url(remote).post(body).build()).execute()) { @@ -608,11 +578,6 @@ public class Manage implements Process { object.addProperty("rawSize", archive.getRawSize()); object.addProperty("zipSize", archive.getZipSize()); } - if (mpvArchive != null) { - object.addProperty("mpvFiles", mpvArchive.getCount()); - object.addProperty("mpvRawSize", mpvArchive.getRawSize()); - object.addProperty("mpvZipSize", mpvArchive.getZipSize()); - } if (loginArchive != null) { object.addProperty("loginFiles", loginArchive.getCount()); object.addProperty("loginRawSize", loginArchive.getRawSize()); @@ -621,12 +586,11 @@ public class Manage implements Process { return json(object); } finally { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); } } - private RequestBody buildSyncBody(boolean pull, SyncOptions options, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive) { + private RequestBody buildSyncBody(boolean pull, SyncOptions options, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive) { if (pull) { FormBody.Builder body = new FormBody.Builder(); body.add("options", options.toString()); @@ -634,7 +598,7 @@ public class Manage implements Process { body.add("device", Device.get().toString()); return body.build(); } - if (archive == null && mpvArchive == null && loginArchive == null) { + if (archive == null && loginArchive == null) { FormBody.Builder body = new FormBody.Builder(); body.add("options", options.toString()); body.add("force", "false"); @@ -648,7 +612,6 @@ public class Manage implements Process { body.addFormDataPart("backup", Backup.create(options).toString()); if (options.isRemoteRelay()) body.addFormDataPart("remoteRelay", RemoteStore.exportRelayConfig()); if (archive != null) body.addFormDataPart(SyncFiles.PART_NAME, archive.getFile().getName(), new ProgressRequestBody(archive.getFile(), ZIP, null)); - if (mpvArchive != null) body.addFormDataPart(MpvConfigSync.PART_NAME, mpvArchive.getFile().getName(), new ProgressRequestBody(mpvArchive.getFile(), ZIP, null)); if (loginArchive != null) body.addFormDataPart(LoginStateSync.PART_NAME, loginArchive.getFile().getName(), new ProgressRequestBody(loginArchive.getFile(), ZIP, null)); return body.build(); } diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/PlaybackHistoryApi.java b/app/src/main/java/com/fongmi/android/tv/server/process/PlaybackHistoryApi.java deleted file mode 100644 index 16b13018cf..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/server/process/PlaybackHistoryApi.java +++ /dev/null @@ -1,158 +0,0 @@ -package com.fongmi.android.tv.server.process; - -import android.text.TextUtils; - -import com.fongmi.android.tv.playback.PlaybackApi; -import com.fongmi.android.tv.playback.PlaybackRecord; -import com.fongmi.android.tv.playback.ViewingRecordSyncStore; -import com.fongmi.android.tv.server.Nano; -import com.fongmi.android.tv.server.impl.Process; -import com.github.catvod.crawler.SpiderDebug; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; - -import java.util.List; -import java.util.Map; - -import fi.iki.elonen.NanoHTTPD; -import fi.iki.elonen.NanoHTTPD.IHTTPSession; -import fi.iki.elonen.NanoHTTPD.Response; - -/** - * 按站点读取播放历史的只读端点。 - * - *

为什么需要它:既有的 {@code /api/playback/current} 读的是 - * {@code PlaybackRuntime.currentHistory()}——一个只在**播放中**有值的内存字段, - * 未播放或已息屏时恒为 {@code null} 并返回 404。对端(WebHTV PC 的实时同步) - * 需要的是"这个站点有哪些历史",而不是"现在正在播什么",因此必须有一个 - * 从数据库读的端点。 - * - *

设计约束: - *

    - *
  • 只读:不接受 POST,不落库,不触发任何播放副作用;
  • - *
  • 无记录返回空数组(200),而不是 404——"没有历史"与"端点不存在" - * 必须可区分,否则对端无法判断自己是否该降级;
  • - *
  • 字段裁剪与 {@code /api/playback/current} 一致 - * ({@link com.fongmi.android.tv.playback.PlaybackFieldPolicy#apiSafe()}), - * 不因为新增端点而多暴露字段;
  • - *
  • 隐身模式返回空数组:隐身是用户显式表达的"不要记录/不要外泄"意图;
  • - *
  • 观影记录同步关闭时返回 403:与写端点 {@code /api/playback/progress} - * 同款开关({@link com.fongmi.android.tv.playback.ViewingRecordSyncStore#isEnabled()}), - * 关闭后本端点不再向局域网暴露任何历史;
  • - *
  • siteKey 用精确前缀匹配({@code substr}),不用 {@code LIKE}—— - * siteKey 可能含 {@code %} / {@code _},{@code LIKE} 会误命中。
  • - *
- * - *

响应形态(对端按此解析): - *

- * {
- *   "siteKey": "csp_xxx",
- *   "items": [ { ...apiSafe 字段集... } ],
- *   "total": 3
- * }
- * 
- * - *

{@code total} 是**本次响应 items 的长度**,不是该站点的历史总数: - * 本端点没有分页参数,结果被 {@code limit} 截断(默认 20,上限 100)后一次返回。 - * 对端若看到 {@code total == limit},不能据此推断"已取全"。 - */ -public class PlaybackHistoryApi implements Process { - - @Override - public boolean isRequest(IHTTPSession session, String url) { - return "/api/playback/history".equals(url) || "/playback/history".equals(url); - } - - @Override - public Response doResponse(IHTTPSession session, String url, Map files) { - if (session.getMethod() == NanoHTTPD.Method.OPTIONS) return cors(json(Response.Status.NO_CONTENT, ""), session); - if (session.getMethod() != NanoHTTPD.Method.GET) return cors(error(Response.Status.METHOD_NOT_ALLOWED, 405, "只支持 GET"), session); - // 与写端点 /api/playback/progress 同款门控:观影记录同步关闭时,本端点会一次 - // 暴露整站最多 100 条历史(vodName/episodeName/positionMs),暴露面远大于 - // /api/playback/current(1 条且要求正在播放),因此必须同样受开关约束。 - if (!ViewingRecordSyncStore.isEnabled()) return cors(error(Response.Status.FORBIDDEN, 403, "观影记录同步未开启"), session); - Map params = session.getParms(); - String siteKey = siteKey(params); - if (TextUtils.isEmpty(siteKey)) return cors(error(Response.Status.BAD_REQUEST, 400, "siteKey不能为空"), session); - try { - List records = PlaybackApi.history(siteKey, limit(params)); - SpiderDebug.log("playback-api", "history site=%s total=%s", siteKey, records.size()); - return cors(json(Response.Status.OK, payload(siteKey, records).toString()), session); - } catch (Throwable e) { - // 同 PlaybackApi.history:SpiderDebug 受 DebugLogStore.enabled(默认 false)门控, - // 默认发行版里不会落盘,必须补 printStackTrace 才能让 500 在 Logcat 里可查。 - SpiderDebug.log("playback-api", e); - e.printStackTrace(); - // 不回显 e.getMessage():本端点在局域网内无需鉴权即可访问,SQLite 异常文本 - // 会泄露表名/列名等结构信息。诊断走上面的 Logcat。 - return cors(error(Response.Status.INTERNAL_ERROR, 500, "读取播放历史失败"), session); - } - } - - private JsonObject payload(String siteKey, List records) { - JsonObject object = new JsonObject(); - object.addProperty("siteKey", siteKey); - JsonArray items = new JsonArray(); - // 字段裁剪已在 PlaybackApi.history 内完成;这里只做序列化。 - for (PlaybackRecord record : records) { - if (record == null) continue; - items.add(record.toJson(com.fongmi.android.tv.playback.PlaybackFieldPolicy.apiSafe())); - } - object.add("items", items); - object.addProperty("total", items.size()); - return object; - } - - /** - * 从查询参数里取 siteKey。 - * - *

包级可见而不是私有:参数归一化是纯逻辑,值得被单测直接锁定, - * 不必为了测它去 mock 一整个 HTTP session。 - * - *

{@code siteKey} 存在但只有空白时,**也要**回退到 {@code site} 别名: - * {@code ?siteKey=%20&site=csp_x} 里调用方明确给了别名,若因为主参数是空白就 - * 直接返回空串,端点会报 400 而调用方拿不到任何解释。只有两者都空才返回空串。 - */ - static String siteKey(Map params) { - String value = params.get("siteKey"); - if (TextUtils.isEmpty(value) || TextUtils.isEmpty(value.trim())) value = params.get("site"); - return value == null ? "" : value.trim(); - } - - /** - * 解析 {@code limit}。非法值(非数字、负数、零)一律回落默认, - * 而不是报 400——对端传了一个坏参数不该让整次拉取失败。 - */ - static int limit(Map params) { - String value = params.get("limit"); - if (TextUtils.isEmpty(value)) return 0; - try { - return Integer.parseInt(value.trim()); - } catch (NumberFormatException e) { - return 0; - } - } - - private Response error(Response.Status status, int code, String message) { - JsonObject object = new JsonObject(); - object.addProperty("code", code); - object.addProperty("message", TextUtils.isEmpty(message) ? "请求失败" : message); - return json(status, object.toString()); - } - - private Response json(Response.Status status, String text) { - return NanoHTTPD.newFixedLengthResponse(status, "application/json; charset=utf-8", text); - } - - private Response cors(Response response, IHTTPSession session) { - String origin = session.getHeaders().get("origin"); - response.addHeader("Access-Control-Allow-Origin", TextUtils.isEmpty(origin) ? "*" : origin); - response.addHeader("Access-Control-Allow-Credentials", "true"); - response.addHeader("Access-Control-Allow-Methods", "GET,OPTIONS"); - response.addHeader("Access-Control-Allow-Headers", "*"); - response.addHeader("Access-Control-Expose-Headers", "*"); - response.addHeader("Access-Control-Max-Age", "86400"); - response.addHeader("Cache-Control", "no-store"); - return response; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/Proxy.java b/app/src/main/java/com/fongmi/android/tv/server/process/Proxy.java index eb57221266..c555dcaa88 100644 --- a/app/src/main/java/com/fongmi/android/tv/server/process/Proxy.java +++ b/app/src/main/java/com/fongmi/android/tv/server/process/Proxy.java @@ -5,7 +5,6 @@ import com.fongmi.android.tv.server.Nano; import com.fongmi.android.tv.server.impl.Process; import com.github.catvod.crawler.SpiderDebug; -import java.io.ByteArrayInputStream; import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; @@ -56,34 +55,20 @@ public class Proxy implements Process { SpiderDebug.log("proxy", "response object do=%s type=%s", params.get("do"), rs[0].getClass().getName()); return response; } - if (rs.length < 3 || !(rs[0] instanceof Integer code)) { + if (rs.length < 3 || !(rs[0] instanceof Integer code) || !(rs[2] instanceof InputStream stream)) { SpiderDebug.log("proxy", "response invalid do=%s status=%s mime=%s body=%s headers=%s", params.get("do"), rs.length > 0 ? rs[0] : null, rs.length > 1 ? rs[1] : null, rs.length > 2 ? rs[2] : null, rs.length > 3 ? rs[3] : null); return Nano.error(INVALID_RESPONSE); } - InputStream stream = rs[2] instanceof InputStream candidate ? candidate : null; - if (stream == null && !canResponseHaveEmptyBody(code)) { - SpiderDebug.log("proxy", "response invalid do=%s status=%s mime=%s body=%s headers=%s", params.get("do"), code, rs[1], rs[2], rs.length > 3 ? rs[3] : null); - return Nano.error(INVALID_RESPONSE); - } + SpiderDebug.log("proxy", "response do=%s status=%s mime=%s body=%s headers=%s", params.get("do"), code, rs[1], stream.getClass().getName(), rs.length > 3 ? rs[3] : null); Map headers = headers(rs.length > 3 ? rs[3] : null); - if (stream == null && (headers == null || header(headers, "Location") == null)) { - SpiderDebug.log("proxy", "response invalid do=%s status=%s mime=%s body=%s headers=%s", params.get("do"), code, rs[1], rs[2], headers); - return Nano.error(INVALID_RESPONSE); - } - SpiderDebug.log("proxy", "response do=%s status=%s mime=%s body=%s headers=%s", params.get("do"), code, rs[1], rs[2] == null ? "empty" : rs[2].getClass().getName(), rs.length > 3 ? rs[3] : null); - - if (stream != null) stream = wrapStream(params, headers, stream); - Response response = NanoHTTPD.newChunkedResponse(toStatus(code), Objects.toString(rs[1], null), stream == null ? new ByteArrayInputStream(new byte[0]) : stream); + stream = wrapStream(params, headers, stream); + Response response = NanoHTTPD.newChunkedResponse(toStatus(code), Objects.toString(rs[1], null), stream); addHeaders(response, headers); long rangeStart = ProxyRangeResponsePolicy.resolveStart(code, first(params.get("range"), params.get("Range"))); if (rangeStart >= 0) response.addHeader(ProxyRangeResponsePolicy.HEADER_RANGE_START, String.valueOf(rangeStart)); return response; } - private static boolean canResponseHaveEmptyBody(int code) { - return code >= 300 && code < 400; - } - private static void addHeaders(Response response, Map headers) { if (headers == null) return; for (Map.Entry entry : headers.entrySet()) { diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/VodApi.java b/app/src/main/java/com/fongmi/android/tv/server/process/VodApi.java deleted file mode 100644 index fa4d4a46c3..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/server/process/VodApi.java +++ /dev/null @@ -1,675 +0,0 @@ -package com.fongmi.android.tv.server.process; - -import android.text.TextUtils; - -import androidx.collection.ArrayMap; - -import com.fongmi.android.tv.api.SiteApi; -import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.api.config.VodSiteCatalog; -import com.fongmi.android.tv.bean.Class; -import com.fongmi.android.tv.bean.Episode; -import com.fongmi.android.tv.bean.Flag; -import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.Rule; -import com.fongmi.android.tv.bean.Site; -import com.fongmi.android.tv.bean.Value; -import com.fongmi.android.tv.bean.Vod; -import com.fongmi.android.tv.server.impl.Process; -import com.fongmi.android.tv.utils.Sniffer; -import com.github.catvod.bean.Doh; -import com.github.catvod.crawler.Spider; -import com.github.catvod.crawler.SpiderDebug; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.net.URI; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import okio.ByteString; - -import fi.iki.elonen.NanoHTTPD; -import fi.iki.elonen.NanoHTTPD.IHTTPSession; -import fi.iki.elonen.NanoHTTPD.Response; - -/** - * 当前已加载站源的 T4 网关,复用 NanoHTTPD 端口(默认 9978)。 - * 配置:/vod/api?ac=config;站点:/vod/api?key=... 或 /vod/api/。 - * T3 直接调用 Spider;远端站源仅复用 SiteApi.call 的 HTTP 层,不进入本机播放器。 - * 协议参考用户提供的实验室 T4Proxy.js,并以 SiteApi 的实际 T4 请求为互操作契约。 - */ -public class VodApi implements Process { - - static final String PATH = "/vod/api"; - static final String WARNING = "资源来自网络,仅供学习使用"; - /** 与 T4Proxy.js / vod.php 一致:站源没给总页数时按 9999 处理。 */ - static final int DEFAULT_PAGE_COUNT = 9999; - static final int DEFAULT_LIMIT = 20; - private static final String[] FIELDS = {"area", "year", "type", "class", "lang"}; - /** 与 {@code App.gson()} 等价(那里就是 new Gson()),独立持有以便单元测试。 */ - private static final Gson GSON = new Gson(); - private final VodConfig sources; - private final VodSiteCatalog.Source catalogs; - - public VodApi() { - this(VodConfig.get(), VodSiteCatalog.database()); - } - - VodApi(VodConfig sources) { - this(sources, VodSiteCatalog.database()); - } - - VodApi(VodConfig sources, VodSiteCatalog.Source catalogs) { - this.sources = sources; - this.catalogs = catalogs; - } - - @Override - public boolean isRequest(IHTTPSession session, String url) { - return PATH.equals(url) || (url != null && url.startsWith(PATH + "/")); - } - - @Override - public Response doResponse(IHTTPSession session, String url, Map files) { - if (session.getMethod() == NanoHTTPD.Method.OPTIONS) { - return cors(NanoHTTPD.newFixedLengthResponse(Response.Status.NO_CONTENT, "application/json", "")); - } - if (session.getMethod() != NanoHTTPD.Method.GET && session.getMethod() != NanoHTTPD.Method.POST - && session.getMethod() != NanoHTTPD.Method.HEAD) { - return cors(NanoHTTPD.newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "application/json", error("不支持的 HTTP 方法").toString())); - } - try { - JsonObject input = input(session, files); - String origin = origin(session); - String key = siteKeyFromPath(url); - if (key.isEmpty()) key = text(input, "key"); - String configKey = text(input, "configKey"); - if (isConfig(input) || (key.isEmpty() && input.isEmpty())) { - return json(configJson(origin, configKey)); - } - SpiderDebug.log("vod-api", "route=%s configKey=%s", route(input), configKey); - VodSiteCatalog.Catalog catalog = catalogOf(configKey); - JsonObject result = handle(input, key, origin, catalog); - Site site = findSite(catalog, key); - String proxyKey = site != null && !site.isEmpty() && site.getType() == 3 && supportsDirectProxy(site.spider()) ? key : ""; - externalize(result, origin, proxyKey, configKey, com.github.catvod.Proxy.getPort()); - return json(result); - } catch (Exception e) { - SpiderDebug.log("vod-api", e); - return json(error(e instanceof IllegalArgumentException ? e.getMessage() : "站源请求失败,请检查站源或查看应用日志")); - } - } - - /** - * 配置清单。带 {@code configKey} 时返回该配置的站源,解析走 {@link VodSiteCatalog}, - * 不加载、不保存、不切换当前激活配置;不带时保持原语义,返回当前已加载站源。 - */ - private JsonObject configJson(String origin, String configKey) throws Exception { - VodSiteCatalog.Catalog catalog = catalogOf(configKey); - if (catalog == null) return configJson(sources.getSites(), origin + PATH, sources.getDoh(), sources.getRules()); - return configJson(catalog.getSites(), origin + PATH, catalog.getDoh(), catalog.getRules(), configKey); - } - - /** 只在显式传入 configKey 时才解析目标配置;空值一律走当前激活源。 */ - private VodSiteCatalog.Catalog catalogOf(String configKey) throws Exception { - if (configKey == null || configKey.isEmpty()) return null; - return catalogs.load(configKey); - } - - private Site findSite(VodSiteCatalog.Catalog catalog, String key) { - return catalog != null ? catalog.find(key) : sources.getSite(key); - } - - private List flagsOf(VodSiteCatalog.Catalog catalog) { - return catalog != null ? catalog.getFlags() : sources.getFlags(); - } - - private JsonObject handle(JsonObject input, String key, String origin, VodSiteCatalog.Catalog catalog) throws Exception { - if (key.isEmpty()) return error("缺少站点 key 参数"); - Site site = findSite(catalog, key); - if (site == null || site.isEmpty()) return error("site not found: " + key); - if (isSelfGateway(site.getApi(), origin, com.github.catvod.Proxy.getPort())) return error("不能把本网关的配置作为本机源再次导入"); - int page = Math.max(1, parseInt(text(input, "pg"), 1)); - String route = route(input); - String flag = text(input, "flag"); - String id = text(input, "id").isEmpty() ? text(input, "play") : text(input, "id"); - List ids = ids(input); - if ("play".equals(route) && (flag.isEmpty() || id.isEmpty())) return error("缺少 flag 或 id 参数"); - if ("detail".equals(route) && ids.isEmpty()) return error("缺少 ids 参数"); - String tid = text(input, "t"); - boolean filter = !input.has("f") || truthy(text(input, "f")); - if (input.has("filter")) filter = truthy(text(input, "filter")); - JsonObject raw; - if (site.getType() == 3) { - // 不切换全局 recent loader;返回 /proxy 时以 siteKey 明确路由。 - Spider spider = site.spider(); - String content; - switch (route) { - case "search": - boolean quick = truthy(text(input, "quick")); - content = page == 1 ? spider.searchContent(text(input, "wd"), quick) - : spider.searchContent(text(input, "wd"), quick, String.valueOf(page)); - break; - case "detail": content = spider.detailContent(ids); break; - case "play": content = spider.playerContent(flag, id, flagsOf(catalog)); break; - default: - content = tid.isEmpty() ? spider.homeContent(filter) - : spider.categoryContent(tid, String.valueOf(page), filter, extendFrom(input)); - } - raw = sourceObject(content); - if ("list".equals(route) && tid.isEmpty()) { - // 推荐内容是可选的;保留已经成功拿到的首页分类和筛选。 - try { - JsonObject video = sourceObject(spider.homeVideoContent()); - if (video.has("list") && video.get("list").isJsonArray() && !video.getAsJsonArray("list").isEmpty()) raw.add("list", video.get("list")); - } catch (Exception e) { - SpiderDebug.log("vod-api", "homeVideoContent unavailable: %s", e.getClass().getSimpleName()); - } - } - } else { - if ("play".equals(route) && site.getType() != 4) { - Result result = new Result(); - result.setUrl(id); - result.setFlag(flag); - result.setHeader(site.getHeader()); - result.setPlayUrl(site.getPlayUrl()); - result.setParse(Sniffer.isVideoFormat(id) && site.getPlayUrl().isEmpty() ? 0 : 1); - return play(result, flag); - } - ArrayMap params = new ArrayMap<>(); - switch (route) { - case "search": - params.put("wd", text(input, "wd")); - params.put("pg", String.valueOf(page)); - params.put("quick", String.valueOf(truthy(text(input, "quick")))); - break; - case "detail": - params.put("ac", site.getType() == 0 ? "videolist" : "detail"); - params.put("ids", String.join(",", ids)); - break; - case "play": params.put("play", id); params.put("flag", flag); break; - default: - if (tid.isEmpty()) { - params.put("filter", String.valueOf(filter)); - if (site.getType() == 4) site.fetchExt(); - } else { - params.put("ac", site.getType() == 0 ? "videolist" : "detail"); - params.put("t", tid); - params.put("pg", String.valueOf(page)); - String extend = GSON.toJson(extendFrom(input)); - if (site.getType() == 4) params.put("ext", ByteString.encodeUtf8(extend).base64Url()); - if (site.getType() == 1) params.put("f", extend); - } - } - String content = SiteApi.call(site, params); - raw = site.getType() == 0 ? GSON.toJsonTree(Result.fromXml(content)).getAsJsonObject() : sourceObject(content); - } - if (raw.has("error") || "-1".equals(text(raw, "code"))) return error("站源返回错误"); - if ("play".equals(route)) return playJson(raw, flag, site); - return formatContent(raw, page); - } - - private static JsonObject sourceObject(String raw) { - if (raw == null || raw.isEmpty()) return new JsonObject(); - JsonObject object = jsonObject(raw); - if (object == null) throw new IllegalArgumentException("站源返回的内容不是 JSON 对象"); - return object; - } - - private static List ids(JsonObject input) { - List ids = new ArrayList<>(); - JsonElement raw = input.get("ids"); - if (raw != null && raw.isJsonArray()) { - for (JsonElement id : raw.getAsJsonArray()) if (id.isJsonPrimitive() && !id.getAsString().trim().isEmpty()) ids.add(id.getAsString().trim()); - } else { - for (String id : csv(input, "ids").split(",")) if (!id.trim().isEmpty()) ids.add(id.trim()); - } - if (ids.size() > 100) throw new IllegalArgumentException("一次最多查询 100 个视频"); - return ids; - } - - /** ac=detail 既用于分类也用于详情;具体参数优先于 ac。 */ - static String route(JsonObject input) { - String ac = text(input, "ac"); - if ("config".equals(ac) || "site".equals(ac)) return "config"; - if (!text(input, "wd").isEmpty()) return "search"; - if (input.has("ids")) return "detail"; - if ("play".equals(ac) || !text(input, "play").isEmpty() - || (!text(input, "flag").isEmpty() && !text(input, "id").isEmpty())) return "play"; - return "list"; - } - - private static boolean isConfig(JsonObject input) { - return "config".equals(route(input)); - } - - /** NanoHTTPD 已解码 URI;不要再次解码或将 key 中的 '?'、'/' 当分隔符。 */ - static String siteKeyFromPath(String url) { - return url != null && url.startsWith(PATH + "/") ? url.substring(PATH.length() + 1) : ""; - } - - /** 保留原始分页/筛选元数据,同时用公共字段白名单隔离宿主内部状态。 */ - static JsonObject formatContent(JsonObject raw, int page) { - Result result = new Result(); - List vods = new ArrayList<>(); - List originals = new ArrayList<>(); - JsonElement rawList = raw.get("list"); - if (rawList != null && rawList.isJsonArray()) { - for (JsonElement element : rawList.getAsJsonArray()) { - if (!element.isJsonObject()) continue; - JsonObject item = element.getAsJsonObject().deepCopy(); - item.remove("site"); - item.remove("tmdb"); - Vod vod = GSON.fromJson(item, Vod.class); - if (item.has("flags") && item.get("flags").isJsonArray()) vod.setFlags(Arrays.asList(GSON.fromJson(item.get("flags"), Flag[].class))); - vods.add(vod); - originals.add(item); - } - } - result.setList(vods); - List types = new ArrayList<>(); - JsonElement rawTypes = raw.get("class"); - if (rawTypes != null && rawTypes.isJsonArray()) { - for (JsonElement type : rawTypes.getAsJsonArray()) if (type.isJsonObject()) types.add(GSON.fromJson(type, Class.class)); - } - result.setTypes(types); - JsonObject out = macCms(result, page); - int actualPage = Math.max(1, parseInt(text(raw, "page"), page)); - int pageCount = Math.max(1, parseInt(text(raw, "pagecount"), vods.isEmpty() ? actualPage : DEFAULT_PAGE_COUNT)); - int limit = Math.max(1, parseInt(text(raw, "limit"), vods.isEmpty() ? DEFAULT_LIMIT : vods.size())); - out.addProperty("page", actualPage); - out.addProperty("pagecount", pageCount); - out.addProperty("limit", limit); - long total = (long) pageCount * limit; - try { total = Math.max(0L, Long.parseLong(text(raw, "total"))); } catch (NumberFormatException ignored) { } - out.addProperty("total", total); - for (int i = 0; i < originals.size(); i++) { - JsonObject item = out.getAsJsonArray("list").get(i).getAsJsonObject(); - for (String name : new String[]{"type_id", "vod_tag"}) { - if (originals.get(i).has(name) && originals.get(i).get(name).isJsonPrimitive()) item.add(name, originals.get(i).get(name)); - } - } - if (raw.has("filters") && raw.get("filters").isJsonObject()) out.add("filters", raw.get("filters").deepCopy()); - if (!text(raw, "msg").isEmpty()) out.addProperty("msg", text(raw, "msg")); - return out; - } - - /** 保留 Spider 的解析提示/URL 数组,不假定服务端已经完成嗅探或提取。 */ - static JsonObject playJson(JsonObject raw, String flag, Site site) { - JsonObject out = new JsonObject(); - for (String field : new String[]{"url", "urls", "playUrl", "flag", "parse", "jx", "format", "header", "drm", "subs", "click", "jxFrom", "msg", "danmaku"}) { - if (raw.has(field) && !raw.get(field).isJsonNull()) out.add(field, raw.get(field).deepCopy()); - } - if (!out.has("url")) out.addProperty("url", ""); - if (text(out, "flag").isEmpty()) prop(out, "flag", flag); - if (!out.has("parse")) out.addProperty("parse", 0); - if (!out.has("jx")) out.addProperty("jx", 0); - if (!out.has("header") || (out.get("header").isJsonObject() && out.getAsJsonObject("header").isEmpty())) out.add("header", GSON.toJsonTree(site.getHeader())); - return out; - } - - /** 只改实际 URL 字段;请求头、视频 ID 和签名 query 内嵌字符串不能做全局替换。 */ - static void externalize(JsonObject out, String origin, String key, int port) { - externalize(out, origin, key, "", port); - } - - static void externalize(JsonObject out, String origin, String key, String configKey, int port) { - for (String field : new String[]{"url", "urls", "playUrl", "subs", "drm"}) { - if (out.has(field)) out.add(field, mediaUrls(out.get(field), origin, key, configKey, port)); - } - if (out.has("list") && out.get("list").isJsonArray()) { - for (JsonElement element : out.getAsJsonArray("list")) { - if (!element.isJsonObject()) continue; - JsonObject item = element.getAsJsonObject(); - if (item.has("vod_pic")) item.add("vod_pic", mediaUrls(item.get("vod_pic"), origin, key, configKey, port)); - } - } - } - - private static JsonElement mediaUrls(JsonElement value, String origin, String key, String configKey, int port) { - if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { - return new com.google.gson.JsonPrimitive(remoteUrl(value.getAsString(), origin, key, configKey, port)); - } - if (value.isJsonArray()) { - JsonArray array = new JsonArray(); - for (JsonElement item : value.getAsJsonArray()) array.add(mediaUrls(item, origin, key, configKey, port)); - return array; - } - if (value.isJsonObject()) { - JsonObject object = value.getAsJsonObject(); - for (String field : new String[]{"url", "v", "values", "server"}) { - if (object.has(field)) object.add(field, mediaUrls(object.get(field), origin, key, configKey, port)); - } - } - return value; - } - - static String remoteUrl(String url, String origin, String key, int port) { - return remoteUrl(url, origin, key, "", port); - } - - /** 本网关代理地址回填:/proxy 同时带上 siteKey 与 configKey,非激活配置的代理也能路由回原站点。 */ - static String remoteUrl(String url, String origin, String key, String configKey, int port) { - try { - URI uri = URI.create(url); - String host = uri.getHost(); - if (port <= 0 || uri.getPort() != port || host == null || uri.getRawUserInfo() != null - || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) return url; - if (!loopback(host) && !host.equalsIgnoreCase(URI.create(origin).getHost())) return url; - String path = uri.getRawPath(); - String query = uri.getRawQuery(); - if ("/proxy".equals(path) || path.startsWith("/proxy/")) { - if (!key.isEmpty() && (query == null || !(query.startsWith("siteKey=") || query.contains("&siteKey=")))) { - query = (query == null || query.isEmpty() ? "" : query + "&") + "siteKey=" + encode(key); - } - if (configKey != null && !configKey.isEmpty() && (query == null || !query.contains("configKey="))) { - query = (query == null || query.isEmpty() ? "" : query + "&") + "configKey=" + encode(configKey); - } - } - return origin + path + (query == null ? "" : "?" + query) + (uri.getRawFragment() == null ? "" : "#" + uri.getRawFragment()); - } catch (IllegalArgumentException ignored) { - return url; - } - } - - /** 老式 JAR 的代理在集中 Proxy 类上;强加 siteKey 会绕过 JarLoader 的兼容分派。 */ - static boolean supportsDirectProxy(Spider spider) { - try { - return spider.getClass().getMethod("proxy", Map.class).getDeclaringClass() != Spider.class; - } catch (ReflectiveOperationException e) { - return false; - } - } - - private static boolean loopback(String host) { - return "127.0.0.1".equals(host) || "localhost".equalsIgnoreCase(host) || "[::1]".equals(host) || "::1".equals(host); - } - - static boolean isSelfGateway(String api, String origin, int port) { - try { - URI uri = URI.create(api); - String path = uri.getPath(); - if (uri.getHost() == null || path == null || !(PATH.equals(path) || path.startsWith(PATH + "/"))) return false; - return (loopback(uri.getHost()) && uri.getPort() == port) || uri.getRawAuthority().equalsIgnoreCase(URI.create(origin).getRawAuthority()); - } catch (IllegalArgumentException ignored) { - return false; - } - } - - /** 苹果CMS 列表信封。 */ - static JsonObject macCms(Result result, int page) { - Result safe = result == null ? Result.empty() : result; - JsonArray list = new JsonArray(); - for (Vod vod : safe.getList()) list.add(vodJson(vod)); - int limit = safe.getList().isEmpty() ? DEFAULT_LIMIT : safe.getList().size(); - int pageCount = safe.getPageCount() > 0 ? safe.getPageCount() : safe.getList().isEmpty() ? Math.max(1, page) : DEFAULT_PAGE_COUNT; - JsonObject out = new JsonObject(); - out.addProperty("page", page); - out.addProperty("pagecount", pageCount); - out.addProperty("limit", limit); - out.addProperty("total", (long) pageCount * limit); - out.add("list", list); - JsonArray types = new JsonArray(); - for (Class type : safe.getTypes()) types.add(typeJson(type)); - if (!types.isEmpty()) out.add("class", types); - JsonElement filters = GSON.toJsonTree(safe.getFilters()); - if (filters.isJsonObject() && !filters.getAsJsonObject().isEmpty()) out.add("filters", filters); - return out; - } - - /** 苹果CMS 播放信封:给 T4 客户端一条已解析好的可播放地址。 */ - static JsonObject play(Result result, String flag) { - Result safe = result == null ? Result.empty() : result; - JsonObject out = new JsonObject(); - prop(out, "url", safe.getRealUrl()); - prop(out, "flag", safe.getFlag().isEmpty() ? flag : safe.getFlag()); - out.addProperty("parse", safe.getParse()); - out.addProperty("jx", safe.getJx()); - String format = safe.getFormat(); - if (!TextUtils.isEmpty(format)) prop(out, "format", format); - JsonElement header = GSON.toJsonTree(safe.getHeader()); - if (header.isJsonObject() && !header.getAsJsonObject().isEmpty()) out.add("header", header); - if (safe.getDrm() != null) out.add("drm", GSON.toJsonTree(safe.getDrm())); - if (!safe.getSubs().isEmpty()) out.add("subs", GSON.toJsonTree(safe.getSubs())); - if (safe.getUrl().isMulti()) { - JsonArray urls = new JsonArray(); - for (Value value : safe.getUrl().getValues()) urls.add(value.getV()); - out.add("urls", urls); - } - if (safe.hasMsg()) prop(out, "msg", safe.getMsg()); - return out; - } - - /** 单个视频条目,只输出公开的苹果CMS 字段,不泄漏站点配置等内部状态。 */ - static JsonObject vodJson(Vod vod) { - JsonObject item = new JsonObject(); - prop(item, "vod_id", vod.getId()); - prop(item, "vod_name", vod.getName()); - prop(item, "type_id", ""); - prop(item, "type_name", vod.getTypeName()); - prop(item, "vod_pic", vod.getPic()); - prop(item, "vod_remarks", vod.getRemarks()); - prop(item, "vod_year", vod.getYear()); - prop(item, "vod_area", vod.getArea()); - prop(item, "vod_director", vod.getDirector()); - prop(item, "vod_actor", vod.getActor()); - prop(item, "vod_content", vod.getContent()); - prop(item, "vod_play_from", playFrom(vod)); - prop(item, "vod_play_url", playUrl(vod)); - String action = vod.getAction(); - if (!TextUtils.isEmpty(action)) prop(item, "action", action); - return item; - } - - static JsonObject typeJson(Class type) { - JsonObject item = new JsonObject(); - prop(item, "type_id", type.getTypeId()); - prop(item, "type_name", type.getTypeName()); - if (!type.getTypeFlag().isEmpty()) prop(item, "type_flag", type.getTypeFlag()); - return item; - } - - /** 字段可能来自爬虫的原始 JSON,缺失时是 null;统一转成空串,避免写响应时 NPE。 */ - private static void prop(JsonObject target, String key, String value) { - target.addProperty(key, value == null ? "" : value); - } - - /** - * 线路名。T3 爬虫常只回 {@code flags} 数组而不回 {@code vod_play_from}, - * 直接透传会让 T4 客户端看到空线路,所以这里按苹果CMS 的 {@code $$$} 规则回填。 - */ - static String playFrom(Vod vod) { - String raw = vod.getPlayFrom(); - if (!raw.isEmpty()) return raw; - List flags = new ArrayList<>(); - for (Flag flag : vod.getFlags()) if (flag != null) flags.add(flag.getFlag()); - return String.join("$$$", flags); - } - - /** 播放地址,回填规则同 {@link #playFrom(Vod)},集与集之间用 {@code #}。 */ - static String playUrl(Vod vod) { - String raw = vod.getPlayUrl(); - if (!raw.isEmpty()) return raw; - List groups = new ArrayList<>(); - for (Flag flag : vod.getFlags()) { - if (flag == null) continue; - if (flag.getEpisodes() == null || flag.getEpisodes().isEmpty()) { - groups.add(flag.getUrls()); - continue; - } - List episodes = new ArrayList<>(); - for (Episode episode : flag.getEpisodes()) if (episode != null) episodes.add(episode.getName() + "$" + episode.getUrl()); - groups.add(String.join("#", episodes)); - } - return String.join("$$$", groups); - } - - /** T4 配置:站点 {@code api} 指回本网关,因此整条链路不再依赖外部脚本。 */ - static JsonObject configJson(List sites, String baseUrl, List doh, List rules) { - return configJson(sites, baseUrl, doh, rules, ""); - } - - /** - * 同上,但把配置标识写进每个站点 api:网关据此服务非激活配置, - * 客户端后续的 /proxy 请求也能带着它回到同一条配置。 - */ - static JsonObject configJson(List sites, String baseUrl, List doh, List rules, String configKey) { - String suffix = configKey == null || configKey.isEmpty() ? "" : "&configKey=" + encode(configKey); - JsonObject config = new JsonObject(); - config.addProperty("spider", ""); - config.addProperty("wallpaper", ""); - config.addProperty("warningText", WARNING); - JsonArray array = new JsonArray(); - if (sites != null) { - for (Site site : sites) { - if (site == null || site.getKey().isEmpty()) continue; - JsonObject item = new JsonObject(); - item.addProperty("key", site.getKey()); - item.addProperty("name", site.getName()); - item.addProperty("api", baseUrl + "?key=" + encode(site.getKey()) + suffix); - item.addProperty("type", "4"); - item.addProperty("searchable", site.isSearchable() ? 1 : 0); - item.addProperty("quickSearch", site.isQuickSearch() ? 1 : 0); - item.addProperty("filterable", 1); - array.add(item); - } - } - config.add("sites", array); - config.add("doh", doh == null ? new JsonArray() : GSON.toJsonTree(doh)); - config.add("rules", rules == null ? new JsonArray() : GSON.toJsonTree(rules)); - config.add("lives", new JsonArray()); - return config; - } - - /** 筛选参数:base64 {@code ext} → JSON {@code extend} → 显式字段,后者覆盖前者。 */ - static HashMap extendFrom(JsonObject input) { - HashMap extend = new HashMap<>(); - putAll(extend, decodeExtend(text(input, "ext"))); - JsonElement raw = input.get("extend"); - putAll(extend, raw != null && raw.isJsonObject() ? raw.getAsJsonObject() : jsonObject(text(input, "extend"))); - for (String field : FIELDS) { - String value = text(input, field); - if (!value.isEmpty()) extend.put(field, value); - } - return extend; - } - - private static void putAll(HashMap target, JsonObject source) { - if (source == null) return; - for (String key : source.keySet()) { - JsonElement value = source.get(key); - if (value != null && value.isJsonPrimitive()) target.put(key, value.getAsString()); - } - } - - /** TVBox 的 T4 约定:URL-safe base64 的 JSON。 */ - static JsonObject decodeExtend(String ext) { - if (TextUtils.isEmpty(ext)) return null; - try { - ByteString decoded = ByteString.decodeBase64(ext); - return decoded == null ? null : jsonObject(decoded.utf8()); - } catch (Throwable e) { - return null; - } - } - - static JsonObject jsonObject(String json) { - if (TextUtils.isEmpty(json)) return null; - try { - JsonElement element = JsonParser.parseString(json); - return element != null && element.isJsonObject() ? element.getAsJsonObject() : null; - } catch (Throwable e) { - return null; - } - } - - private static JsonObject input(IHTTPSession session, Map files) { - JsonObject input = new JsonObject(); - for (Map.Entry entry : session.getParms().entrySet()) input.addProperty(entry.getKey(), entry.getValue()); - String post = files == null ? null : files.get("postData"); - JsonObject body = jsonObject(post); - if (post != null && !post.isEmpty() && body == null) throw new IllegalArgumentException("POST body 必须是 JSON 对象"); - if (body != null) for (String key : body.keySet()) input.add(key, body.get(key)); - return input; - } - - private static String origin(IHTTPSession session) { - String host = session.getHeaders().get("host"); - if (TextUtils.isEmpty(host)) host = "127.0.0.1:" + com.github.catvod.Proxy.getPort(); - URI uri = URI.create("http://" + host); - if (uri.getHost() == null || uri.getRawUserInfo() != null || !uri.getRawPath().isEmpty() - || uri.getRawQuery() != null || uri.getRawFragment() != null) throw new IllegalArgumentException("无效的 Host"); - return "http://" + host; - } - - private static String encode(String value) { - try { - return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); - } catch (java.io.UnsupportedEncodingException impossible) { - throw new IllegalStateException(impossible); - } - } - - static String text(JsonObject input, String key) { - if (input == null || !input.has(key)) return ""; - JsonElement element = input.get(key); - if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) return ""; - return element.getAsString(); - } - - /** {@code ids} 允许逗号分隔或 JSON 数组(POST body)。 */ - static String csv(JsonObject input, String key) { - if (input == null || !input.has(key)) return ""; - JsonElement element = input.get(key); - if (element == null || element.isJsonNull()) return ""; - if (element.isJsonArray()) { - StringBuilder builder = new StringBuilder(); - for (JsonElement item : element.getAsJsonArray()) { - if (item == null || item.isJsonNull()) continue; - if (builder.length() > 0) builder.append(","); - builder.append(item.getAsString()); - } - return builder.toString(); - } - return element.isJsonPrimitive() ? element.getAsString() : ""; - } - - private static boolean truthy(String value) { - return "1".equals(value) || "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value); - } - - private static int parseInt(String value, int fallback) { - try { - return Integer.parseInt(value.trim()); - } catch (Exception e) { - return fallback; - } - } - - static JsonObject error(String msg) { - JsonObject result = new JsonObject(); - result.addProperty("code", -1); - result.addProperty("msg", msg == null ? "" : msg); - return result; - } - - private static Response json(JsonObject object) { - return cors(NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", object.toString())); - } - - private static Response cors(Response response) { - response.addHeader("Access-Control-Allow-Origin", "*"); - response.addHeader("Access-Control-Allow-Methods", "GET, POST, HEAD, OPTIONS"); - response.addHeader("Access-Control-Allow-Headers", "Content-Type"); - return response; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/service/AiTitleExtractionService.java b/app/src/main/java/com/fongmi/android/tv/service/AiTitleExtractionService.java index 22cf941867..88cbf153f4 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/AiTitleExtractionService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/AiTitleExtractionService.java @@ -28,8 +28,6 @@ public final class AiTitleExtractionService { private static final int READ_TIMEOUT_SECONDS = 25; private static final int CALL_TIMEOUT_SECONDS = 35; private static final int MAX_LEARNING_EXAMPLES = 5; - private static final int MAX_REASONABLE_SEASON = 100; - private static final int MAX_REASONABLE_EPISODE = 10000; private final AiConfig config; @@ -78,16 +76,9 @@ public final class AiTitleExtractionService { input.addProperty("ruleTitle", rule.getRuleTitle()); input.addProperty("rawRemarks", request.getRawRemarks()); input.addProperty("episodeName", request.getEpisodeName()); - input.addProperty("folderName", request.getFolderName()); - JsonArray contextTitles = new JsonArray(); - for (String title : request.getContextTitles()) contextTitles.add(title); - input.add("contextTitles", contextTitles); input.addProperty("year", rule.getYear()); input.addProperty("seasonNumber", rule.getSeasonNumber()); input.addProperty("episodeNumber", rule.getEpisodeNumber()); - input.addProperty("ruleSource", rule.getRuleSource()); - input.addProperty("contextConfidence", rule.getContextConfidence()); - input.addProperty("contextGroupSize", rule.getContextGroupSize()); JsonArray examples = new JsonArray(); List learning = request.getLearningExamples(); @@ -120,21 +111,19 @@ public final class AiTitleExtractionService { JsonObject object = element.getAsJsonObject(); MediaTitleParser parser = new MediaTitleParser(); String rawTitle = firstString(object, "canonicalTitle", "title", "name"); - if (!isUsableAiTitle(rawTitle) || containsNoise(rawTitle)) return null; + if (containsNoise(rawTitle)) return null; String title = parser.cleanTitle(rawTitle); - if (!isUsableAiTitle(title) || containsNoise(title)) return null; + if (title.isEmpty() || containsNoise(title)) return null; MediaTitleResolution result = copyRule(fallback); result.setCanonicalTitle(title); result.setOriginalTitle(firstString(object, "originalTitle", "originalName")); - String mediaType = firstString(object, "mediaType", "type").toLowerCase(Locale.ROOT); - if ("tv".equals(mediaType) || "movie".equals(mediaType)) result.setMediaType(mediaType); - result.setYear(firstValidInt(object, fallback.getYear(), 1900, 2099, "year", "releaseYear")); - result.setSeasonNumber(firstValidInt(object, fallback.getSeasonNumber(), 1, MAX_REASONABLE_SEASON, "seasonNumber", "season")); - result.setEpisodeNumber(firstValidInt(object, fallback.getEpisodeNumber(), 1, MAX_REASONABLE_EPISODE, "episodeNumber", "episode")); + result.setMediaType(firstString(object, "mediaType", "type")); + result.setYear(firstInt(object, "year", "releaseYear")); + result.setSeasonNumber(firstInt(object, "seasonNumber", "season")); + result.setEpisodeNumber(firstInt(object, "episodeNumber", "episode")); result.setEpisodeTitle(parser.cleanTitle(firstString(object, "episodeTitle"))); - result.setConfidence(confidence(object, fallback)); + result.setConfidence((float) firstDouble(object, "confidence", "score")); result.setSource(MediaTitleResolution.SOURCE_AI); - result.setAiReasonCode(firstString(object, "reasonCode", "reason")); result.setNeedsVerification(!normalize(title).equals(normalize(fallback.getRuleTitle())) && result.getConfidence() < 0.9f); result.addCandidate(MediaTitleCandidate.of(title, MediaTitleCandidate.SOURCE_AI, result.getConfidence())); for (JsonElement alias : array(object, "aliases")) { @@ -166,12 +155,6 @@ public final class AiTitleExtractionService { result.setEpisodeTitle(source.getEpisodeTitle()); result.setConfidence(source.getConfidence()); result.setSource(source.getSource()); - result.setAiReasonCode(source.getAiReasonCode()); - result.setContextConfidence(source.getContextConfidence()); - result.setContextGroupSize(source.getContextGroupSize()); - result.setRuleSource(source.getRuleSource()); - result.setConfidenceBreakdown(source.getConfidenceBreakdown()); - result.setDegradationReason(source.getDegradationReason()); result.setNeedsVerification(source.isNeedsVerification()); for (String alias : source.getAliases()) result.addAlias(alias); for (MediaTitleCandidate candidate : source.getCandidates()) result.addCandidate(candidate); @@ -203,38 +186,24 @@ public final class AiTitleExtractionService { return Objects.toString(object.get(key).getAsString(), "").trim(); } - private static int firstValidInt(JsonObject object, int fallback, int min, int max, String... keys) { + private static int firstInt(JsonObject object, String... keys) { for (String key : keys) { try { - if (object == null || !object.has(key) || object.get(key).isJsonNull()) continue; - int value = object.get(key).getAsInt(); - if (value >= min && value <= max) return value; + if (object != null && object.has(key) && !object.get(key).isJsonNull()) return object.get(key).getAsInt(); } catch (Throwable ignored) { } } - return fallback; + return -1; } - private static float confidence(JsonObject object, MediaTitleResolution fallback) { - for (String key : new String[]{"confidence", "score"}) { + private static double firstDouble(JsonObject object, String... keys) { + for (String key : keys) { try { - if (object == null || !object.has(key) || object.get(key).isJsonNull()) continue; - double value = object.get(key).getAsDouble(); - if (Double.isNaN(value) || Double.isInfinite(value) || value < 0d || value > 1d) return 0f; - return (float) value; + if (object != null && object.has(key) && !object.get(key).isJsonNull()) return object.get(key).getAsDouble(); } catch (Throwable ignored) { - return 0f; } } - return Math.max(0.65f, fallback == null ? 0f : fallback.getConfidence()); - } - - private static boolean isUsableAiTitle(String text) { - String value = Objects.toString(text, "").trim(); - if (value.isEmpty() || value.length() > 80 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) return false; - if (value.matches(".*https?://.*") || value.matches(".*[\\\\/].*")) return false; - if (!value.matches(".*[\\u4e00-\\u9fffA-Za-z].*")) return false; - return !value.matches("(?i)^(更新至|更至|连载至|全集|合集|完结|电影|电视剧|剧集|动漫|动画|综艺|tv)$"); + return 0.0; } private static String extractJson(String text) { @@ -266,4 +235,7 @@ public final class AiTitleExtractionService { return text == null ? "" : text.replaceAll("[\\s·•::\\-_/\\\\|()()\\[\\]【】]+", "").trim().toLowerCase(Locale.ROOT); } + private static boolean isBlank(String text) { + return text == null || text.trim().isEmpty(); + } } diff --git a/app/src/main/java/com/fongmi/android/tv/service/DriveCheckService.java b/app/src/main/java/com/fongmi/android/tv/service/DriveCheckService.java index 728efc55b5..c5f39f21fb 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/DriveCheckService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/DriveCheckService.java @@ -151,7 +151,7 @@ public class DriveCheckService { if (diskType.isEmpty() || item.getUrl().isEmpty()) return result(diskType, item.getUrl(), normalized, STATE_UNCERTAIN, false, "type 和 url 不能为空"); if (normalized.isEmpty()) return result(diskType, item.getUrl(), "", STATE_UNCERTAIN, false, "链接格式无效"); - String key = cacheKey(diskType, normalized); + String key = diskType + "|" + normalized; DriveCheckResult cached = getCached(key); if (cached != null) { SpiderDebug.log("pan-check", "cache type=%s state=%s url=%s", diskType, cached.getState(), normalized); @@ -449,9 +449,13 @@ public class DriveCheckService { JsonObject data = obj(object, "data"); JsonObject shareInfo = obj(data, "shareinfo"); if (state && errno == 0) { - String shareState = classify115ShareState(data); - String summary = STATE_OK.equals(shareState) ? "链接有效" : coalesce(str(shareInfo, "forbid_reason"), "链接状态异常"); - return result(item, normalized, shareState, false, summary); + int size = data.has("list") && data.get("list").isJsonArray() ? data.getAsJsonArray("list").size() : 0; + if (size > 0 || integer(data, "count", 0) > 0 || !str(shareInfo, "snap_id").isEmpty() || !str(shareInfo, "share_title").isEmpty()) return result(item, normalized, STATE_OK, false, "链接有效"); + int shareState = integer(data, "share_state", integer(shareInfo, "share_state", 0)); + if (shareState == 1) return result(item, normalized, STATE_OK, false, "链接有效"); + String reason = coalesce(str(shareInfo, "forbid_reason"), "链接状态异常"); + if (containsAny(reason.toLowerCase(Locale.ROOT), "密码", "提取码")) return result(item, normalized, STATE_LOCKED, false, reason); + return result(item, normalized, STATE_BAD, false, reason); } String error = str(object, "error"); if (containsAny(error.toLowerCase(Locale.ROOT), "密码", "提取码", "receive_code")) return result(item, normalized, STATE_LOCKED, false, coalesce(error, "需要提取码")); @@ -459,19 +463,6 @@ public class DriveCheckService { return result(item, normalized, error.isEmpty() ? STATE_UNCERTAIN : STATE_BAD, false, coalesce(error, "无法确认链接状态")); } - static String classify115ShareState(JsonObject data) { - JsonObject shareInfo = obj(data, "shareinfo"); - int shareState = integer(data, "share_state", integer(shareInfo, "share_state", 0)); - String reason = str(shareInfo, "forbid_reason"); - // Expired or forbidden shares may still include a file list and share metadata. - if (shareState == 7) return STATE_BAD; - if (!reason.isEmpty()) return containsAny(reason.toLowerCase(Locale.ROOT), "密码", "提取码") ? STATE_LOCKED : STATE_BAD; - if (shareState == 1) return STATE_OK; - int size = data.has("list") && data.get("list").isJsonArray() ? data.getAsJsonArray("list").size() : 0; - if (size > 0 || integer(data, "count", 0) > 0 || !str(shareInfo, "snap_id").isEmpty() || !str(shareInfo, "share_title").isEmpty()) return STATE_OK; - return STATE_BAD; - } - private DriveCheckResult checkMobile(DriveCheckItem item, String normalized) throws Exception { String shareId = extractMobileShareID(normalized); if (shareId.isEmpty()) return result(item, normalized, STATE_UNCERTAIN, false, "无法解析分享地址"); @@ -564,11 +555,6 @@ public class DriveCheckService { } } - static String cacheKey(String diskType, String normalized) { - // Do not reuse 115 results cached before explicit share-state checks took priority. - return ("115".equals(diskType) ? "115:v2" : diskType) + "|" + normalized; - } - private DriveCheckResult getCached(String key) { DriveCheckResult result = memory.get(key); if (result == null) return null; diff --git a/app/src/main/java/com/fongmi/android/tv/service/IntroSkipService.java b/app/src/main/java/com/fongmi/android/tv/service/IntroSkipService.java index 7d0ef52d4e..325e193c7d 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/IntroSkipService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/IntroSkipService.java @@ -1,7 +1,5 @@ package com.fongmi.android.tv.service; -import android.os.SystemClock; - import androidx.annotation.NonNull; import com.fongmi.android.tv.utils.Task; @@ -35,178 +33,64 @@ public class IntroSkipService { private static final String THE_INTRO_DB_MEDIA = "https://api.theintrodb.org/v3/media"; private static final long TIMEOUT_MS = TimeUnit.SECONDS.toMillis(5); private static final int MAX_CACHE = 128; - /** 参考时长与本集时长的差值超过该值才做时间轴折算,避免为几百毫秒的抖动挪动片尾。 */ - private static final long TIME_BASE_TOLERANCE_MS = 2000; - /** 片尾结束点落在参考结尾的这个范围内,视为「一直放到文件结束」。 */ - private static final long OPEN_END_TOLERANCE_MS = 5000; - /** 参考时长与本集时长的最大容许差。超过即认为不是同一版本,放弃折算。 */ - private static final long MAX_TIME_BASE_DRIFT_MS = TimeUnit.MINUTES.toMillis(5); - /** 起点差在此范围内视为两家在说同一段。 */ - private static final long SAME_START_TOLERANCE_MS = 3000; - /** 外部时间轴超过一周即视为异常输入,不进入播放器。 */ - private static final long MAX_MEDIA_TIME_MS = TimeUnit.DAYS.toMillis(7); - private static final int MAX_SUBMISSION_COUNT = 1_000_000; - /** - * 下集预告的字段名。两家文档都取不到(域名被网络策略拦),这里按常见拼法都试一遍; - * 命中不了只是没有预告段,不影响其余三类。实机日志确认字段名后可收敛成一个。 - */ - private static final String[] PREVIEW_KEYS = {"preview", "next_episode", "next_preview", "trailer"}; - /** - * 缓存未折算的原始段,键只含剧集身份、不含时长。 - * - *

时长不是查询条件(IntroDB 压根不收,TheIntroDB 可选),只是解读结果的参数,所以不该 - * 进键:进了键就意味着同一集在 onPrepare(时长还是 0)和 STATE_READY(时长已知)会各发 - * 一轮请求,HLS 时长抖动 1 秒也会穿透缓存,更没法预载还没开播、时长未知的邻集。 - */ - private static final Map CACHE = Collections.synchronizedMap(new LinkedHashMap<>(32, 0.75f, true) { + private static final Map CACHE = Collections.synchronizedMap(new LinkedHashMap<>(32, 0.75f, true) { @Override - protected boolean removeEldestEntry(Map.Entry eldest) { + protected boolean removeEldestEntry(Map.Entry eldest) { return size() > MAX_CACHE; } }); - /** 固定数量的锁条带,避免按外部剧集 key 建无限增长的锁表。 */ - private static final Object[] CACHE_LOCKS = createCacheLocks(); - - private static Object[] createCacheLocks() { - Object[] locks = new Object[32]; - for (int i = 0; i < locks.length; i++) locks[i] = new Object(); - return locks; - } public IntroSkipPlan load(@NonNull Query query) { - return loadResult(query).getPlan(); - } - - /** 只把结果灌进缓存,不折算——用于预热邻集,此时时长还无从得知。 */ - public void preload(@NonNull Query query) { - loadResult(query); - } - - /** 返回计划以及本次是否完整,供播放层决定是否允许抑制后续重试。 */ - public LoadResult loadResult(@NonNull Query query) { - if (!query.hasLookupKey()) return new LoadResult(IntroSkipPlan.empty(), true); + if (!query.hasLookupKey()) return IntroSkipPlan.empty(); String key = query.cacheKey(); - Object lock = CACHE_LOCKS[key.hashCode() & (CACHE_LOCKS.length - 1)]; - synchronized (lock) { - CacheEntry cached = CACHE.get(key); - if (cached != null && canUseCachedResponse(cached.durationMs, query.durationMs)) { - IntroSkipPlan plan = IntroSkipPlan.from(cached.segments, query.durationMs); - SpiderDebug.log("intro-skip", "cache hit key=%s segments=%d cachedDurationMs=%d durationMs=%d", key, cached.segments.size(), cached.durationMs, query.durationMs); - return new LoadResult(plan, true); - } - if (cached != null) SpiderDebug.log("intro-skip", "cache refresh key=%s cachedDurationMs=%d durationMs=%d", key, cached.durationMs, query.durationMs); - SpiderDebug.log("intro-skip", "query start tmdbId=%d imdbId=%s mediaType=%s season=%d episode=%d durationMs=%d", query.tmdbId, query.imdbId, query.mediaType, query.season, query.episode, query.durationMs); - RemoteResult result = loadRemote(query); - // 部分 provider 失败时不写入缓存,下一次请求仍能重试失败的一方。 - if (result.cacheable && (cached == null || shouldReplaceCachedResponse(cached.durationMs, query.durationMs))) { - CACHE.put(key, new CacheEntry(result.segments, query.durationMs)); - } - IntroSkipPlan plan = IntroSkipPlan.from(result.segments, query.durationMs); - SpiderDebug.log("intro-skip", "query done key=%s segments=%d cacheable=%s durationMs=%d", key, result.segments.size(), result.cacheable, query.durationMs); - return new LoadResult(plan, result.cacheable); + IntroSkipPlan cached = CACHE.get(key); + if (cached != null) { + SpiderDebug.log("intro-skip", "cache hit key=%s", key); + return cached; } + SpiderDebug.log("intro-skip", "query start tmdbId=%d imdbId=%s mediaType=%s season=%d episode=%d durationMs=%d", query.tmdbId, query.imdbId, query.mediaType, query.season, query.episode, query.durationMs); + IntroSkipPlan plan = loadRemote(query); + CACHE.put(key, plan); + SpiderDebug.log("intro-skip", "query done openings=%d endings=%d", plan.getOpenings().size(), plan.getEndings().size()); + return plan; } - public static final class LoadResult { - - private final IntroSkipPlan plan; - private final boolean cacheable; - - private LoadResult(IntroSkipPlan plan, boolean cacheable) { - this.plan = plan == null ? IntroSkipPlan.empty() : plan; - this.cacheable = cacheable; - } - - public IntroSkipPlan getPlan() { - return plan; - } - - public boolean isCacheable() { - return cacheable; - } - } - - /** 一次远端查询的结果;cacheable=false 表示至少一家 provider 没有可靠完成。 */ - private static final class RemoteResult { - - private final List segments; - private final boolean cacheable; - - private RemoteResult(List segments, boolean cacheable) { - this.segments = segments == null ? Collections.emptyList() : segments; - this.cacheable = cacheable; - } - } - - private static final class CacheEntry { - - private final List segments; - private final long durationMs; - - private CacheEntry(List segments, long durationMs) { - this.segments = segments == null ? Collections.emptyList() : segments; - this.durationMs = Math.max(0, durationMs); - } - } - - private RemoteResult loadRemote(Query query) { - List>> futures = new ArrayList<>(); - ExecutorCompletionService> completion = new ExecutorCompletionService<>(Task.largeExecutor()); + private IntroSkipPlan loadRemote(Query query) { + List> futures = new ArrayList<>(); + ExecutorCompletionService completion = new ExecutorCompletionService<>(Task.largeExecutor()); if (!isEmpty(query.imdbId) && query.season > 0 && query.episode > 0) { futures.add(completion.submit(() -> fetchIntroDb(query))); } if (query.tmdbId > 0) { futures.add(completion.submit(() -> fetchTheIntroDb(query))); } - // 没有可用的查询条件不是失败,是确定的「查不了」,可以缓存以免反复进来 - if (futures.isEmpty()) return new RemoteResult(Collections.emptyList(), true); + if (futures.isEmpty()) return IntroSkipPlan.empty(); - long deadline = SystemClock.elapsedRealtime() + TIMEOUT_MS; - List segments = new ArrayList<>(); - int completed = 0; - boolean failed = false; + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + List plans = new ArrayList<>(); for (int i = 0; i < futures.size(); i++) { - long waitMs = deadline - SystemClock.elapsedRealtime(); - if (waitMs <= 0) { - failed = true; - break; - } + long waitMs = Math.max(1, deadline - System.currentTimeMillis()); try { - Future> future = completion.poll(waitMs, TimeUnit.MILLISECONDS); - if (future == null) { - failed = true; // 超时,剩下的也别等了 - break; - } - List part = future.get(); - completed++; - if (part == null) { - failed = true; // 该家失败 - continue; - } - segments.addAll(part); + Future future = completion.poll(waitMs, TimeUnit.MILLISECONDS); + if (future != null) plans.add(future.get()); } catch (Throwable e) { - failed = true; SpiderDebug.log("intro-skip", "provider failed error=%s", e.getMessage()); } } - for (Future> future : futures) if (!future.isDone()) future.cancel(true); - return new RemoteResult(segments, isCacheableResponse(futures.size(), completed, failed)); + for (Future future : futures) if (!future.isDone()) future.cancel(true); + return IntroSkipPlan.merge(plans); } - private List fetchIntroDb(Query query) { + private IntroSkipPlan fetchIntroDb(Query query) { HttpUrl url = HttpUrl.parse(INTRO_DB_SEGMENTS).newBuilder() .addQueryParameter("imdb_id", query.imdbId) .addQueryParameter("season", String.valueOf(query.season)) .addQueryParameter("episode", String.valueOf(query.episode)) .build(); - return fetch(url, PROVIDER_INTRO_DB); + return fetch(url, PROVIDER_INTRO_DB, query.durationMs); } - /** - * duration_ms 有就带、没有就不带:带上它服务端能挑更贴近本地片源的那一版标定, - * 预载时(还没开播、时长未知)省掉即可,不因此放弃预载。 - */ - private List fetchTheIntroDb(Query query) { + private IntroSkipPlan fetchTheIntroDb(Query query) { HttpUrl.Builder builder = HttpUrl.parse(THE_INTRO_DB_MEDIA).newBuilder() .addQueryParameter("tmdb_id", String.valueOf(query.tmdbId)); if (query.isTv() && query.season > 0 && query.episode > 0) { @@ -214,82 +98,48 @@ public class IntroSkipService { builder.addQueryParameter("episode", String.valueOf(query.episode)); } if (query.durationMs > 0) builder.addQueryParameter("duration_ms", String.valueOf(query.durationMs)); - return fetch(builder.build(), PROVIDER_THE_INTRO_DB); + return fetch(builder.build(), PROVIDER_THE_INTRO_DB, query.durationMs); } - /** @return null 表示这家没答上来(超时/报错/非 2xx),调用方据此决定要不要缓存。 */ - private List fetch(HttpUrl url, String provider) { - long start = SystemClock.elapsedRealtime(); + private IntroSkipPlan fetch(HttpUrl url, String provider, long durationMs) { + long start = System.currentTimeMillis(); SpiderDebug.log("intro-skip", "%s request url=%s", provider, url.toString()); Request request = new Request.Builder().url(url).get().build(); try (Response response = OkHttp.client(TIMEOUT_MS).newCall(request).execute()) { if (response.body() == null || !response.isSuccessful()) { SpiderDebug.log("intro-skip", "%s http=%d empty=%s url=%s", provider, response.code(), response.body() == null, url.toString()); - // 404 是明确的「这一集没有数据」,可以缓存;其余状态码都可能是暂时的 - return response.code() == 404 ? Collections.emptyList() : null; + return IntroSkipPlan.empty(); } String body = response.body().string(); - if (parseObject(body) == null) { - SpiderDebug.log("intro-skip", "%s invalid json url=%s", provider, url.toString()); - return null; - } - List raw = PROVIDER_INTRO_DB.equals(provider) ? parseIntroDbRaw(body) : parseTheIntroDbRaw(body); - SpiderDebug.log("intro-skip", "%s loaded segments=%d cost=%dms url=%s", provider, raw.size(), SystemClock.elapsedRealtime() - start, url.toString()); - return raw; + IntroSkipPlan plan = PROVIDER_INTRO_DB.equals(provider) + ? parseIntroDb(body, durationMs) + : parseTheIntroDb(body, durationMs); + SpiderDebug.log("intro-skip", "%s loaded openings=%d endings=%d cost=%dms url=%s", provider, plan.getOpenings().size(), plan.getEndings().size(), System.currentTimeMillis() - start, url.toString()); + return plan; } catch (Throwable e) { - SpiderDebug.log("intro-skip", "%s failed error=%s cost=%dms url=%s", provider, e.getMessage(), SystemClock.elapsedRealtime() - start, url.toString()); - return null; + SpiderDebug.log("intro-skip", "%s failed error=%s cost=%dms url=%s", provider, e.getMessage(), System.currentTimeMillis() - start, url.toString()); + return IntroSkipPlan.empty(); } } public static IntroSkipPlan parseIntroDb(String body, long durationMs) { - return IntroSkipPlan.from(parseIntroDbRaw(body), durationMs); + JsonObject object = parseObject(body); + if (object == null) return IntroSkipPlan.empty(); + List segments = new ArrayList<>(); + addIntroDbSegment(segments, object, "recap", Segment.Kind.RECAP, durationMs); + addIntroDbSegment(segments, object, "intro", Segment.Kind.INTRO, durationMs); + addIntroDbSegment(segments, object, "outro", Segment.Kind.OUTRO, durationMs); + return IntroSkipPlan.from(segments); } public static IntroSkipPlan parseTheIntroDb(String body, long durationMs) { - return IntroSkipPlan.from(parseTheIntroDbRaw(body), durationMs); - } - - static List parseIntroDbRaw(String body) { JsonObject object = parseObject(body); - if (object == null) return Collections.emptyList(); - long reference = referenceDuration(object); - List segments = new ArrayList<>(); - addIntroDbSegment(segments, object, "recap", Segment.Kind.RECAP, reference); - addIntroDbSegment(segments, object, "intro", Segment.Kind.INTRO, reference); - addIntroDbSegment(segments, object, "outro", Segment.Kind.OUTRO, reference); - for (String key : PREVIEW_KEYS) addIntroDbSegment(segments, object, key, Segment.Kind.PREVIEW, reference); - return segments; - } - - static List parseTheIntroDbRaw(String body) { - JsonObject object = parseObject(body); - if (object == null) return Collections.emptyList(); - long reference = referenceDuration(object); - List segments = new ArrayList<>(); - addTheIntroDbSegments(segments, object, "recap", Segment.Kind.RECAP, reference); - addTheIntroDbSegments(segments, object, "intro", Segment.Kind.INTRO, reference); - addTheIntroDbSegments(segments, object, "credits", Segment.Kind.OUTRO, reference); - for (String key : PREVIEW_KEYS) addTheIntroDbSegments(segments, object, key, Segment.Kind.PREVIEW, reference); - return segments; - } - - /** - * 数据源标定所用版本的总时长,用于把尾部段折算到本地片源的时间轴上。 - * - *

字段名两家不统一、也可能整个缺失(返回 0 表示无从折算)。刻意不认裸 {@code duration} - * 之类无单位后缀的键:TMDB 系接口用它表示「分钟」,当成秒会算出天量偏移,把所有尾部段 - * 静默抹掉,比拿不到基准更糟。解析时还不知道本集时长,量级校验推迟到折算时做, - * 见 {@link Segment#plausibleReference}。 - */ - private static long referenceDuration(JsonObject root) { - Long ms = longValue(root, "duration_ms"); - if (ms == null) ms = longValue(root, "runtime_ms"); - if (ms != null && ms > 0) return ms; - Double sec = doubleValue(root, "duration_sec"); - if (sec == null) sec = doubleValue(root, "runtime_sec"); - Long millis = secondsToMillis(sec); - return millis == null ? 0 : millis; + if (object == null) return IntroSkipPlan.empty(); + List segments = new ArrayList<>(); + addTheIntroDbSegments(segments, object, "recap", Segment.Kind.RECAP, durationMs); + addTheIntroDbSegments(segments, object, "intro", Segment.Kind.INTRO, durationMs); + addTheIntroDbSegments(segments, object, "credits", Segment.Kind.OUTRO, durationMs); + return IntroSkipPlan.from(segments); } private static JsonObject parseObject(String body) { @@ -301,23 +151,24 @@ public class IntroSkipService { } } - private static void addIntroDbSegment(List segments, JsonObject root, String key, Segment.Kind kind, long referenceDurationMs) { + private static void addIntroDbSegment(List segments, JsonObject root, String key, Segment.Kind kind, long durationMs) { JsonObject object = object(root, key); if (object == null) return; Long start = millis(object, "start_ms", "start_sec"); Long end = millis(object, "end_ms", "end_sec"); - segments.add(new RawSegment(kind, PROVIDER_INTRO_DB, key, start, end, referenceDurationMs, number(object, "confidence", 0.5), integer(object, "submission_count", 0))); + Segment segment = Segment.create(kind, PROVIDER_INTRO_DB, start, end, durationMs, number(object, "confidence", 0.5), integer(object, "submission_count", 0)); + if (segment != null) segments.add(segment); } - private static void addTheIntroDbSegments(List segments, JsonObject root, String key, Segment.Kind kind, long referenceDurationMs) { + private static void addTheIntroDbSegments(List segments, JsonObject root, String key, Segment.Kind kind, long durationMs) { JsonArray array = array(root, key); - for (int index = 0; index < array.size(); index++) { - JsonElement element = array.get(index); + for (JsonElement element : array) { if (!element.isJsonObject()) continue; JsonObject object = element.getAsJsonObject(); Long start = millis(object, "start_ms", null); Long end = millis(object, "end_ms", null); - segments.add(new RawSegment(kind, PROVIDER_THE_INTRO_DB, key + "#" + index, start, end, referenceDurationMs, 0.5, 0)); + Segment segment = Segment.create(kind, PROVIDER_THE_INTRO_DB, start, end, durationMs, 0.5, 0); + if (segment != null) segments.add(segment); } } @@ -336,26 +187,18 @@ public class IntroSkipService { if (ms != null) return ms; if (secKey == null) return null; Double sec = doubleValue(object, secKey); - return secondsToMillis(sec); + return sec == null ? null : Math.round(sec * 1000.0); } private static Long longValue(JsonObject object, String key) { Double value = doubleValue(object, key); - if (value == null || value < 0 || value > MAX_MEDIA_TIME_MS) return null; - return Math.round(value); - } - - private static Long secondsToMillis(Double seconds) { - if (seconds == null || seconds < 0 || seconds > MAX_MEDIA_TIME_MS / 1000.0) return null; - double millis = seconds * 1000.0; - return Double.isFinite(millis) && millis <= MAX_MEDIA_TIME_MS ? Math.round(millis) : null; + return value == null ? null : Math.round(value); } private static Double doubleValue(JsonObject object, String key) { try { if (object == null || key == null || !object.has(key) || object.get(key).isJsonNull()) return null; - double value = object.get(key).getAsDouble(); - return Double.isFinite(value) ? value : null; + return object.get(key).getAsDouble(); } catch (Throwable e) { return null; } @@ -363,40 +206,18 @@ public class IntroSkipService { private static double number(JsonObject object, String key, double fallback) { Double value = doubleValue(object, key); - return clampConfidence(value == null ? fallback : value); + return value == null ? fallback : value; } - static double clampConfidence(double value) { - if (!Double.isFinite(value)) return 0; - return Math.max(0, Math.min(1, value)); - } - - /** 夹取而非 intValue():外部数据超出 int 范围时截断会绕回小值甚至 0,静默改变去重排序。 */ private static int integer(JsonObject object, String key, int fallback) { Long value = longValue(object, key); - if (value == null) return fallback; - return (int) Math.min(MAX_SUBMISSION_COUNT, value); + return value == null ? fallback : value.intValue(); } private static boolean isEmpty(String value) { return value == null || value.trim().isEmpty(); } - /** 只有所有已发起的 provider 都可靠返回,结果才允许进入无 TTL 的内存缓存。 */ - static boolean isCacheableResponse(int providerCount, int completedCount, boolean failed) { - return providerCount > 0 && providerCount == completedCount && !failed; - } - - /** 未知时长的缓存可用于预载/查询,但不能阻止正式请求获取时长感知结果。 */ - static boolean canUseCachedResponse(long cachedDurationMs, long requestedDurationMs) { - return requestedDurationMs <= 0 || cachedDurationMs > 0; - } - - /** 只允许已知时长结果替换未知时长结果,避免预载响应覆盖正式响应。 */ - static boolean shouldReplaceCachedResponse(long cachedDurationMs, long incomingDurationMs) { - return cachedDurationMs <= 0 && incomingDurationMs > 0; - } - public static final class Query { private final int tmdbId; @@ -412,7 +233,7 @@ public class IntroSkipService { this.mediaType = mediaType == null ? "" : mediaType.trim().toLowerCase(Locale.ROOT); this.season = season; this.episode = episode; - this.durationMs = Math.min(MAX_MEDIA_TIME_MS, Math.max(0, durationMs)); + this.durationMs = durationMs; } public boolean hasLookupKey() { @@ -423,52 +244,9 @@ public class IntroSkipService { return "tv".equals(mediaType) || season > 0 || episode > 0; } - /** - * 只含剧集身份,不含时长。 - * - *

时长不是查询条件(IntroDB 不收,TheIntroDB 可选),把它写进键会让同一集在 - * onPrepare(时长还是 0)和 STATE_READY(时长已知)落到两个桶各发一轮请求,也让预载 - * 永远命中不了——预载时那一集还没开播,时长无从得知。代价是首次请求若发生在预载阶段 - * 就没带上 duration_ms,拿不到服务端按本地片源挑的那一版标定;折算在客户端本来就要做 - * 一遍,这个损失可以接受,换来的是每集一次请求加预载真正生效。 - */ public String cacheKey() { - return tmdbId + "|" + imdbId + "|" + mediaType + "|" + season + "|" + episode; - } - - public long getDurationMs() { - return durationMs; - } - } - - /** - * 解析产物,尚未按本集时长折算。缓存存的是这个,折算推迟到读取时按当次时长做, - * 同一集换了播放源(时长不同)能复用同一份缓存。 - */ - static final class RawSegment { - - private final Segment.Kind kind; - private final String provider; - private final String identity; - private final Long startMs; - private final Long endMs; - private final long referenceDurationMs; - private final double confidence; - private final int submissionCount; - - RawSegment(Segment.Kind kind, String provider, String identity, Long startMs, Long endMs, long referenceDurationMs, double confidence, int submissionCount) { - this.kind = kind; - this.provider = provider; - this.identity = identity; - this.startMs = startMs; - this.endMs = endMs; - this.referenceDurationMs = referenceDurationMs; - this.confidence = confidence; - this.submissionCount = submissionCount; - } - - Segment resolve(long durationMs) { - return Segment.create(kind, provider, identity, startMs, endMs, durationMs, referenceDurationMs, confidence, submissionCount); + long durationBucket = durationMs <= 0 ? 0 : TimeUnit.MILLISECONDS.toSeconds(durationMs); + return tmdbId + "|" + imdbId + "|" + mediaType + "|" + season + "|" + episode + "|" + durationBucket; } } @@ -487,18 +265,7 @@ public class IntroSkipService { return EMPTY; } - static IntroSkipPlan from(List raw, long durationMs) { - List segments = new ArrayList<>(); - if (raw != null) { - for (RawSegment item : raw) { - Segment segment = item == null ? null : item.resolve(durationMs); - if (segment != null) segments.add(segment); - } - } - return fromResolved(segments); - } - - private static IntroSkipPlan fromResolved(List segments) { + private static IntroSkipPlan from(List segments) { List openings = new ArrayList<>(); List endings = new ArrayList<>(); for (Segment segment : segments) { @@ -510,12 +277,16 @@ public class IntroSkipService { return openings.isEmpty() && endings.isEmpty() ? EMPTY : new IntroSkipPlan(openings, endings); } - /** 按时间先后给出全部段落(片头、片尾、预告混排),供逐段判定使用。 */ - public List getAll() { - List all = new ArrayList<>(openings); - all.addAll(endings); - sort(all); - return all; + public static IntroSkipPlan merge(List plans) { + List segments = new ArrayList<>(); + if (plans != null) { + for (IntroSkipPlan plan : plans) { + if (plan == null) continue; + segments.addAll(plan.openings); + segments.addAll(plan.endings); + } + } + return from(segments); } public boolean isEmpty() { @@ -534,7 +305,7 @@ public class IntroSkipService { for (int i = 0; i < segments.size(); i++) { Segment existing = segments.get(i); if (!existing.overlaps(segment)) continue; - if (segment.better(existing)) segments.set(i, segment); + if (segment.score() > existing.score()) segments.set(i, segment); return; } segments.add(segment); @@ -550,104 +321,38 @@ public class IntroSkipService { public enum Kind { INTRO, RECAP, - OUTRO, - PREVIEW + OUTRO } private final Kind kind; private final String provider; - private final String identity; private final long startMs; private final long endMs; - private final boolean openEnded; private final double confidence; private final int submissionCount; - private Segment(Kind kind, String provider, String identity, long startMs, long endMs, boolean openEnded, double confidence, int submissionCount) { + private Segment(Kind kind, String provider, long startMs, long endMs, double confidence, int submissionCount) { this.kind = kind; this.provider = provider; - this.identity = isEmpty(identity) ? kind + "|" + provider + "|" + startMs + "|" + endMs : identity; this.startMs = startMs; this.endMs = endMs; - this.openEnded = openEnded; this.confidence = confidence; this.submissionCount = submissionCount; } - private static Segment create(Kind kind, String provider, String identity, Long startMs, Long endMs, long durationMs, long referenceDurationMs, double confidence, int submissionCount) { + private static Segment create(Kind kind, String provider, Long startMs, Long endMs, long durationMs, double confidence, int submissionCount) { if (kind == null) return null; - boolean trailing = kind == Kind.OUTRO || kind == Kind.PREVIEW; - long start = startMs == null && !trailing ? 0 : startMs == null ? -1 : startMs; + long start = startMs == null && kind != Kind.OUTRO ? 0 : startMs == null ? -1 : startMs; long end = endMs == null ? -1 : endMs; if (start < 0) return null; - if (endMs != null && end < 0) return null; - if (trailing) return createTrailing(kind, provider, identity, start, end, durationMs, referenceDurationMs, confidence, submissionCount); if (durationMs > 0) { if (start >= durationMs) return null; + if (end < 0 && kind == Kind.OUTRO) end = durationMs; if (end > durationMs) end = durationMs; } - if (end <= start) return null; - return new Segment(kind, provider, identity, start, end, false, Math.max(0, confidence), Math.max(0, submissionCount)); - } - - /** - * 尾部段(片尾、下集预告)按「距文件结尾的偏移」理解,而不是照抄绝对时间戳。 - * - *

片源普遍存在掐头、加站点前贴、删减,与数据源标定的参考版本时长对不上;此时绝对 - * 时间戳整体漂移,但「距结尾还有多久」基本稳定,按该距离折算才能对上本地时间轴。 - * 早先直接用参考时间戳,只要本地片源比参考版本短一点就命中 start >= durationMs, - * 整段被丢弃——这正是片头能跳、片尾不跳的主因。参考时长缺失时无从折算, - * 只能沿用原始时间戳。 - */ - private static Segment createTrailing(Kind kind, String provider, String identity, long start, long end, long durationMs, long referenceDurationMs, double confidence, int submissionCount) { - long reference = plausibleReference(referenceDurationMs, durationMs); - if (reference > 0 && end > reference && end - reference > OPEN_END_TOLERANCE_MS) return null; - // openEnded 只能拿参考时间轴上的结尾去比。没有参考时长时(IntroDB 从不给)无从判断, - // 一律按「有界」处理:错判成 openEnded 会让 seek 变成切集,把片尾之后的正片一起扔掉。 - boolean missingEnd = end < 0; - boolean openEnded = reference > 0 - && (missingEnd || reference >= end && reference - end <= OPEN_END_TOLERANCE_MS); - if (reference > 0 && durationMs > 0 && !withinDistance(reference, durationMs, TIME_BASE_TOLERANCE_MS)) { - long shift = durationMs - reference; - start = safeAdd(start, shift); - if (end >= 0) end = safeAdd(end, shift); - if (start == Long.MIN_VALUE || end == Long.MIN_VALUE) return null; - } - if (durationMs > 0) { - if (missingEnd || openEnded || end > durationMs) end = durationMs; - if (start >= durationMs) return null; - } - if (start <= 0) return null; // 尾部段不可能从 0 开始,整集当片尾必是错配 if (end >= 0 && end <= start) return null; - return new Segment(kind, provider, identity, start, end, openEnded, Math.max(0, confidence), Math.max(0, submissionCount)); - } - - /** - * 参考时长与本集时长必须足够接近,否则视为对不上同一版本,放弃折算。 - * - *

折算是整段平移,基准差多少片尾就挪多少。掐头去尾、贴片、删减这类真实差异通常在 - * 几分钟量级;差到分钟数十倍(合并版、跨版本错配)时平移量本身就比片尾还长,算出来的 - * 落点落在正片里,跳过去等于把正片当片尾扔掉。宁可不折算保留原始时间戳。 - * 用减法而非乘法比较,避免 reference 接近 Long.MAX_VALUE 时溢出成负数骗过检查。 - */ - private static long plausibleReference(long reference, long durationMs) { - if (reference <= 0) return 0; - if (durationMs <= 0) return reference; - boolean plausible = withinDistance(reference, durationMs, MAX_TIME_BASE_DRIFT_MS); - if (!plausible) SpiderDebug.log("intro-skip", "reject reference=%d durationMs=%d", reference, durationMs); - return plausible ? reference : 0; - } - - static boolean withinDistance(long left, long right, long tolerance) { - if (tolerance < 0) return false; - long distance = left >= right ? left - right : right - left; - return distance >= 0 && distance <= tolerance; - } - - private static long safeAdd(long value, long delta) { - if (delta > 0 && value > Long.MAX_VALUE - delta) return Long.MIN_VALUE; - if (delta < 0 && value < Long.MIN_VALUE - delta) return Long.MIN_VALUE; - return value + delta; + if (end < 0 && kind != Kind.OUTRO) return null; + return new Segment(kind, provider, start, end, Math.max(0, confidence), Math.max(0, submissionCount)); } public Kind getKind() { @@ -658,11 +363,6 @@ public class IntroSkipService { return provider; } - /** 原始数据边界生成的稳定身份,不随本地片源时长折算而变化。 */ - public String getIdentity() { - return identity; - } - public long getStartMs() { return startMs; } @@ -671,45 +371,21 @@ public class IntroSkipService { return endMs; } - /** 片尾是否一直延伸到文件结束——为真表示没有可 seek 的落点,只能按「本集看完」处理。 */ - public boolean isOpenEnded() { - return openEnded; - } - public boolean isOpening() { return kind == Kind.INTRO || kind == Kind.RECAP; } public boolean isEnding() { - return kind == Kind.OUTRO || kind == Kind.PREVIEW; + return kind == Kind.OUTRO; } private double score() { return confidence * 100.0 + submissionCount * 2.0 + (PROVIDER_INTRO_DB.equals(provider) ? 1.0 : 0.0); } - /** - * 两段被判为同一段时该保留哪一个。 - * - *

有界优先于无界,先于分数比较:无界段没有 seek 落点,只能按「本集看完」处理, - * 让它挤掉另一家给出的确切结束点,就把片尾之后的彩蛋、预告连同跳过能力一起丢了。 - */ - private boolean better(Segment other) { - if (openEnded != other.openEnded) return !openEnded; - return score() > other.score(); - } - private boolean overlaps(Segment other) { if (other == null) return false; - // 开头段之间、尾部段之间允许跨类去重:两家对同一段的标注常不一致(一家算 recap - // 另一家算 intro),不合并会对同一段连跳两次。开头与尾部之间不并——它们物理上 - // 相隔整集。片尾与紧随其后的预告都在尾部,靠起点差与重叠比例区分。 - if (isOpening() != other.isOpening()) return false; - // 无界段的 end 已被拉到文件结尾,拿它算重叠会把紧随其后的段整个吞掉(片尾吞预告)。 - // 这种情况只按起点判断:起点隔得远就是两段不同的内容。 - if (openEnded || other.openEnded) return withinDistance(startMs, other.startMs, SAME_START_TOLERANCE_MS); - if (withinDistance(startMs, other.startMs, SAME_START_TOLERANCE_MS) - && (endMs < 0 || other.endMs < 0 || withinDistance(endMs, other.endMs, 5000))) return true; + if (Math.abs(startMs - other.startMs) <= 3000 && (endMs < 0 || other.endMs < 0 || Math.abs(endMs - other.endMs) <= 5000)) return true; if (endMs < 0 || other.endMs < 0) return false; long overlap = Math.min(endMs, other.endMs) - Math.max(startMs, other.startMs); if (overlap <= 0) return false; diff --git a/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationCache.java b/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationCache.java deleted file mode 100644 index ce8d17aceb..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationCache.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.fongmi.android.tv.service; - -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbItem; -import com.github.catvod.utils.Path; -import com.google.gson.Gson; -import com.google.gson.annotations.SerializedName; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -final class PersonalRecommendationCache { - - private static final int VERSION = 1; - private static final int MAX_CACHED_ITEMS = 128; - private static final int MAX_CACHE_FILES = 32; - private static final long TTL = TimeUnit.DAYS.toMillis(7); - private static final Gson GSON = new Gson(); - - private final File directory; - - PersonalRecommendationCache(File directory) { - this.directory = directory; - } - - synchronized PersonalRecommendationService.RecommendationPage read( - String source, String key, String fingerprint) { - File file = cacheFile(source, key); - if (file == null) return null; - Payload payload = readPayload(file); - if (payload == null || !isValid(payload, fingerprint)) return null; - return new PersonalRecommendationService.RecommendationPage( - payload.items, payload.offset, payload.nextOffset, payload.hasMore, payload.fingerprint); - } - - synchronized void write( - String source, String key, String fingerprint, - PersonalRecommendationService.RecommendationPage page) { - if (page == null || page.getItems().isEmpty()) return; - List items = page.getItems(); - if (items.size() > MAX_CACHED_ITEMS) items = new ArrayList<>(items.subList(0, MAX_CACHED_ITEMS)); - writePayload(cacheFile(source, key), new Payload( - VERSION, items, page.getOffset(), page.getNextOffset(), page.hasMore(), fingerprint)); - cleanup(); - } - - private Payload readPayload(File file) { - try { - if (file == null || !file.exists() || file.length() <= 0) return null; - if (System.currentTimeMillis() - file.lastModified() > TTL) return null; - String body = Path.read(file); - if (isBlank(body)) return null; - return GSON.fromJson(body, Payload.class); - } catch (RuntimeException e) { - return null; - } - } - - private void writePayload(@Nullable File file, Payload payload) { - try { - if (file == null) return; - File parent = file.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs()) return; - Path.write(file, GSON.toJson(payload).getBytes(StandardCharsets.UTF_8)); - } catch (RuntimeException ignored) { - } - } - - private boolean isValid(Payload payload, String fingerprint) { - return payload.version == VERSION - && payload.items != null - && !payload.items.isEmpty() - && payload.items.size() <= MAX_CACHED_ITEMS - && payload.offset >= 0 - && payload.nextOffset >= payload.offset - && Objects.equals(payload.fingerprint, fingerprint); - } - - private void cleanup() { - try { - if (directory == null || !directory.isDirectory()) return; - File[] files = directory.listFiles(file -> file != null && file.isFile() && file.getName().endsWith(".json")); - if (files == null || files.length <= MAX_CACHE_FILES) return; - Arrays.sort(files, Comparator.comparingLong(File::lastModified).reversed()); - for (int index = MAX_CACHE_FILES; index < files.length; index++) { - File file = files[index]; - if (file != null && !file.delete()) file.deleteOnExit(); - } - } catch (RuntimeException ignored) { - } - } - - @Nullable - private File cacheFile(String source, String key) { - try { - if (directory == null || isBlank(source) || isBlank(key)) return null; - return new File(directory, source + "_" + md5(key) + ".json"); - } catch (RuntimeException e) { - return null; - } - } - - private static String md5(String value) { - try { - MessageDigest digest = MessageDigest.getInstance("MD5"); - byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder builder = new StringBuilder(); - for (byte valueByte : bytes) builder.append(String.format("%02x", valueByte)); - return builder.toString(); - } catch (Exception e) { - throw new IllegalStateException("MD5 unavailable", e); - } - } - - private static boolean isBlank(String value) { - return value == null || value.trim().isEmpty(); - } - - private static final class Payload { - - @SerializedName("version") - private final int version; - @SerializedName("items") - private final List items; - @SerializedName("offset") - private final int offset; - @SerializedName("nextOffset") - private final int nextOffset; - @SerializedName("hasMore") - private final boolean hasMore; - @SerializedName("fingerprint") - private final String fingerprint; - - private Payload( - int version, List items, int offset, int nextOffset, - boolean hasMore, String fingerprint) { - this.version = version; - this.items = items == null ? new ArrayList<>() : new ArrayList<>(items); - this.offset = offset; - this.nextOffset = nextOffset; - this.hasMore = hasMore; - this.fingerprint = fingerprint == null ? "" : fingerprint; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationService.java b/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationService.java index fdf75363a1..0753fc8083 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/PersonalRecommendationService.java @@ -47,8 +47,6 @@ import okhttp3.Response; public class PersonalRecommendationService { public static final int DEFAULT_PAGE_SIZE = 12; - private static final String CACHE_SOURCE_TMDB = "tmdb"; - private static final String CACHE_SOURCE_DOUBAN = "douban"; private static final int TMDB_HISTORY_SEED_BATCH = 4; private static final int MIN_TMDB_HISTORY_RESULTS = 4; private static final int DOUBAN_SEED_BATCH = 8; @@ -95,8 +93,6 @@ public class PersonalRecommendationService { } }; - private final PersonalRecommendationCache resultCache = new PersonalRecommendationCache(resultCacheDirectory()); - private static final DoubanRatingMemoryCache DOUBAN_RATING_CACHE = new DoubanRatingMemoryCache(MAX_DOUBAN_RATING_CACHE, DOUBAN_CACHE_TTL); private static final Object DOUBAN_REQUEST_LOCK = new Object(); @@ -105,7 +101,7 @@ public class PersonalRecommendationService { private static volatile boolean doubanCooldownLoaded; public PersonalRecommendationService() { - this(new TmdbService(), TmdbConfig.effectiveCurrent()); + this(new TmdbService(), TmdbConfig.objectFrom(Setting.getTmdbConfig())); } public PersonalRecommendationService(TmdbService tmdbService, TmdbConfig tmdbConfig) { @@ -123,16 +119,9 @@ public class PersonalRecommendationService { public RecommendationPages loadPage(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable JsonObject currentDetail, int offset, int pageSize) { if (!Setting.isPersonalRecommendation() || Thread.currentThread().isInterrupted()) return RecommendationPages.empty(); - RecommendationPage tmdb = loadCachedOrRefreshTmdbPage(currentVod, currentItem, currentDetail, offset, pageSize); + RecommendationPage tmdb = loadTmdbPage(currentVod, currentItem, currentDetail, offset, pageSize); if (Thread.currentThread().isInterrupted()) return new RecommendationPages(tmdb, RecommendationPage.empty(""), RecommendationPage.empty("")); - RecommendationPage douban = loadCachedOrRefreshDoubanPage(currentVod, offset, pageSize); - return new RecommendationPages(tmdb, douban, RecommendationPage.empty(aiFingerprint(currentVod, currentItem))); - } - - public RecommendationPages loadIndependentPages(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable JsonObject currentDetail, int offset, int pageSize) { - if (!Setting.isPersonalRecommendation() || Thread.currentThread().isInterrupted()) return RecommendationPages.empty(); - RecommendationPage tmdb = loadCachedOrRefreshTmdbPage(currentVod, currentItem, currentDetail, offset, pageSize); - RecommendationPage douban = loadCachedOrRefreshDoubanPage(currentVod, offset, pageSize); + RecommendationPage douban = loadDoubanPage(currentVod, offset, pageSize); return new RecommendationPages(tmdb, douban, RecommendationPage.empty(aiFingerprint(currentVod, currentItem))); } @@ -143,7 +132,7 @@ public class PersonalRecommendationService { public RecommendationPage loadTmdbPage(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable JsonObject currentDetail, int offset, int pageSize) { if (!Setting.isPersonalRecommendation() || !tmdbConfig.isReady()) return RecommendationPage.empty(historyFingerprint(currentVod, true)); - return loadCachedOrRefreshTmdbPage(currentVod, currentItem, currentDetail, offset, pageSize); + return loadFromTmdb(currentVod, currentItem, currentDetail, offset, pageSize); } public List loadDouban(@Nullable Vod currentVod) { @@ -221,25 +210,7 @@ public class PersonalRecommendationService { public RecommendationPage loadDoubanPage(@Nullable Vod currentVod, int offset, int pageSize) { if (!Setting.isPersonalRecommendation()) return RecommendationPage.empty(historyFingerprint(currentVod, false)); - return loadCachedOrRefreshDoubanPage(currentVod, offset, pageSize); - } - - public RecommendationPage loadFreshTmdbPage(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable JsonObject currentDetail, int offset, int pageSize) { - if (!Setting.isPersonalRecommendation() || !tmdbConfig.isReady()) return RecommendationPage.empty(historyFingerprint(currentVod, true)); - return writePage(CACHE_SOURCE_TMDB, tmdbCacheKey(currentVod, currentItem), tmdbFingerprint(currentVod), loadFromTmdb(currentVod, currentItem, currentDetail, offset, pageSize)); - } - - public RecommendationPage loadFreshDoubanPage(@Nullable Vod currentVod, int offset, int pageSize) { - if (!Setting.isPersonalRecommendation()) return RecommendationPage.empty(historyFingerprint(currentVod, false)); - return writePage(CACHE_SOURCE_DOUBAN, doubanCacheKey(currentVod), doubanFingerprint(currentVod), loadFromDouban(currentVod, offset, pageSize)); - } - - public void writeCachedTmdbPage(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable PersonalRecommendationService.RecommendationPage page) { - writePage(CACHE_SOURCE_TMDB, tmdbCacheKey(currentVod, currentItem), tmdbFingerprint(currentVod), page); - } - - public void writeCachedDoubanPage(@Nullable Vod currentVod, @Nullable PersonalRecommendationService.RecommendationPage page) { - writePage(CACHE_SOURCE_DOUBAN, doubanCacheKey(currentVod), doubanFingerprint(currentVod), page); + return loadFromDouban(currentVod, offset, pageSize); } public RecommendationPage loadAiPage(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, int pageSize) { @@ -267,16 +238,6 @@ public class PersonalRecommendationService { return AiRecommendationService.fingerprint(currentTitle, aiHistoryFingerprint(currentTitle), Setting.getKeyword(), AiConfig.objectFrom(Setting.getAiConfig())); } - public String tmdbFingerprint(@Nullable Vod currentVod) { - List seeds = historySeeds(currentTitle(currentVod, null), Integer.MAX_VALUE, true); - return recommendationFingerprint(seeds); - } - - public String doubanFingerprint(@Nullable Vod currentVod) { - List seeds = doubanSeeds(currentTitle(currentVod, null), Integer.MAX_VALUE); - return recommendationFingerprint(seeds); - } - private String aiHistoryFingerprint(String currentTitle) { return AiRecommendationService.historyMetadataFingerprint(safeHistory(), currentTitle) + "|feedback:" + RecommendationFeedbackStore.fingerprint(); @@ -290,72 +251,6 @@ public class PersonalRecommendationService { return recommendationFingerprint(seeds); } - private RecommendationPage loadCachedOrRefreshTmdbPage( - @Nullable Vod currentVod, @Nullable TmdbItem currentItem, - @Nullable JsonObject currentDetail, int offset, int pageSize) { - if (offset == 0) { - String key = tmdbCacheKey(currentVod, currentItem); - String fingerprint = tmdbFingerprint(currentVod); - RecommendationPage cached = resultCache.read(CACHE_SOURCE_TMDB, key, fingerprint); - if (cached != null) return cached; - } - return writePage( - CACHE_SOURCE_TMDB, - tmdbCacheKey(currentVod, currentItem), - tmdbFingerprint(currentVod), - loadFromTmdb(currentVod, currentItem, currentDetail, offset, pageSize)); - } - - private RecommendationPage loadCachedOrRefreshDoubanPage(@Nullable Vod currentVod, int offset, int pageSize) { - if (offset == 0) { - String key = doubanCacheKey(currentVod); - String fingerprint = doubanFingerprint(currentVod); - RecommendationPage cached = resultCache.read(CACHE_SOURCE_DOUBAN, key, fingerprint); - if (cached != null) return cached; - } - return writePage( - CACHE_SOURCE_DOUBAN, - doubanCacheKey(currentVod), - doubanFingerprint(currentVod), - loadFromDouban(currentVod, offset, pageSize)); - } - - private RecommendationPage writePage(String source, String key, String fingerprint, RecommendationPage page) { - resultCache.write(source, key, fingerprint, page); - return page; - } - - private String tmdbCacheKey(@Nullable Vod currentVod, @Nullable TmdbItem currentItem) { - List seeds = historySeeds(currentTitle(currentVod, currentItem), Integer.MAX_VALUE, true); - return String.join("|", - Objects.toString(currentTitle(currentVod, currentItem), ""), - historySeedFingerprint(seeds), - RecommendationFeedbackStore.fingerprint(), - Objects.toString(tmdbConfig.getApiBase(), ""), - Objects.toString(tmdbConfig.getLanguage(), "")); - } - - private String doubanCacheKey(@Nullable Vod currentVod) { - List seeds = doubanSeeds(currentTitle(currentVod, null), Integer.MAX_VALUE); - return String.join("|", - Objects.toString(currentTitle(currentVod, null), ""), - historySeedFingerprint(seeds), - RecommendationFeedbackStore.fingerprint(), - Objects.toString(tmdbConfig.getApiBase(), ""), - Objects.toString(tmdbConfig.getLanguage(), "")); - } - - @Nullable - private File resultCacheDirectory() { - try { - File dir = new File(Path.cache(), "personal_rec"); - if (!dir.exists() && !dir.mkdirs()) return null; - return dir; - } catch (Throwable e) { - return null; - } - } - private RecommendationPage loadFromTmdb(@Nullable Vod currentVod, @Nullable TmdbItem currentItem, @Nullable JsonObject currentDetail, int offset, int pageSize) { int pageOffset = Math.max(0, offset); int limit = safePageSize(pageSize); diff --git a/app/src/main/java/com/fongmi/android/tv/service/PlaybackService.java b/app/src/main/java/com/fongmi/android/tv/service/PlaybackService.java index 03df37d090..981ad3f67b 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/PlaybackService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/PlaybackService.java @@ -29,12 +29,10 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.BuildConfig; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.Sub; import com.fongmi.android.tv.browse.BrowseTree; import com.fongmi.android.tv.event.ActionEvent; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.playback.PlaybackEventCollector; -import com.fongmi.android.tv.player.PlaybackOwnershipKeys; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.engine.PlaySpec; import com.fongmi.android.tv.player.lyrics.DesktopLyricsWindow; @@ -244,7 +242,6 @@ private AudioHistory.Record audioHistoryRecord; running = false; syncAudioHistoryProgress(true); clearAudioHistoryRecord(); - player.prepareTerminalRelease(); PlaybackEventCollector.get().onStop(player); if (desktopLyrics != null) desktopLyrics.release(); releaseSession(); @@ -278,7 +275,6 @@ private AudioHistory.Record audioHistoryRecord; keepAlive = false; syncAudioHistoryProgress(true); clearAudioHistoryRecord(); - player.prepareTerminalRelease(); stopAndClear(); removeForeground(); stopSelf(); @@ -448,15 +444,8 @@ private AudioHistory.Record audioHistoryRecord; setNavigationCallback(null, null); } - /** - * 导航归属:本服务记录的 {@link #navigationKey} 是否就是当前播放会话。 - * - *

自动连播时服务自己用「浏览下一集」的 {@code VE:...} 键起播,而 {@code navigationKey} - * 仍是页面写入的记录键;两者字符串不等但同属一次会话。旧实现按字符串判假,后果是下一次 - * ENDED 被当成「无主」事件,服务直接再 {@code navigateItem(1)} 跳一集,连播变成连跳。 - */ private boolean isNavigationOwner() { - return isPlayerAvailable() && PlaybackOwnershipKeys.sameSession(navigationKey, player.getKey()); + return isPlayerAvailable() && (navigationKey == null || navigationKey.equals(player.getKey())); } public void addPlayerCallback(PlayerCallback callback) { @@ -685,11 +674,6 @@ private AudioHistory.Record audioHistoryRecord; playerCallbacks.forEach(PlayerCallback::onExoFirstFrame); } - @Override - public void onSubtitleSelected(Sub sub) { - playerCallbacks.forEach(callback -> callback.onSubtitleSelected(sub)); - } - @Override public void onPlayerRebuild(Player newPlayer, boolean resetVideoSurface) { exoPlayer.removeListener(listener); @@ -811,9 +795,6 @@ public void onIsPlayingChanged(boolean isPlaying) { default void onExoFirstFrame() { } - default void onSubtitleSelected(Sub sub) { - } - default void onPlayerRebuild(Player player, boolean resetVideoSurface) { } } diff --git a/app/src/main/java/com/fongmi/android/tv/service/ReaderTtsService.java b/app/src/main/java/com/fongmi/android/tv/service/ReaderTtsService.java deleted file mode 100644 index 2447be7612..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/service/ReaderTtsService.java +++ /dev/null @@ -1,598 +0,0 @@ -package com.fongmi.android.tv.service; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.media.AudioAttributes; -import android.media.AudioFocusRequest; -import android.media.AudioManager; -import android.os.Build; -import android.os.IBinder; -import android.os.PowerManager; -import android.text.TextUtils; - -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.core.content.ContextCompat; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.tts.ReaderTtsController; -import com.fongmi.android.tv.tts.TtsEngineConfig; -import com.fongmi.android.tv.tts.TtsOptions; -import com.fongmi.android.tv.tts.TtsVoice; -import com.github.catvod.crawler.SpiderDebug; - -import java.util.List; - -/** - * 小说朗读前台服务。 - * - * 对齐 legado {@code BaseReadAloudService} 的能力:朗读在后台/锁屏后继续、通知栏提供 - * 上一段/播放暂停/下一段/停止/定时、遵守音频焦点、拔耳机(ACTION_AUDIO_BECOMING_NOISY)自动暂停。 - * - * 与服务外的交互采用本仓库既有模式({@link PlaybackService} 的静态访问 + {@code App.get()}), - * 不额外引入 Binder/绑定抽象:阅读页启动服务后通过 {@link #get()} 拿到实例下发朗读任务, - * 服务再通过 {@link WebCallback} 把状态回传给阅读页 WebView。 - */ -public class ReaderTtsService extends Service implements ReaderTtsController.Listener { - - private static final String TAG = "TV-tts"; - private static final String CHANNEL_ID = "reader_tts"; - private static final int NOTIFY_ID = 0x7EAD; - - public static final String ACTION_START = "com.fongmi.android.tv.tts.START"; - public static final String ACTION_PAUSE = "com.fongmi.android.tv.tts.PAUSE"; - public static final String ACTION_RESUME = "com.fongmi.android.tv.tts.RESUME"; - public static final String ACTION_TOGGLE = "com.fongmi.android.tv.tts.TOGGLE"; - public static final String ACTION_PREV = "com.fongmi.android.tv.tts.PREV"; - public static final String ACTION_NEXT = "com.fongmi.android.tv.tts.NEXT"; - public static final String ACTION_STOP = "com.fongmi.android.tv.tts.STOP"; - public static final String ACTION_TIMER = "com.fongmi.android.tv.tts.TIMER"; - public static final String ACTION_TIMER_OFF = "com.fongmi.android.tv.tts.TIMER_OFF"; - - /** 阅读页(WebView)回调:由 WebReaderActivity 注册,阅读页销毁时必须清空。 */ - public interface WebCallback { - void onTtsState(String state, int paragraph, int total, String message, String title); - - void onTtsParagraph(int paragraph, int total, String text); - - void onTtsChapterEnd(); - - void onTtsVoices(List voices); - } - - /** - * 阅读页下发的朗读任务。 - * - * 朗读正文可能很长(整章段落),不走 Intent extra(Binder 事务上限), - * 而是静态传参:阅读页先赋值再 startForegroundService,服务在 onStartCommand - * 的 ACTION_START 分支取走。同进程同主线程,顺序确定。 - */ - public static final class StartRequest { - public final String source; - public final TtsOptions options; - public final TtsEngineConfig rule; - public final List paragraphs; - public final int index; - public final int timerMinutes; - public final String title; - - public StartRequest(String source, TtsOptions options, TtsEngineConfig rule, - List paragraphs, int index, int timerMinutes, String title) { - this.source = source; - this.options = options; - this.rule = rule; - this.paragraphs = paragraphs; - this.index = index; - this.timerMinutes = timerMinutes; - this.title = title; - } - } - - private static volatile StartRequest pendingStart; - - /** 阅读页在 startForegroundService 之前调用。 */ - public static void setPendingStart(StartRequest request) { - pendingStart = request; - } - - private static volatile WebCallback pendingWebCallback; - - /** - * 阅读页在服务尚未创建时先登记回调(服务 onCreate 后接管)。 - * 阅读器首次点朗读时服务还不存在,而状态回调(如系统音色列表)在服务创建后就会发出, - * 没有这个登记就会丢掉第一次回调。 - */ - public static void setPendingWebCallback(WebCallback callback) { - pendingWebCallback = callback; - } - - /** - * 章末等待阅读页续读的时间:读完一章后阅读页会立刻用新章的段落重新 start; - * 若它决定不续读(最后一章/用户已退出),服务在此时限后自行结束,避免常驻。 - */ - private static final long IDLE_STOP_DELAY_MS = 6000L; - - private static volatile ReaderTtsService instance; - - private ReaderTtsController controller; - private WebCallback webCallback; - private PowerManager.WakeLock wakeLock; - private Object focusRequest; - private boolean focusHeld; - private boolean foreground; - private final android.os.Handler handler = new android.os.Handler(android.os.Looper.getMainLooper()); - private final Runnable idleStop = this::stopSelf; - private final BroadcastReceiver noisyReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) { - onAudioBecomingNoisy(); - } - } - }; - - @Nullable - public static ReaderTtsService get() { - return instance; - } - - public static boolean isRunning() { - ReaderTtsService service = instance; - return service != null && service.controller != null && service.controller.isActive(); - } - - /** 停止朗读并结束服务(阅读页退出、或用户点停止时调用)。 */ - public static void stopAndQuit() { - ReaderTtsService service = instance; - if (service != null) service.shutdown(); - try { - App.get().stopService(new Intent(App.get(), ReaderTtsService.class)); - } catch (Throwable ignore) { - } - } - - /** 通知栏动作:不依赖阅读页是否在前台。 */ - public static void dispatch(Context context, String action, int timerMinutes) { - Intent intent = new Intent(context, ReaderTtsService.class).setAction(action); - if (timerMinutes > 0) intent.putExtra("minutes", timerMinutes); - try { - context.startService(intent); - } catch (Throwable e) { - SpiderDebug.log(TAG, "dispatch failed action=%s %s", action, e.getMessage()); - } - } - - @Override - public void onCreate() { - super.onCreate(); - instance = this; - controller = new ReaderTtsController(this); - controller.setListener(this); - WebCallback pending = pendingWebCallback; - if (pending != null) { - webCallback = pending; - pendingWebCallback = null; - } - createChannel(); - registerReceiver(noisyReceiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - String action = intent == null ? null : intent.getAction(); - if (action == null) { - // 服务被系统重建:没有朗读任务就退出,避免留下不可控的后台常驻 - if (controller == null || !controller.isActive()) stopSelf(); - return START_NOT_STICKY; - } - switch (action) { - case ACTION_PAUSE: - controller.pause(); - break; - case ACTION_RESUME: - controller.resume(); - break; - case ACTION_TOGGLE: - controller.toggle(); - break; - case ACTION_PREV: - controller.previous(); - break; - case ACTION_NEXT: - controller.next(); - break; - case ACTION_TIMER: - controller.addTimer(intent.getIntExtra("minutes", 30)); - break; - case ACTION_TIMER_OFF: - controller.setTimer(0); - break; - case ACTION_STOP: - controller.stop(null); - stopSelf(); - break; - case ACTION_START: - default: { - StartRequest request = pendingStart; - pendingStart = null; - if (request != null) startReadAloudNow(request); - break; - } - } - if (controller.isActive() || ReaderTtsController.STATE_ERROR.equals(controller.state())) { - startForegroundCompat(); - } - return START_NOT_STICKY; - } - - /** - * 音频输出路由丢失(拔耳机/蓝牙断开)→ 自动暂停。 - * - * 保持 public 是为了让真机测试能驱动广播接收器里的同一条路径: - * {@code ACTION_AUDIO_BECOMING_NOISY} 是系统保护广播,应用和 shell 都发不出去。 - */ - public void onAudioBecomingNoisy() { - SpiderDebug.log(TAG, "audio becoming noisy, pause read aloud"); - if (controller != null) controller.pause(); - } - - /* ---------------- 阅读页(WebView)调用入口 ---------------- */ - - public void setWebCallback(WebCallback callback) { - this.webCallback = callback; - } - - public void clearWebCallback(WebCallback callback) { - if (webCallback == callback) webCallback = null; - if (pendingWebCallback == callback) pendingWebCallback = null; - } - - public void startReadAloud(String source, TtsOptions options, TtsEngineConfig rule, - List paragraphs, int startIndex, int timerMinutes, String title) { - startReadAloudNow(new StartRequest(source, options, rule, paragraphs, startIndex, timerMinutes, title)); - } - - private void startReadAloudNow(StartRequest request) { - handler.removeCallbacks(idleStop); - startForegroundCompat(); - controller.start(request.source, request.options, request.rule, request.paragraphs, - request.index, request.timerMinutes, request.title); - } - - public void pauseReadAloud() { - controller.pause(); - } - - public void resumeReadAloud() { - controller.resume(); - } - - public void toggleReadAloud() { - controller.toggle(); - } - - public void prevParagraph() { - controller.previous(); - } - - public void nextParagraph() { - controller.next(); - } - - public void seekParagraph(int paragraph) { - controller.seekParagraph(paragraph); - } - - public void setRate(float rate) { - controller.setRate(rate); - } - - public void setPitch(float pitch) { - controller.setPitch(pitch); - } - - public void setVoice(String voice) { - controller.setVoice(voice); - } - - public void setTimer(int minutes) { - controller.setTimer(minutes); - } - - public String state() { - return controller.state(); - } - - public int currentParagraph() { - return controller.currentParagraph(); - } - - public int paragraphCount() { - return controller.paragraphCount(); - } - - public int timerMinutes() { - return controller.timerMinutes(); - } - - public String sourceId() { - return controller.sourceId(); - } - - /** 停播并结束服务(阅读页退出时调用)。 */ - public void shutdown() { - handler.removeCallbacks(idleStop); - if (controller != null) controller.stop(null); - stopForegroundCompat(); - stopSelf(); - } - - @Override - public void onDestroy() { - instance = null; - handler.removeCallbacks(idleStop); - try { - unregisterReceiver(noisyReceiver); - } catch (Throwable ignore) { - } - if (controller != null) { - controller.release(); - controller = null; - } - abandonFocus(); - releaseWakeLock(); - // 兜住退出路径上的残留通知:任何原因导致服务销毁时都清掉朗读通知条 - try { - android.app.NotificationManager manager = (android.app.NotificationManager) getSystemService(NOTIFICATION_SERVICE); - if (manager != null) manager.cancel(NOTIFY_ID); - } catch (Throwable ignore) { - } - super.onDestroy(); - } - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } - - /* ---------------- ReaderTtsController.Listener ---------------- */ - - @Override - public void onTtsState(String state, int paragraph, int total, String message, String title) { - boolean stopped = ReaderTtsController.STATE_STOPPED.equals(state); - if (stopped) { - // 章节读完(或用户停播):先给阅读页留出「续读下一章」的窗口,再自行结束 - abandonFocus(); - stopForegroundCompat(); - handler.removeCallbacks(idleStop); - handler.postDelayed(idleStop, IDLE_STOP_DELAY_MS); - } else if (ReaderTtsController.STATE_ERROR.equals(state)) { - abandonFocus(); - handler.removeCallbacks(idleStop); - startForegroundCompat(); - handler.postDelayed(idleStop, IDLE_STOP_DELAY_MS); - } else if (ReaderTtsController.STATE_PAUSED.equals(state)) { - // 暂停即让出音频焦点,否则会挡住用户暂停朗读后去放别的音频 - abandonFocus(); - handler.removeCallbacks(idleStop); - startForegroundCompat(); - } else { - handler.removeCallbacks(idleStop); - startForegroundCompat(); - acquireWakeLock(); - } - // 停播时通知已被 stopForegroundCompat 撤掉,不能再 notify: - // 否则通知栏会残留一条标题写着「正在朗读」的失效控制条, - // 而且服务随后退出也不会把它清掉(用户只能手动划掉)。 - if (!stopped) updateNotification(state, paragraph, total, message, title); - WebCallback callback = webCallback; - if (callback != null) callback.onTtsState(state, paragraph, total, message, title); - } - - @Override - public void onTtsParagraph(int paragraph, int total, String text) { - WebCallback callback = webCallback; - if (callback != null) callback.onTtsParagraph(paragraph, total, text); - } - - @Override - public void onTtsChapterEnd() { - WebCallback callback = webCallback; - if (callback != null) callback.onTtsChapterEnd(); - } - - @Override - public void onTtsVoices(List voices) { - WebCallback callback = webCallback; - if (callback != null) callback.onTtsVoices(voices); - } - - /* ---------------- 前台服务 / 通知 ---------------- */ - - private void createChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; - NotificationManager manager = getSystemService(NotificationManager.class); - if (manager == null) return; - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, getString(R.string.reader_tts_channel), NotificationManager.IMPORTANCE_LOW); - channel.setShowBadge(false); - channel.setSound(null, null); - manager.createNotificationChannel(channel); - } - - private void startForegroundCompat() { - if (foreground) return; - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForeground(NOTIFY_ID, buildNotification(controller == null ? null : controller.state(), - controller == null ? -1 : controller.currentParagraph(), - controller == null ? 0 : controller.paragraphCount(), null, "")); - } else { - Notification notification = buildNotification(controller == null ? null : controller.state(), - controller == null ? -1 : controller.currentParagraph(), - controller == null ? 0 : controller.paragraphCount(), null, ""); - android.app.NotificationManager manager = (android.app.NotificationManager) getSystemService(NOTIFICATION_SERVICE); - if (manager != null) manager.notify(NOTIFY_ID, notification); - } - foreground = true; - } catch (Throwable e) { - SpiderDebug.log(TAG, "startForeground failed %s", e.getMessage()); - } - } - - private void stopForegroundCompat() { - if (!foreground) return; - foreground = false; - try { - stopForeground(true); - } catch (Throwable ignore) { - } - try { - android.app.NotificationManager manager = (android.app.NotificationManager) getSystemService(NOTIFICATION_SERVICE); - if (manager != null) manager.cancel(NOTIFY_ID); - } catch (Throwable ignore) { - } - releaseWakeLock(); - } - - private void updateNotification(String state, int paragraph, int total, String message, String title) { - NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); - if (manager == null) return; - try { - manager.notify(NOTIFY_ID, buildNotification(state, paragraph, total, message, title)); - } catch (Throwable e) { - SpiderDebug.log(TAG, "notify failed %s", e.getMessage()); - } - } - - private Notification buildNotification(String state, int paragraph, int total, String message, String title) { - boolean playing = ReaderTtsController.STATE_PLAYING.equals(state); - String heading; - if (ReaderTtsController.STATE_PAUSED.equals(state)) heading = getString(R.string.reader_tts_paused); - else if (ReaderTtsController.STATE_ERROR.equals(state)) heading = getString(R.string.reader_tts_error); - else heading = getString(R.string.reader_tts_playing); - if (controller != null && controller.timerMinutes() > 0) { - heading += " · " + getString(R.string.reader_tts_timer, controller.timerMinutes()); - } - String track = TextUtils.isEmpty(title) ? getString(R.string.reader_tts_default_title) : title; - String detail = TextUtils.isEmpty(message) - ? (paragraph >= 0 ? getString(R.string.reader_tts_progress, paragraph + 1, Math.max(total, paragraph + 1)) : track) - : message; - NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_lock_silent_mode_off) - .setContentTitle(heading + " · " + track) - .setContentText(detail) - .setOngoing(playing) - .setOnlyAlertOnce(true) - .setShowWhen(false) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setCategory(NotificationCompat.CATEGORY_TRANSPORT) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setVibrate(null) - .setSound(null) - .addAction(0, getString(R.string.reader_tts_prev), servicePending(ACTION_PREV, 1)) - .addAction(0, playing ? getString(R.string.reader_tts_pause) : getString(R.string.reader_tts_resume), - servicePending(playing ? ACTION_PAUSE : ACTION_RESUME, 2)) - .addAction(0, getString(R.string.reader_tts_next), servicePending(ACTION_NEXT, 3)) - .addAction(0, getString(R.string.reader_tts_timer_action), servicePending(ACTION_TIMER, 4)) - .addAction(0, getString(R.string.reader_tts_stop), servicePending(ACTION_STOP, 5)); - return builder.build(); - } - - private PendingIntent servicePending(String action, int requestCode) { - Intent intent = new Intent(this, ReaderTtsService.class).setAction(action); - if (ACTION_TIMER.equals(action)) intent.putExtra("minutes", 30); - int flags = PendingIntent.FLAG_UPDATE_CURRENT; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags |= PendingIntent.FLAG_IMMUTABLE; - return PendingIntent.getService(this, requestCode, intent, flags); - } - - /* ---------------- 音频焦点 / 电源 ---------------- */ - - /** 请求音频焦点;被其他应用抢占时暂停朗读(对齐 legado 的焦点策略)。 */ - private boolean requestFocus() { - AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); - if (manager == null) return true; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (focusRequest == null) { - AudioAttributes attributes = new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build(); - focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes(attributes) - .setWillPauseWhenDucked(true) - .setOnAudioFocusChangeListener(this::onAudioFocusChange) - .build(); - } - return manager.requestAudioFocus((AudioFocusRequest) focusRequest) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; - } - return manager.requestAudioFocus(this::onAudioFocusChange, AudioManager.STREAM_MUSIC, - AudioManager.AUDIOFOCUS_GAIN) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; - } - - private void abandonFocus() { - if (!focusHeld) return; - focusHeld = false; - AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); - if (manager == null) return; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest instanceof AudioFocusRequest) { - manager.abandonAudioFocusRequest((AudioFocusRequest) focusRequest); - } else { - manager.abandonAudioFocus(null); - } - focusRequest = null; - } - - private void onAudioFocusChange(int change) { - if (controller == null || !controller.isActive()) return; - switch (change) { - case AudioManager.AUDIOFOCUS_LOSS: - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: - controller.pause(); - break; - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: - controller.pause(); - break; - default: - break; - } - } - - private void acquireWakeLock() { - // 焦点只请求一次:每次状态回调都去 requestAudioFocus 会刷爆系统焦点栈, - // 也让「暂停后让出焦点」永远无法生效。 - if (!focusHeld) { - focusHeld = requestFocus(); - if (!focusHeld) { - SpiderDebug.log(TAG, "audio focus denied, pause"); - if (controller != null && controller.isPlaying()) controller.pause(); - return; - } - } - if (wakeLock == null) { - PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); - if (powerManager == null) return; - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WebHTV:ReaderTts"); - wakeLock.setReferenceCounted(false); - } - if (!wakeLock.isHeld()) wakeLock.acquire(4 * 60 * 60 * 1000L); - } - - private void releaseWakeLock() { - if (wakeLock != null && wakeLock.isHeld()) { - try { - wakeLock.release(); - } catch (Throwable ignore) { - } - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/service/TmdbConfigTestService.java b/app/src/main/java/com/fongmi/android/tv/service/TmdbConfigTestService.java deleted file mode 100644 index ec510ec09f..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/service/TmdbConfigTestService.java +++ /dev/null @@ -1,280 +0,0 @@ -package com.fongmi.android.tv.service; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.utils.TmdbProxy; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.util.List; -import java.util.Locale; -import java.util.concurrent.TimeUnit; - -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; - -/** Tests unsaved TMDB API and image-host settings without changing persisted configuration. */ -public final class TmdbConfigTestService { - - private static final OkHttpClient CLIENT = com.github.catvod.net.OkHttp.client().newBuilder() - .callTimeout(10, TimeUnit.SECONDS) - .followRedirects(true) - .build(); - - private TmdbConfigTestService() { - } - - public static Result test(String credential, String apiHost, String imageHost, String omdbApiKey) { - return test(credential, apiHost, imageHost, "", omdbApiKey); - } - - public static Result test(String credential, String apiHost, String imageHost, String proxyBase, String omdbApiKey) { - return test(CLIENT, credential, apiHost, imageHost, proxyBase, omdbApiKey, "https://www.omdbapi.com/"); - } - - static Result test(OkHttpClient client, String credential, String apiHost, String imageHost, String omdbApiKey, String omdbBaseUrl) { - return test(client, credential, apiHost, imageHost, "", omdbApiKey, omdbBaseUrl); - } - - static Result test(OkHttpClient client, String credential, String apiHost, String imageHost, String proxyBase, String omdbApiKey, String omdbBaseUrl) { - return new Result( - timed(() -> testApi(client, credential, apiHost, proxyBase)), - timed(() -> testImage(client, imageHost, proxyBase)), - timed(() -> testOmdb(client, omdbApiKey, omdbBaseUrl))); - } - - static Check testApi(OkHttpClient client, String credential, String apiHost) { - return testApi(client, credential, apiHost, ""); - } - - static Check testApi(OkHttpClient client, String credential, String apiHost, String proxyBase) { - if (credential == null || credential.trim().isEmpty()) return Check.failed("API Key / Access Token is empty"); - TmdbConfig config = config(credential, apiHost, null, proxyBase, null); - Check last = Check.failed("API route unavailable"); - for (String route : config.getApiCandidates()) { - long started = System.nanoTime(); - try { - HttpUrl base = HttpUrl.parse(route + "/configuration"); - if (base == null) { - last = Check.failed("invalid URL"); - continue; - } - HttpUrl.Builder url = base.newBuilder(); - Request.Builder request = new Request.Builder().get(); - if (config.getAccessToken().isEmpty()) { - request.url(url.addQueryParameter("api_key", config.getApiKey()).build()); - } else { - request.url(url.build()).header("Authorization", "Bearer " + config.getAccessToken()); - } - try (Response response = client.newCall(request.build()).execute()) { - if (!response.isSuccessful()) { - last = Check.failed("HTTP " + response.code()); - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, route); - continue; - } - ResponseBody body = response.body(); - if (body == null) { - last = Check.failed("empty response"); - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, route); - continue; - } - JsonObject json = JsonParser.parseString(body.string()).getAsJsonObject(); - if (!json.has("images") || !json.get("images").isJsonObject()) { - last = Check.failed("response is not TMDB configuration data"); - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, route); - continue; - } - TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.API, route, - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)); - return Check.success(); - } - } catch (Exception e) { - last = Check.failed(message(e)); - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, route); - } - } - return last; - } - - static Check testImage(OkHttpClient client, String imageHost) { - return testImage(client, imageHost, ""); - } - - static Check testImage(OkHttpClient client, String imageHost, String proxyBase) { - TmdbConfig config = config(null, null, imageHost, proxyBase, null); - try { - Check last = Check.failed("image route unavailable"); - List candidates = config.getImageCandidates(); - for (String route : candidates) { - long started = System.nanoTime(); - String base = config.isImageAuto() ? TmdbProxy.imageBaseFor(route, "w342") : config.getImageBase(); - HttpUrl url = HttpUrl.parse(TmdbProxy.imageUrl(base, "/wwemzKWzjKYJFfCeiB57q3r4Bcm.png")); - if (url == null) continue; - try (Response response = client.newCall(new Request.Builder().url(url).get().build()).execute()) { - if (!response.isSuccessful()) { - last = Check.failed("HTTP " + response.code()); - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, route); - continue; - } - ResponseBody body = response.body(); - String type = response.header("Content-Type", "").toLowerCase(Locale.ROOT); - if (!type.startsWith("image/")) { - last = Check.failed("response is not an image"); - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, route); - continue; - } - if (body == null || body.contentLength() == 0) { - last = Check.failed("empty image"); - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, route); - continue; - } - byte[] prefix = body.source().peek().readByteArray(16); - if (!hasImageSignature(prefix)) { - last = Check.failed("invalid image data"); - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, route); - continue; - } - TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.IMAGE, route, - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)); - return Check.success(); - } - } - return last; - } catch (Exception e) { - return Check.failed(message(e)); - } - } - - static Check testOmdb(OkHttpClient client, String apiKey, String baseUrl) { - if (apiKey == null || apiKey.trim().isEmpty()) return Check.failed("OMDb API Key is empty"); - try { - HttpUrl base = parseHost(baseUrl); - if (base == null) return Check.failed("invalid OMDb URL"); - HttpUrl url = base.newBuilder() - .addQueryParameter("i", "tt0111161") - .addQueryParameter("apikey", apiKey.trim()) - .build(); - try (Response response = client.newCall(new Request.Builder().url(url).get().build()).execute()) { - if (!response.isSuccessful()) return Check.failed("HTTP " + response.code()); - ResponseBody body = response.body(); - if (body == null) return Check.failed("empty response"); - JsonObject json = JsonParser.parseString(body.string()).getAsJsonObject(); - if (json.has("Response") && "False".equalsIgnoreCase(json.get("Response").getAsString())) { - return Check.failed(json.has("Error") ? json.get("Error").getAsString() : "OMDb rejected the request"); - } - if (!json.has("imdbID") || !json.get("imdbID").getAsString().startsWith("tt")) { - return Check.failed("response is not valid OMDb data"); - } - return Check.success(); - } - } catch (Exception e) { - return Check.failed(message(e)); - } - } - - private static TmdbConfig config(String credential, String apiHost, String imageHost, String proxyBase, String omdbApiKey) { - JsonObject json = new JsonObject(); - String value = trim(credential); - if (value.split("\\.").length >= 3) json.addProperty("accessToken", value); - else json.addProperty("apiKey", value); - if (apiHost != null) { - if (TmdbProxy.isAuto(apiHost)) { - json.addProperty("apiAuto", true); - json.addProperty("apiBase", TmdbProxy.OFFICIAL_API); - } else json.addProperty("apiBase", trim(apiHost)); - } - if (imageHost != null) { - if (TmdbProxy.isAuto(imageHost)) { - json.addProperty("imageAuto", true); - json.addProperty("imageBase", TmdbProxy.OFFICIAL_IMAGE); - } else json.addProperty("imageBase", trim(imageHost)); - } - if (proxyBase != null) json.addProperty("proxyBase", trim(proxyBase)); - if (omdbApiKey != null) json.addProperty("omdbApiKey", trim(omdbApiKey)); - return TmdbConfig.objectFrom(json.toString()); - } - - private static String trim(String value) { - return value == null ? "" : value.trim(); - } - - private static Check timed(CheckSupplier supplier) { - long started = System.nanoTime(); - Check result; - try { - result = supplier.get(); - } catch (RuntimeException e) { - result = Check.failed(message(e)); - } - return result.withLatency(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)); - } - - private interface CheckSupplier { - Check get(); - } - - private static HttpUrl parseHost(String host) { - if (host == null || host.trim().isEmpty()) return null; - String value = host.trim(); - if (!value.endsWith("/")) value += "/"; - try { - HttpUrl url = HttpUrl.get(value); - return "http".equals(url.scheme()) || "https".equals(url.scheme()) ? url : null; - } catch (IllegalArgumentException e) { - return null; - } - } - - private static boolean hasImageSignature(byte[] value) { - if (value.length < 3) return false; - boolean jpeg = (value[0] & 0xff) == 0xff && (value[1] & 0xff) == 0xd8 && (value[2] & 0xff) == 0xff; - boolean png = value.length >= 8 && (value[0] & 0xff) == 0x89 && value[1] == 0x50 && value[2] == 0x4e && value[3] == 0x47; - boolean gif = value.length >= 6 && value[0] == 'G' && value[1] == 'I' && value[2] == 'F'; - boolean webp = value.length >= 12 && value[0] == 'R' && value[1] == 'I' && value[2] == 'F' && value[3] == 'F' - && value[8] == 'W' && value[9] == 'E' && value[10] == 'B' && value[11] == 'P'; - return jpeg || png || gif || webp; - } - - private static String message(Exception error) { - String value = error.getMessage(); - return value == null || value.trim().isEmpty() ? error.getClass().getSimpleName() : value; - } - - public static final class Result { - public final Check api; - public final Check image; - public final Check omdb; - - Result(Check api, Check image, Check omdb) { - this.api = api; - this.image = image; - this.omdb = omdb; - } - } - - public static final class Check { - public final boolean success; - public final String message; - public final long latencyMillis; - - private Check(boolean success, String message, long latencyMillis) { - this.success = success; - this.message = message; - this.latencyMillis = latencyMillis; - } - - static Check success() { - return new Check(true, "", 0); - } - - static Check failed(String message) { - return new Check(false, message, 0); - } - - Check withLatency(long latencyMillis) { - return new Check(success, message, Math.max(0, latencyMillis)); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/service/TmdbService.java b/app/src/main/java/com/fongmi/android/tv/service/TmdbService.java index a5e2ba613c..c1cf530522 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/TmdbService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/TmdbService.java @@ -5,16 +5,12 @@ import android.text.TextUtils; import androidx.annotation.NonNull; import com.fongmi.android.tv.App; -import com.fongmi.android.tv.api.config.SubscriptionTmdbCredentialStore; import com.fongmi.android.tv.bean.TmdbConfig; import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.bean.TmdbPerson; import com.fongmi.android.tv.bean.TmdbVideo; -import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.utils.TmdbImageSelector; -import com.fongmi.android.tv.utils.TmdbLanguagePolicy; -import com.fongmi.android.tv.utils.TmdbProxy; import com.github.catvod.crawler.SpiderDebug; import com.github.catvod.utils.Path; import com.google.gson.JsonArray; @@ -23,7 +19,6 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.io.File; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.ArrayList; @@ -39,8 +34,6 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; -import androidx.annotation.Nullable; - import okhttp3.HttpUrl; import okhttp3.Request; import okhttp3.Response; @@ -130,45 +123,6 @@ public class TmdbService { return requestJson(url, config, "detail", cacheKey, fallbackKeys, DETAIL_CACHE_TTL, "TMDB 详情返回为空", "TMDB 详情失败: HTTP ", false); } - public JsonObject detailForFollowing(@NonNull TmdbItem item, @NonNull TmdbConfig config, boolean refresh) throws Exception { - ensureReady(config); - String url = detailUrl(item, config, false); - List fallbackKeys = detailCacheKeys(item, config, false); - String cacheKey = "following-" + fallbackKeys.remove(0); - long ttl = TimeUnit.MINUTES.toMillis(30); - return requestJson(url, config, "detail-following", cacheKey, fallbackKeys, ttl, - "TMDB 追更详情返回为空", "TMDB 追更详情失败: HTTP ", refresh); - } - - public JsonObject detailForSource(@NonNull TmdbItem item, int seasonNumber, @NonNull TmdbConfig config, @NonNull Set missing) throws Exception { - ensureReady(config); - boolean includeRelated = missing.contains("recommendations") || missing.contains("similar"); - String url = detailUrl(item, config, includeRelated); - List fallbackKeys = detailCacheKeys(item, config, includeRelated); - return requestJson(url, config, "detail", sourceDetailCacheKey(item, seasonNumber, config, missing), fallbackKeys, DETAIL_CACHE_TTL, "TMDB 详情返回为空", "TMDB 详情失败: HTTP ", false); - } - - /** - * 自动匹配消歧专用详情:只取打分真正用到的字段。 - *

- * 消歧只读 {@code credits}/{@code aggregate_credits}(人物打分)与根对象基础字段 - * (标题/年份/国家/季数/分季标题),不读 images、translations、external_ids、 - * content_ratings、release_dates、recommendations、similar。2026-10-21 实测: - * TV 样例 446,404B → 335,387B(−25%),电影样例 192,107B → 56,056B(−71%); - * 最多 6 个候选时按倍数累积。 - *

- * 与 {@link #detail} 共用文件缓存机制但使用独立命名空间({@code detail-lite}), - * 避免 lite 结果污染完整详情缓存;必须保留 {@code aggregate_credits},因为 - * {@link #cast} 优先读它,去掉会改变人物打分输入。 - */ - public JsonObject detailForDisambiguation(@NonNull TmdbItem item, @NonNull TmdbConfig config) throws Exception { - ensureReady(config); - String url = detailUrl(item, config, false, true); - String cacheKey = disambiguationCacheKey(item, config); - return requestJson(url, config, "detail-lite", cacheKey, List.of(), DETAIL_CACHE_TTL, - "TMDB 详情返回为空", "TMDB 详情失败: HTTP ", false); - } - private String detailAppend(@NonNull TmdbItem item, boolean includeRelated) { boolean tv = "tv".equalsIgnoreCase(item.getMediaType()); String append = tv @@ -177,19 +131,11 @@ public class TmdbService { return includeRelated ? append + ",recommendations,similar" : append; } - private String disambiguationAppend(@NonNull TmdbItem item) { - return "tv".equalsIgnoreCase(item.getMediaType()) ? "credits,aggregate_credits" : "credits"; - } - private String detailUrl(@NonNull TmdbItem item, @NonNull TmdbConfig config, boolean includeRelated) { - return detailUrl(item, config, includeRelated, false); - } - - private String detailUrl(@NonNull TmdbItem item, @NonNull TmdbConfig config, boolean includeRelated, boolean disambiguation) { return apiBuilder(config.getApiBase() + "/" + item.getMediaType() + "/" + item.getTmdbId(), config) - .addQueryParameter("language", requestLanguage(config)) - .addQueryParameter("append_to_response", disambiguation ? disambiguationAppend(item) : detailAppend(item, includeRelated)) - .addQueryParameter("include_image_language", requestLanguage(config) + ",null") + .addQueryParameter("language", config.getLanguage()) + .addQueryParameter("append_to_response", detailAppend(item, includeRelated)) + .addQueryParameter("include_image_language", config.getLanguage() + ",null") .build() .toString(); } @@ -197,9 +143,9 @@ public class TmdbService { public JsonObject season(@NonNull TmdbItem item, int seasonNumber, @NonNull TmdbConfig config) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + "/tv/" + item.getTmdbId() + "/season/" + seasonNumber, config) - .addQueryParameter("language", requestLanguage(config)) + .addQueryParameter("language", config.getLanguage()) .addQueryParameter("append_to_response", "images,credits,aggregate_credits,translations") - .addQueryParameter("include_image_language", requestLanguage(config) + ",null") + .addQueryParameter("include_image_language", config.getLanguage() + ",null") .build(); return requestJson(url.toString(), config, "season", seasonCacheKey(item, seasonNumber, config), SEASON_CACHE_TTL, "TMDB 分季返回为空", "TMDB 分季失败: HTTP "); } @@ -211,9 +157,9 @@ public class TmdbService { public JsonObject season(@NonNull TmdbItem item, int seasonNumber, @NonNull TmdbConfig config, JsonObject detail, boolean refresh) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + "/tv/" + item.getTmdbId() + "/season/" + seasonNumber, config) - .addQueryParameter("language", requestLanguage(config)) + .addQueryParameter("language", config.getLanguage()) .addQueryParameter("append_to_response", "images,credits,aggregate_credits,translations") - .addQueryParameter("include_image_language", requestLanguage(config) + ",null") + .addQueryParameter("include_image_language", config.getLanguage() + ",null") .build(); return requestJson(url.toString(), config, "season", seasonCacheKey(item, seasonNumber, config), seasonCacheTtl(detail), "TMDB 分季返回为空", "TMDB 分季失败: HTTP ", refresh); } @@ -221,9 +167,9 @@ public class TmdbService { public JsonObject episode(@NonNull TmdbItem item, int seasonNumber, int episodeNumber, @NonNull TmdbConfig config, JsonObject detail) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + "/tv/" + item.getTmdbId() + "/season/" + seasonNumber + "/episode/" + episodeNumber, config) - .addQueryParameter("language", requestLanguage(config)) + .addQueryParameter("language", config.getLanguage()) .addQueryParameter("append_to_response", "images,credits,translations") - .addQueryParameter("include_image_language", requestLanguage(config) + ",null") + .addQueryParameter("include_image_language", config.getLanguage() + ",null") .build(); return requestJson(url.toString(), config, "episode", episodeCacheKey(item, seasonNumber, episodeNumber, config), seasonCacheTtl(detail), "TMDB 单集返回为空", "TMDB 单集失败: HTTP "); } @@ -231,9 +177,9 @@ public class TmdbService { public JsonObject episode(int tmdbId, int seasonNumber, int episodeNumber, @NonNull TmdbConfig config) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + "/tv/" + tmdbId + "/season/" + seasonNumber + "/episode/" + episodeNumber, config) - .addQueryParameter("language", requestLanguage(config)) + .addQueryParameter("language", config.getLanguage()) .addQueryParameter("append_to_response", "images,credits,translations") - .addQueryParameter("include_image_language", requestLanguage(config) + ",null") + .addQueryParameter("include_image_language", config.getLanguage() + ",null") .build(); return requestJson(url.toString(), config, "episode", episodeCacheKey(tmdbId, seasonNumber, episodeNumber, config), SEASON_CACHE_TTL, "TMDB 单集返回为空", "TMDB 单集失败: HTTP "); } @@ -241,7 +187,7 @@ public class TmdbService { public JsonObject person(int personId, @NonNull TmdbConfig config) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + "/person/" + personId, config) - .addQueryParameter("language", requestLanguage(config)) + .addQueryParameter("language", config.getLanguage()) .addQueryParameter("append_to_response", "combined_credits,images,translations,external_ids") .build(); if (System.currentTimeMillis() >= 0) return requestJson(url.toString(), config, "person", PERSON_CACHE_TTL, "TMDB 演员作品返回为空", "TMDB 演员作品失败: HTTP "); @@ -370,19 +316,16 @@ public class TmdbService { List items = new ArrayList<>(); try { JsonArray results = array(season, "episodes"); - String target = requestLanguage(config); for (JsonElement element : results) { try { if (!element.isJsonObject()) continue; JsonObject object = element.getAsJsonObject(); int number = object.has("episode_number") && !object.get("episode_number").isJsonNull() ? object.get("episode_number").getAsInt() : items.size() + 1; - String title = episodeValue(object, season, number, "name", target); - String overview = episodeValue(object, season, number, "overview", target); items.add(new TmdbEpisode( number, - title, + string(object, "name"), string(object, "air_date"), - overview, + string(object, "overview"), image(config.getBackdropBase(), string(object, "still_path")), object.has("vote_average") && !object.get("vote_average").isJsonNull() ? object.get("vote_average").getAsDouble() : 0, object.has("runtime") && !object.get("runtime").isJsonNull() ? object.get("runtime").getAsInt() : 0, @@ -530,7 +473,7 @@ public class TmdbService { private List requestVideos(TmdbItem item, String path, TmdbVideo.Scope scope, int seasonNumber, int episodeNumber, TmdbConfig config) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + path, config) - .addQueryParameter("language", requestLanguage(config)) + .addQueryParameter("language", config.getLanguage()) .addQueryParameter("include_video_language", videoLanguages(config)) .build(); String cacheKey = videoCacheKey(item, scope, seasonNumber, episodeNumber, config); @@ -590,77 +533,26 @@ public class TmdbService { String mediaType = normalizeMediaType(item.getMediaType()); if (TextUtils.isEmpty(mediaType) || item.getTmdbId() <= 0) return new ArrayList<>(); HttpUrl url = apiBuilder(config.getApiBase() + "/" + mediaType + "/" + item.getTmdbId() + "/" + type, config) - .addQueryParameter("language", requestLanguage(config)) + .addQueryParameter("language", config.getLanguage()) .addQueryParameter("page", String.valueOf(Math.max(1, page))) .build(); JsonObject body = requestJson(url.toString(), config, type, DETAIL_CACHE_TTL, "TMDB 推荐返回为空", "TMDB 推荐失败: HTTP "); return items(array(body, "results"), config, mediaType); } - public String preferredTitle(@Nullable TmdbItem item, @Nullable JsonObject detail, @NonNull TmdbConfig config) { - if (item == null || detail == null) return ""; - String primary = "movie".equalsIgnoreCase(item.getMediaType()) ? string(detail, "title", "name") : string(detail, "name", "title"); - return TmdbLanguagePolicy.bestDisplayValue(primary, array(detail, "translations", "translations"), preferredTitleKey(item), requestLanguage(config)); - } - - public String seasonName(@Nullable JsonObject season, @NonNull TmdbConfig config) { - return TmdbLanguagePolicy.bestDisplayValue(string(season, "name"), translations(season), "name", requestLanguage(config)); - } - - public String seasonOverview(@Nullable JsonObject season, @NonNull TmdbConfig config) { - return TmdbLanguagePolicy.bestDisplayValue(string(season, "overview"), translations(season), "overview", requestLanguage(config)); - } - public String translatedOverview(JsonObject detail, @NonNull TmdbConfig config) { - String preferred = TmdbLanguagePolicy.bestDisplayValue(string(detail, "overview"), translations(detail), "overview", requestLanguage(config)); - return preferred == null ? "" : preferred; - } - - private String preferredTitleKey(TmdbItem item) { - return "movie".equalsIgnoreCase(item.getMediaType()) ? "title" : "name"; - } - - private JsonArray translations(JsonObject object) { - return array(object, "translations", "translations"); - } - - private String episodeValue(JsonObject episode, JsonObject season, int number, String key, String target) { - JsonArray episodeTranslations = translations(episode); - if (episodeTranslations.size() > 0) { - String value = TmdbLanguagePolicy.bestDisplayValue(string(episode, key), episodeTranslations, key, target); - if (!TextUtils.isEmpty(value)) return value; - } - JsonObject seasonTranslation = seasonTranslationFor(season, number); - if (seasonTranslation != null) { - String value = TmdbLanguagePolicy.bestDisplayValue(string(episode, key), array(seasonTranslation, "translations"), key, target); - if (!TextUtils.isEmpty(value)) return value; - } - return string(episode, key); - } - - private JsonObject seasonTranslationFor(JsonObject season, int number) { - if (season == null || number <= 0) return null; - for (JsonElement element : array(season, "translations", "translations")) { - if (element == null || !element.isJsonObject()) continue; - JsonObject object = element.getAsJsonObject(); - JsonObject data = object.has("data") && object.get("data").isJsonObject() ? object.getAsJsonObject("data") : null; - if (data == null) continue; - JsonArray episodes = data.has("episode_translations") && data.get("episode_translations").isJsonArray() ? data.getAsJsonArray("episode_translations") : new JsonArray(); - for (JsonElement episodeElement : episodes) { - if (episodeElement == null || !episodeElement.isJsonObject()) continue; - JsonObject episode = episodeElement.getAsJsonObject(); - if (episode.has("episode_number") && !episode.get("episode_number").isJsonNull() && episode.get("episode_number").getAsInt() == number) return object; - } - } - return null; - } - - /** - * 请求语言与缓存分键共用同一个判定,两者必须同源:一旦不一致, - * 不同语言的响应用同一个键落盘,会把简体标题当成繁体的缓存命中。 - */ - private String requestLanguage(TmdbConfig config) { - return TmdbLanguagePolicy.requestLanguage(config, Setting.getLanguage()); + String current = string(detail, "overview"); + if (!TextUtils.isEmpty(current)) return current; + JsonArray translations = array(detail, "translations", "translations"); + String preferred = overviewForLanguage(translations, config.getLanguage()); + if (!TextUtils.isEmpty(preferred)) return preferred; + preferred = overviewForLanguage(translations, languageRoot(config.getLanguage())); + if (!TextUtils.isEmpty(preferred)) return preferred; + preferred = overviewForLanguage(translations, "zh-CN"); + if (!TextUtils.isEmpty(preferred)) return preferred; + preferred = overviewForLanguage(translations, "zh"); + if (!TextUtils.isEmpty(preferred)) return preferred; + return overviewForLanguage(translations, "en"); } public List personWorks(JsonObject person, @NonNull TmdbConfig config) { @@ -680,12 +572,12 @@ public class TmdbService { public String image(String base, String path) { if (TextUtils.isEmpty(path)) return ""; - return TmdbProxy.imageUrl(base, path); + return base + (path.startsWith("/") ? path : "/" + path); } private String searchUrl(String keyword, TmdbConfig config) { return apiBuilder(config.getApiBase() + "/search/multi", config) - .addQueryParameter("language", requestLanguage(config)) + .addQueryParameter("language", config.getLanguage()) .addQueryParameter("query", keyword) .build() .toString(); @@ -693,24 +585,14 @@ public class TmdbService { private void ensureReady(TmdbConfig config) { if (!config.sanitize().isReady()) throw new IllegalStateException("请先配置 TMDB API Key"); - ensureCredentialTransport(config); } private HttpUrl.Builder apiBuilder(String url, TmdbConfig config) { - ensureCredentialTransport(config); HttpUrl.Builder builder = HttpUrl.parse(url).newBuilder(); if (TextUtils.isEmpty(config.getAccessToken())) builder.addQueryParameter("api_key", config.getApiKey()); return builder; } - private void ensureCredentialTransport(TmdbConfig config) { - if (config == null || !config.isTransientSubscriptionCredential()) return; - if (!SubscriptionTmdbCredentialStore.isCurrent(config.getCredentialSubscriptionKey(), config.getCredentialScopeEpoch())) { - throw new IllegalStateException("TMDB 临时凭据已失效"); - } - if (!TmdbConfig.isOfficialApiBase(config.getApiBase())) throw new IllegalStateException("TMDB 临时凭据仅允许访问官方 HTTPS API"); - } - void throwIfAuthBlocked(TmdbConfig config) { if (Thread.currentThread().isInterrupted()) throw new CancellationException("TMDB request cancelled"); String key = authCircuitKey(config); @@ -726,9 +608,6 @@ public class TmdbService { RuntimeException httpFailure(TmdbConfig config, int statusCode, String message) { if (statusCode == 401 || statusCode == 403) { - if (config != null && config.isTransientSubscriptionCredential()) { - SubscriptionTmdbCredentialStore.clearIfCurrent(config.getCredentialSubscriptionKey(), config.getCredentialScopeEpoch()); - } AUTH_FAILURE_BLOCKS.put(authCircuitKey(config), System.currentTimeMillis() + AUTH_FAILURE_COOLDOWN); SpiderDebug.log("tmdb", "authentication circuit opened status=%d cooldown=%dms", statusCode, AUTH_FAILURE_COOLDOWN); return new AuthException(statusCode, message); @@ -749,43 +628,9 @@ public class TmdbService { private Response execute(String url, TmdbConfig config) throws Exception { throwIfAuthBlocked(config); - String currentBase = config.getApiBase(); - List candidates = config.getApiCandidates(); - Exception last = null; - for (String candidate : candidates) { - String requestUrl = url; - if (config.isApiAuto() && url.startsWith(currentBase)) { - requestUrl = candidate + url.substring(currentBase.length()); - } - long started = System.nanoTime(); - try { - Request.Builder builder = new Request.Builder().url(requestUrl); - if (!TextUtils.isEmpty(config.getAccessToken())) builder.header("Authorization", "Bearer " + config.getAccessToken()); - Response response = com.github.catvod.net.OkHttp.client().newCall(builder.build()).execute(); - if (response.isSuccessful() || !config.isApiAuto() || response.code() == 401 || response.code() == 403 || candidate.equals(candidates.get(candidates.size() - 1))) { - if (response.isSuccessful() && config.isApiAuto()) { - TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.API, candidate, - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)); - } - return response; - } - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, candidate); - response.close(); - } catch (Exception e) { - last = e; - if (!config.isApiAuto() || candidate.equals(candidates.get(candidates.size() - 1))) break; - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, candidate); - } - } - if (last != null) throw new IOException(redactMessage(last.getMessage()), last); - throw new IOException("TMDB request failed on all automatic routes"); - } - - public static String redactMessage(String message) { - if (TextUtils.isEmpty(message) || message.trim().isEmpty()) return "TMDB request failed"; - String redacted = message.replaceAll("(?i)([?&](?:api_key|apikey|key|token|access_token)=)[^&\\s]+", "$1"); - redacted = redacted.replaceAll("(?i)(Bearer\\s+)[A-Za-z0-9._~+/-]+=*", "$1"); - return redacted; + Request.Builder builder = new Request.Builder().url(url); + if (!TextUtils.isEmpty(config.getAccessToken())) builder.header("Authorization", "Bearer " + config.getAccessToken()); + return com.github.catvod.net.OkHttp.client().newCall(builder.build()).execute(); } private JsonObject requestVideoJson(String url, TmdbConfig config, String cacheKey) throws Exception { @@ -952,19 +797,6 @@ public class TmdbService { return cacheKey("detail", item.getMediaType(), item.getTmdbId(), cacheLanguage(config), includeRelated ? "full" : "core"); } - /** 消歧 lite 详情的缓存键:与完整详情、core 详情都不共用条目。 */ - String disambiguationCacheKey(@NonNull TmdbItem item, @NonNull TmdbConfig config) { - return cacheKey("detail-lite", item.getMediaType(), item.getTmdbId(), cacheLanguage(config)); - } - - String sourceDetailCacheKey(@NonNull TmdbItem item, int seasonNumber, @NonNull TmdbConfig config, @NonNull Set missing) { - List capabilities = new ArrayList<>(); - for (String capability : missing) if (!TextUtils.isEmpty(capability)) capabilities.add(capability.trim().toLowerCase(Locale.ROOT)); - capabilities.sort(String::compareTo); - String mask = capabilities.isEmpty() ? "none" : String.join(",", capabilities); - return cacheKey("tmdb-detail", item.getMediaType(), item.getTmdbId(), "season=" + Math.max(0, seasonNumber), "lang=" + cacheLanguage(config), "include=" + mask); - } - String searchCacheKey(@NonNull String keyword, @NonNull TmdbConfig config) { return cacheKey("search", keyword, cacheLanguage(config)); } @@ -1005,7 +837,8 @@ public class TmdbService { } private String cacheLanguage(TmdbConfig config) { - return requestLanguage(config); + String language = config == null ? "" : config.getLanguage(); + return TextUtils.isEmpty(language) ? "zh-CN" : language; } private boolean isOnAir(JsonObject detail) { diff --git a/app/src/main/java/com/fongmi/android/tv/setting/AppBranding.java b/app/src/main/java/com/fongmi/android/tv/setting/AppBranding.java index 4a25c9c260..def24dca63 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/AppBranding.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/AppBranding.java @@ -17,10 +17,7 @@ import androidx.core.graphics.drawable.RoundedBitmapDrawable; import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.bean.Config; import com.fongmi.android.tv.ui.activity.HomeActivity; -import com.fongmi.android.tv.utils.ImgUtil; import com.github.catvod.utils.Prefers; /** Owns the two supported built-in app branding choices. */ @@ -50,14 +47,8 @@ public final class AppBranding { return context.getString(R.string.app_name); } - /** - * 首页标题优先展示站点/配置自己的名字,只有两者都空时才回落到应用名。 - * 固定返回应用名会让所有站源都显示成「默影视」,看不出当前在哪个源。 - */ @NonNull public static String getDisplayName(@NonNull Context context, @Nullable String homeName, @Nullable String configName) { - if (homeName != null && !homeName.trim().isEmpty()) return homeName; - if (configName != null && !configName.trim().isEmpty()) return configName; return getName(context); } @@ -80,15 +71,9 @@ public final class AppBranding { return context.getString(iconLabelResource(getIconMode(context))); } - /** - * 首帧先落品牌图标,避免闪白;随后若线路配置带 logo 就异步覆盖上去。 - * 只用品牌图标会让配置里自带的线路图标彻底失效。 - */ + /** Uses the selected built-in icon in the app UI with a circular presentation. */ public static void applyLogo(@NonNull ImageView view) { view.setImageDrawable(logoDrawable(view.getContext())); - Config config = VodConfig.get().getConfig(); - String logo = config == null ? null : config.getLogo(); - if (logo != null && !logo.trim().isEmpty()) ImgUtil.logo(view, logo); } /** diff --git a/app/src/main/java/com/fongmi/android/tv/setting/ConfigSyncPolicy.java b/app/src/main/java/com/fongmi/android/tv/setting/ConfigSyncPolicy.java deleted file mode 100644 index de24136edf..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/ConfigSyncPolicy.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.fongmi.android.tv.setting; - -import android.text.TextUtils; - -public final class ConfigSyncPolicy { - - private ConfigSyncPolicy() {} - - public static boolean shouldSyncLive(String previousVodUrl, String currentLiveUrl) { - return TextUtils.equals(currentLiveUrl, previousVodUrl); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/setting/CustomCspSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/CustomCspSetting.java index 3733ac732a..cd7441bde6 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/CustomCspSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/CustomCspSetting.java @@ -463,20 +463,6 @@ public class CustomCspSetting { return load().getItems().size(); } - public static boolean matchesSearch(Item item, String query) { - if (item == null) return false; - if (item.isOther()) return matchesSearch(query, item.getName(), item.getOtherKey(), item.getOtherKeys(), item.getOtherText()); - return matchesSearch(query, item.getName(), item.getKey(), item.getApi(), item.getHomePage(), item.getUrl(), item.getExt(), item.getJar(), item.getExtensionsText()); - } - - public static boolean matchesSearch(String query, String... fields) { - String needle = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); - if (needle.isEmpty()) return true; - if (fields == null) return false; - for (String field : fields) if (field != null && field.toLowerCase(Locale.ROOT).contains(needle)) return true; - return false; - } - public static Count count() { return count(load()); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/DanmakuSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/DanmakuSetting.java index ee8d31cfbf..9b4be78185 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/DanmakuSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/DanmakuSetting.java @@ -5,9 +5,7 @@ import android.text.TextUtils; import androidx.media3.ui.danmaku.DanmakuConfig; -import com.fongmi.android.tv.api.CatSource; import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.node.NodeRuntime; import com.fongmi.android.tv.utils.Util; import com.github.catvod.utils.Prefers; @@ -300,46 +298,13 @@ public class DanmakuSetting { Prefers.put("danmaku_show_special", value); } - /** - * 真实配置过的接口地址:用户手填优先,其次订阅配置的 danmaku 字段。 - * - *

不含本地包兜底。设置界面的输入框回填必须用这个值,否则用户不改直接确定, - * {@link #putApiUrl} 会把运行时算出来的本机回环地址固化进 prefs,换包后指向一个死地址。 - */ - public static String getConfiguredApiUrl() { + public static String getEffectiveApiUrl() { String userUrl = getApiUrl(); if (!TextUtils.isEmpty(userUrl)) return userUrl.trim(); String configUrl = VodConfig.get().getConfig().getDanmaku(); return configUrl == null ? "" : configUrl.trim(); } - /** - * 搜索接口地址:真实配置优先,都没配时才回退到猫源 bundle 自带的弹幕服务。 - * - *

最后一档是为了本地包开箱可用:本地包把弹幕服务开在猫源端口之外(实测 9321), - * 但 index.config.js 里没有 danmaku 字段,没有这个回退时用户必须自己查出端口手填才有弹幕。 - */ - public static String getEffectiveApiUrl() { - String configured = getConfiguredApiUrl(); - return TextUtils.isEmpty(configured) ? bundleApiUrl() : configured; - } - - /** 当前订阅是否还是猫源本地包:与运行时是否就绪无关,探测窗口期内也必须为 true。 */ - public static boolean isBundleSource() { - return CatSource.isBundle(VodConfig.get().getConfig().getUrl()); - } - - /** - * 只有当前订阅还是猫源 bundle 时才回退。 - * - *

切到普通订阅时 {@code VodConfig} 不会停 node,{@code :node} 进程和端口都还活着, - * 只判存活会把本地包的地址继续交给普通线路——用户切源后仍看到「有接口」且真能搜到弹幕, - * 要重启 APP 才恢复正常。 - */ - private static String bundleApiUrl() { - return isBundleSource() ? NodeRuntime.danmakuApiUrl() : ""; - } - public static String getValidApiUrl() { String url = getEffectiveApiUrl(); return isValidApiUrl(url) ? url : ""; diff --git a/app/src/main/java/com/fongmi/android/tv/setting/DetailRuntimeModePolicy.java b/app/src/main/java/com/fongmi/android/tv/setting/DetailRuntimeModePolicy.java deleted file mode 100644 index a0aa8cc58e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/DetailRuntimeModePolicy.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.fongmi.android.tv.setting; - -import java.util.Objects; - -/** Resolves the detail mode for one loaded detail session without touching Android UI or network state. */ -public final class DetailRuntimeModePolicy { - - public record Input( - int configuredMode, - boolean tmdbReady, - boolean siteAllowed, - TmdbSourceState sourceState - ) { - public Input { - Objects.requireNonNull(sourceState, "sourceState"); - } - } - - public record Decision( - int runtimeMode, - boolean sourceOnly, - boolean networkAllowed - ) { - } - - public static Decision resolve(Input input) { - Objects.requireNonNull(input, "input"); - if (!Setting.isTmdbMode(input.configuredMode())) { - return new Decision(Setting.DETAIL_OPEN_DIRECT, false, false); - } - if (!input.siteAllowed()) { - return new Decision(Setting.DETAIL_OPEN_DIRECT, false, false); - } - boolean renderable = input.sourceState() == TmdbSourceState.RENDERABLE; - if (!renderable && !input.tmdbReady()) { - return new Decision(Setting.DETAIL_OPEN_DIRECT, false, false); - } - return new Decision( - input.configuredMode(), - renderable && !input.tmdbReady(), - input.tmdbReady() - ); - } - - private DetailRuntimeModePolicy() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicy.java b/app/src/main/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicy.java deleted file mode 100644 index 06fa23523d..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicy.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.fongmi.android.tv.setting; - -public final class FlagSwitchPlaybackPolicy { - - private FlagSwitchPlaybackPolicy() { - } - - public static boolean shouldDeferPlayback(boolean deferFlagSwitchPlayback, boolean programmaticSwitch) { - return deferFlagSwitchPlayback && !programmaticSwitch; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverPolicy.java b/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverPolicy.java deleted file mode 100644 index 6ee4c8100e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverPolicy.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.fongmi.android.tv.setting; - -public final class InterfaceFailoverPolicy { - - public static final int OFF = 0; - public static final int AUTO = 1; - public static final int CONFIRM = 2; - public static final int DEFAULT_MODE = AUTO; - public static final int MAX_ATTEMPTS = 3; - - private InterfaceFailoverPolicy() { - } - - public static int clampMode(int mode) { - if (mode < OFF || mode > CONFIRM) return DEFAULT_MODE; - return mode; - } - - public static boolean isAuto(int mode) { - return clampMode(mode) == AUTO; - } - - public static boolean isConfirm(int mode) { - return clampMode(mode) == CONFIRM; - } - - public static boolean shouldFailover(int mode) { - return isAuto(mode) || isConfirm(mode); - } - - public static int attemptLimit(int candidateCount) { - if (candidateCount < 0) candidateCount = 0; - return Math.min(candidateCount, MAX_ATTEMPTS); - } - - public static int fallbackLimit(int candidateCount) { - return Math.max(0, attemptLimit(candidateCount) - 1); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverState.java b/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverState.java deleted file mode 100644 index d15a133882..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceFailoverState.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.fongmi.android.tv.setting; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -/** Pure state for one VOD interface failover round. */ -public final class InterfaceFailoverState { - - private final int mode; - private final List candidates; - private final Set attempted = new LinkedHashSet<>(); - private int nextIndex; - private boolean cancelled; - private boolean selected; - - public InterfaceFailoverState(int mode, @Nullable String originUrl, @NonNull List candidateUrls) { - this.mode = InterfaceFailoverPolicy.clampMode(mode); - this.candidates = candidates(candidateUrls); - if (originUrl != null && !originUrl.isEmpty()) attempted.add(originUrl); - } - - @Nullable - public String nextAutomatic() { - if (cancelled || selected || !InterfaceFailoverPolicy.isAuto(mode)) return null; - while (nextIndex < candidates.size()) { - String url = candidates.get(nextIndex++); - if (attempted.add(url)) return url; - } - return null; - } - - @Nullable - public String select(int index) { - if (cancelled || selected || !InterfaceFailoverPolicy.isConfirm(mode) - || index < 0 || index >= candidates.size()) return null; - selected = true; - nextIndex = candidates.size(); - String url = candidates.get(index); - return attempted.add(url) ? url : null; - } - - public boolean shouldContinueAfterFailure() { - return !cancelled && !selected && InterfaceFailoverPolicy.isAuto(mode) - && nextIndex < candidates.size(); - } - - public void cancel() { - cancelled = true; - } - - @NonNull - public List candidates() { - return candidates; - } - - @NonNull - private static List candidates(@NonNull List values) { - int limit = InterfaceFailoverPolicy.fallbackLimit(values.size() + 1); - Set unique = new LinkedHashSet<>(); - for (String value : values) { - if (value != null && !value.isEmpty() && unique.size() < limit) unique.add(value); - } - return Collections.unmodifiableList(new ArrayList<>(unique)); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceOrderStore.java b/app/src/main/java/com/fongmi/android/tv/setting/InterfaceOrderStore.java deleted file mode 100644 index 2c37a5b007..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/InterfaceOrderStore.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.fongmi.android.tv.setting; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import android.text.TextUtils; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.bean.Config; -import com.github.catvod.utils.Prefers; -import com.google.gson.reflect.TypeToken; - -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; - -public final class InterfaceOrderStore { - - public static final String KEY_VOD = "interface_order_vod"; - private static final Type STRING_LIST = new TypeToken>() {}.getType(); - - private InterfaceOrderStore() { - } - - @NonNull - public static List getVodOrder() { - List values = read(); - return dedupe(values); - } - - public static void putVodOrder(@Nullable List urls) { - List normalized = urls == null ? new ArrayList<>() : dedupe(urls); - if (normalized.isEmpty()) Prefers.remove(KEY_VOD); - else Prefers.put(KEY_VOD, App.gson().toJson(normalized)); - } - - public static void saveVodConfigs(@NonNull List configs) { - List order = new ArrayList<>(); - for (Config config : configs) if (config != null) order.add(config.getUrl()); - for (String url : getVodOrder()) if (!order.contains(url)) order.add(url); - putVodOrder(order); - } - - @NonNull - public static List sortVodConfigs(@NonNull List configs) { - List available = new ArrayList<>(); - for (Config config : configs) if (config != null) available.add(config.getUrl()); - List order = sortUrls(available, getVodOrder()); - List result = new ArrayList<>(); - for (String url : order) { - Config match = find(configs, url); - if (match != null && !contains(result, match)) result.add(match); - } - for (Config item : configs) if (!contains(result, item)) result.add(item); - return result; - } - - @Nullable - public static Config findVodConfig(@NonNull List configs, @Nullable String url) { - return find(configs, url); - } - - @Nullable - private static Config find(@NonNull List configs, @Nullable String url) { - if (TextUtils.isEmpty(url)) return null; - for (Config item : configs) if (TextUtils.equals(item.getUrl(), url)) return item; - return null; - } - - private static boolean contains(@NonNull List items, @NonNull Config target) { - for (Config item : items) if (TextUtils.equals(item.getUrl(), target.getUrl())) return true; - return false; - } - - @NonNull - private static List read() { - String json = Prefers.getString(KEY_VOD, "[]"); - if (TextUtils.isEmpty(json)) return new ArrayList<>(); - try { - List values = App.gson().fromJson(json, STRING_LIST); - return values == null ? new ArrayList<>() : values; - } catch (Throwable ignored) { - return new ArrayList<>(); - } - } - - @NonNull - private static List dedupe(@NonNull List values) { - LinkedHashSet result = new LinkedHashSet<>(); - for (String value : values) if (!TextUtils.isEmpty(value)) result.add(value); - return new ArrayList<>(result); - } - - @NonNull - static List normalize(@NonNull List values) { - return Collections.unmodifiableList(dedupe(values)); - } - - @NonNull - static List sortUrls(@NonNull List available, @NonNull List savedOrder) { - List result = new ArrayList<>(); - List known = dedupe(available); - for (String url : dedupe(savedOrder)) { - if (known.contains(url) && !result.contains(url)) result.add(url); - } - for (String url : known) if (!result.contains(url)) result.add(url); - return result; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/setting/LiveSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/LiveSetting.java index 87fcb866e5..bedc8513d9 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/LiveSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/LiveSetting.java @@ -32,14 +32,6 @@ public class LiveSetting { Prefers.put("change", change); } - public static boolean isSourceFallback() { - return Prefers.getBoolean("live_source_fallback", true); - } - - public static void putSourceFallback(boolean fallback) { - Prefers.put("live_source_fallback", fallback); - } - public static boolean isInvert() { return Prefers.getBoolean("invert"); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/MpvPerformanceSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/MpvPerformanceSetting.java index 59e345740d..ef82428e0c 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/MpvPerformanceSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/MpvPerformanceSetting.java @@ -26,8 +26,6 @@ public final class MpvPerformanceSetting { public static final int HLS_LOWEST = 3; public static final int PRIORITY_PERFORMANCE = 0; public static final int PRIORITY_CONFIG = 1; - public static final int MULTICHANNEL_STEREO_COMPAT = 0; - public static final int MULTICHANNEL_PCM = 1; // Kept only to migrate the temporary four-choice test build. Formal UI // exposes direct / legacy / stable, and old "auto" values become legacy. public static final int VULKAN_BACKEND_AUTO = 0; @@ -35,11 +33,8 @@ public final class MpvPerformanceSetting { public static final int VULKAN_BACKEND_LEGACY = 2; public static final int VULKAN_BACKEND_STABLE = 3; - // 自动电视直出只由「设备是否支持零拷贝」决定,不再有全局暂停开关。 - // 历史上 506639c9b0 曾用全局 kill switch 暂停自动直出(当时 native Surface 拆除 - // 尚未加固),但 22f2333317 已明确要求生产环境保留自动直出,且 P4-1/P4-3 已修好 - // 拆除与终态释放;残留的开关会让自动档在电视上永久退化为 GPU 合成 + 拷贝, - // 4K 高帧率下更容易掉帧/音频欠载。 + // Keep automatic Surface direct output paused independently from hardware decoder quirks. + private static final boolean SURFACE_AUTO_STABILITY_GUARD_ENABLED = true; private static final boolean ZERO_COPY_DEVICE_GUARD_ENABLED = true; private static final String KEY_OUTPUT_MODE = "perf_mpv_output_mode"; private static final String KEY_HWDEC = "perf_mpv_hwdec"; @@ -47,12 +42,12 @@ public final class MpvPerformanceSetting { private static final String KEY_FRAME_DROP = "perf_mpv_frame_drop"; private static final String KEY_INTERPOLATION = "perf_mpv_interpolation"; private static final String KEY_SOFT_TUNE = "perf_mpv_soft_tune"; + private static final String KEY_VERBOSE_LOG = "perf_mpv_verbose_log"; private static final String KEY_FRAME_RATE = "perf_mpv_frame_rate"; private static final String KEY_HLS_BITRATE = "perf_mpv_hls_bitrate"; private static final String KEY_REBUFFER_MS = "perf_mpv_rebuffer_ms"; private static final String KEY_OPTION_PRIORITY = "perf_mpv_option_priority"; private static final String KEY_VULKAN_BACKEND = "perf_mpv_vulkan_backend"; - private static final String KEY_MULTICHANNEL_AUDIO = "perf_mpv_multichannel_audio"; private MpvPerformanceSetting() { } @@ -79,13 +74,8 @@ public final class MpvPerformanceSetting { }; } - /** - * 自动档是否允许进入电视直出。只剩设备级零拷贝保护这一个否决条件: - * 命中 {@link MpvHardwarePolicy} 的已知不兼容设备(Kirin/HiSilicon、x86 转译、 - * 模拟器)时仍强制 GPU + 兼容复制,其余设备恢复自动直出。 - */ public static boolean isAutoSurfaceDirectEnabled() { - return !isZeroCopyBlocked(); + return !SURFACE_AUTO_STABILITY_GUARD_ENABLED && !isZeroCopyBlocked(); } public static boolean isZeroCopyBlocked() { @@ -105,7 +95,7 @@ public final class MpvPerformanceSetting { return switch (clamp(outputMode, OUTPUT_AUTO, OUTPUT_SURFACE_DIRECT)) { case OUTPUT_SURFACE_DIRECT -> true; case OUTPUT_GPU -> false; - default -> leanback && autoEligible; + default -> !SURFACE_AUTO_STABILITY_GUARD_ENABLED && leanback && autoEligible; }; } @@ -223,6 +213,10 @@ public final class MpvPerformanceSetting { }; } + public static boolean isVerboseLog() { + return Prefers.getBoolean(KEY_VERBOSE_LOG); + } + public static int getFrameRateMode() { return resolveFrameRateMode(Prefers.getInt(KEY_FRAME_RATE, FRAME_RATE_SEAMLESS)); } @@ -315,27 +309,6 @@ public final class MpvPerformanceSetting { return isPerformancePriority() ? "播放性能优先" : "mpv.conf优先"; } - public static int getMultichannelAudioMode() { - return clamp(Prefers.getInt(KEY_MULTICHANNEL_AUDIO, - MULTICHANNEL_STEREO_COMPAT), - MULTICHANNEL_STEREO_COMPAT, MULTICHANNEL_PCM); - } - - public static void putMultichannelAudioMode(int value) { - Prefers.put(KEY_MULTICHANNEL_AUDIO, clamp(value, - MULTICHANNEL_STEREO_COMPAT, MULTICHANNEL_PCM)); - PlaybackPerformanceSetting.markOverride( - PlaybackPerformanceCatalog.MPV_MULTICHANNEL_AUDIO); - } - - public static boolean isMultichannelPcm() { - return getMultichannelAudioMode() == MULTICHANNEL_PCM; - } - - public static String getMultichannelAudioText() { - return isMultichannelPcm() ? "多声道 PCM" : "立体声兼容"; - } - public static int getVulkanBackend() { return normalizeVulkanBackend(Prefers.getInt( KEY_VULKAN_BACKEND, VULKAN_BACKEND_DIRECT)); @@ -377,6 +350,11 @@ public final class MpvPerformanceSetting { }; } + public static void putVerboseLog(boolean value) { + Prefers.put(KEY_VERBOSE_LOG, value); + PlaybackPerformanceSetting.markOverride(PlaybackPerformanceCatalog.MPV_VERBOSE_LOG); + } + public static void applyRecommended() { PlayerSetting.putMpvRender(PlayerSetting.MPV_RENDER_OPENGL); Prefers.put(KEY_OUTPUT_MODE, OUTPUT_AUTO); @@ -385,10 +363,10 @@ public final class MpvPerformanceSetting { Prefers.put(KEY_FRAME_DROP, FRAME_DROP_OUTPUT); Prefers.put(KEY_INTERPOLATION, false); Prefers.put(KEY_SOFT_TUNE, SOFT_TUNE_MILD); + Prefers.put(KEY_VERBOSE_LOG, false); Prefers.put(KEY_FRAME_RATE, FRAME_RATE_SEAMLESS); Prefers.put(KEY_HLS_BITRATE, HLS_HIGHEST); Prefers.put(KEY_VULKAN_BACKEND, VULKAN_BACKEND_DIRECT); - Prefers.put(KEY_MULTICHANNEL_AUDIO, MULTICHANNEL_STEREO_COMPAT); applyRebufferPreset(PlaybackPerformanceSetting.PROFILE_RECOMMENDED); } @@ -400,10 +378,10 @@ public final class MpvPerformanceSetting { Prefers.put(KEY_FRAME_DROP, FRAME_DROP_OUTPUT); Prefers.put(KEY_INTERPOLATION, false); Prefers.put(KEY_SOFT_TUNE, SOFT_TUNE_MILD); + Prefers.put(KEY_VERBOSE_LOG, false); Prefers.put(KEY_FRAME_RATE, FRAME_RATE_SEAMLESS); Prefers.put(KEY_HLS_BITRATE, HLS_HIGHEST); Prefers.put(KEY_VULKAN_BACKEND, VULKAN_BACKEND_DIRECT); - Prefers.put(KEY_MULTICHANNEL_AUDIO, MULTICHANNEL_STEREO_COMPAT); applyRebufferPreset(PlaybackPerformanceSetting.PROFILE_AUTO); } @@ -419,10 +397,10 @@ public final class MpvPerformanceSetting { Prefers.put(KEY_FRAME_DROP, FRAME_DROP_OUTPUT); Prefers.put(KEY_INTERPOLATION, false); Prefers.put(KEY_SOFT_TUNE, SOFT_TUNE_MILD); + Prefers.put(KEY_VERBOSE_LOG, false); Prefers.put(KEY_FRAME_RATE, FRAME_RATE_OFF); Prefers.put(KEY_HLS_BITRATE, HLS_8_MBPS); Prefers.put(KEY_VULKAN_BACKEND, VULKAN_BACKEND_DIRECT); - Prefers.put(KEY_MULTICHANNEL_AUDIO, MULTICHANNEL_STEREO_COMPAT); applyRebufferPreset(PlaybackPerformanceSetting.PROFILE_LIGHTWEIGHT); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceCatalog.java b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceCatalog.java index 14476e58c3..694cec832f 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceCatalog.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceCatalog.java @@ -43,11 +43,11 @@ public final class PlaybackPerformanceCatalog { public static final String MPV_FRAME_DROP = "mpv_frame_drop"; public static final String MPV_INTERPOLATION = "mpv_interpolation"; public static final String MPV_SOFT_TUNE = "mpv_soft_tune"; + public static final String MPV_VERBOSE_LOG = "mpv_verbose_log"; public static final String MPV_FRAME_RATE = "mpv_frame_rate"; public static final String MPV_HLS_BITRATE = "mpv_hls_bitrate"; public static final String MPV_REBUFFER = "mpv_rebuffer"; public static final String MPV_OPTION_PRIORITY = "mpv_option_priority"; - public static final String MPV_MULTICHANNEL_AUDIO = "mpv_multichannel_audio"; public static final String IJK_SCENE = "ijk_scene"; public static final String IJK_BUFFER = "ijk_buffer"; public static final String IJK_PACKET_BUFFERING = "ijk_packet_buffering"; @@ -110,9 +110,9 @@ public final class PlaybackPerformanceCatalog { options.add(option(LOAD_SELECTED_TRACKS, BUFFER, "只加载选中轨道", "作用:只请求当前音视频轨道,减少带宽和内存。网速/内存紧张建议开启;经常切换清晰度、音轨时可关闭以减少重新请求。代价:切换轨道可能需要重新缓冲。")); addPreload(options, false); options.add(option(CODEC_ASYNC, DECODE, "MediaCodec 队列", "作用:决定解码输出由异步还是同步队列驱动。保持自动(默认)通常吞吐最高;只有旧设备异步回调异常时才改同步。代价:同步可能更稳,但会增加等待和 CPU 调度压力。")); - options.add(option(DYNAMIC_SCHEDULING, DECODE, "Media3 动态调度", "作用:按渲染器可工作时间调度播放循环。开启后通常更省 CPU;是否“掉帧更少”取决于机型时序精度,电视盒上不保证,出现卡顿可先关此项对比。代价:关闭后可能增加无效唤醒。")); - options.add(option(DURATION_PROGRESS, DECODE, "解码耗时推进", "作用:把异步解码耗时反馈给播放器,让播放循环按预计出帧时间推迟唤醒。默认关闭(与上游一致):它只在动态调度开启且解码器为异步时生效,省 CPU 但会把唤醒预算压到很近,时序精度较差的设备上可能表现为掉帧。仅在确认设备时序稳定时可开启;同步队列不生效。")); - options.add(option(LATE_DROP, DECODE, "输入丢帧阈值", "作用:输入帧迟到超过阈值(15 毫秒,与上游默认一致)时提前丢弃,优先保证“跟上进度”。CPU不足、4K掉帧时建议开启(默认);希望保留每一帧可关闭。代价:开启时画面可能跳帧,但通常比持续延迟更容易接受;关闭后不丢输入帧,解码跟不上时可能累积音画延迟。")); + options.add(option(DYNAMIC_SCHEDULING, DECODE, "Media3 动态调度", "作用:按渲染器可工作时间调度播放循环。保持开启(默认)通常更省 CPU、掉帧更少;遇到特定机型时序异常再关闭。代价:关闭后可能增加无效唤醒。")); + options.add(option(DURATION_PROGRESS, DECODE, "解码耗时推进", "作用:把异步解码耗时反馈给播放器,减少无效等待。异步队列下建议开启(默认);同步队列不生效。代价很小,关闭只用于排查时序问题。")); + options.add(option(LATE_DROP, DECODE, "输入丢帧阈值", "作用:输入帧明显迟到时提前丢弃,优先保证“跟上进度”。CPU不足、4K掉帧时建议开启;希望保留每一帧可关闭。代价:画面可能跳帧,但通常比持续延迟更容易接受。")); options.add(option(SURFACE_FIXED_SIZE, DECODE, "Surface 固定尺寸", "作用:按视频尺寸创建 Surface,减少超高分辨率合成压力。电视4K建议开启(默认);切清晰度/旋转出现画面尺寸异常时关闭。代价:少数设备切换分辨率需要重建 Surface。")); options.add(option(DECODER_FALLBACK, DECODE, "解码器兜底", "作用:首选硬解初始化失败时尝试其他解码器。兼容性优先建议开启(默认);只想快速暴露硬件问题可关闭。代价:可能多等待一次初始化,且备用解码器性能可能较低。")); options.add(option(DV7_HDR10_FALLBACK, DECODE, "DV7处理", "默认“升级P8.1”:设备不支持当前DV7硬解、但支持P8.1硬解时,使用libdovi mode 2实时改写RPU并丢弃增强层;原生DV7可硬解时保持原样。P8.1模式会锁定整次播放,不会自动降级HDR10;转换数据无效时会停止播放。选择“降级HDR10”会整次使用基底层,兼容性更高但失去Dolby Vision动态元数据。")); @@ -139,9 +139,9 @@ public final class PlaybackPerformanceCatalog { options.add(option(MPV_FRAME_DROP, DECODE, "丢帧策略", "怎么选:保持“输出丢帧”(默认),跟不上时优先丢渲染帧以维持音画进度;卡顿仍严重可试“解码丢帧”;不要为追求完整画面关闭丢帧,除非设备性能充足。代价:策略越积极,跳帧越明显。")); options.add(option(MPV_INTERPOLATION, DECODE, "平滑运动", "怎么选:默认关闭。只有GPU余量充足、使用GPU渲染+显示重采样且想改善低帧率运动时才开启;电视4K、HDR、LUT或已经卡顿时必须关闭。代价:会明显增加GPU负载,电视直出时不生效。")); options.add(option(MPV_SOFT_TUNE, DECODE, "软解降负载", "作用:仅软件解码时减少滤波和解码工作。默认“温和”;软解仍掉帧可选“积极”;硬解视频无需靠它提速。代价:模式越积极,细节和画面连续性损失越大。")); - options.add(option(DV7_HDR10_FALLBACK, DECODE, "DV7处理", "默认“升级P8.1”和“降级HDR10”保持原有行为:设备能原生播放DV7时优先原生播放,否则转换P8.1或使用HDR10基底层。“FEL 双层重建”仅手动选择后启用:保留BL、EL和RPU,基底层按现有硬解设置解码,增强层使用软件解码,由GPU完成残差重建,不走原生直出。只对DV7生效,普通视频不改变输出路径。该模式会增加CPU/GPU负载,流畅度取决于设备和片源;无增强层的片源不会凭空增加画质。切换后当前播放会按原有设置应用流程重载并保留进度。")); + options.add(option(MPV_VERBOSE_LOG, DECODE, "详细日志", "怎么选:正常播放保持“正常”(默认);只在排查崩溃、解码或缓冲问题时临时打开详细日志。代价:增加JNI、字符串处理和日志I/O,可能干扰低性能设备的流畅度。")); + options.add(option(DV7_HDR10_FALLBACK, DECODE, "DV7回退HDR10", "作用:MPV 自动输出使用 GPU 路径时,允许 Dolby Vision Profile 7 使用 HDR10 基底层播放,默认开启。关闭后会优先尝试原生 DV7 电视直出,适配能力声明不完整但实际可解码的设备;若设备确实不支持,可能黑屏或播放失败,MPV 字幕、LUT 和 GPU 滤镜也可能不可用。")); options.add(option(AUDIO_PASSTHROUGH, AUDIO, "音频直通", "怎么选:电视/功放明确支持Dolby、DTS且需要多声道时开启;出现无声、杂音或同步异常立即关闭。代价:压缩音频交给外部设备后,MPV无法完成所有混音和重采样处理。")); - options.add(option(MPV_MULTICHANNEL_AUDIO, AUDIO, "非直通多声道", "怎么选:默认“立体声兼容”,电视直出遇到不能直通的多声道音轨时优先切换同语言2.0音轨,性能和设备兼容性最好;选择“多声道 PCM”会保留当前5.1/7.1音轨并由MPV解码输出,可能增加CPU负担,最终声道数仍取决于电视或功放的PCM能力。")); options.add(option(PREFER_AAC, AUDIO, "AAC 优先", "怎么选:高级音轨无声或设备兼容性差时开启;功放支持原始多声道、希望保留最佳音轨时关闭。代价:可能从Dolby/DTS切到质量或声道较低的AAC。")); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceSetting.java index 931d987437..f4adabfc4c 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceSetting.java @@ -2,7 +2,6 @@ package com.fongmi.android.tv.setting; import android.content.SharedPreferences; -import com.fongmi.android.tv.player.exo.ExoDecoderRuntimeProfiles; import com.github.catvod.utils.Prefers; import java.util.Collections; @@ -20,7 +19,6 @@ public class PlaybackPerformanceSetting { public static final int DV7_HANDLING_P81 = 0; public static final int DV7_HANDLING_HDR10 = 1; - public static final int DV7_HANDLING_FEL = 2; public static final String KEY_PROFILE = "playback_performance_profile"; private static final String KEY_PROFILE_MIGRATED = "playback_performance_profile_per_kernel"; @@ -34,8 +32,6 @@ public class PlaybackPerformanceSetting { private static final String KEY_EXO_LOAD_CONTROL_MIGRATED = "playback_performance_exo_load_control_v2"; private static final String KEY_EXO_BACK_BUFFER_MIGRATED = "playback_performance_exo_back_buffer_v1"; private static final String KEY_EXO_REBUFFER_MIGRATED = "playback_performance_exo_rebuffer_v3"; - private static final String KEY_EXO_FRAME_SCHEDULING_PARITY_MIGRATED = - "playback_performance_exo_frame_scheduling_parity_v1"; private static final String KEY_MPV_REBUFFER_MIGRATED = "playback_performance_mpv_rebuffer_v1"; private static final String KEY_MPV_AUTO_BASELINE_MIGRATED = "playback_performance_mpv_auto_baseline_v1"; private static final String KEY_PROFILE_MERGE_SCHEMA = @@ -59,7 +55,6 @@ public class PlaybackPerformanceSetting { private static final String KEY_DECODER_FALLBACK = "perf_decoder_fallback"; private static final String KEY_DV7_HDR10_FALLBACK_LEGACY = "perf_dv7_hdr10_fallback"; - private static final String KEY_MPV_DV7_HANDLING = "perf_mpv_dv7_handling"; private static final String KEY_DV7_HANDLING = "perf_dv7_handling"; private static final String KEY_DEFERRED_CUES = "perf_deferred_cues"; private static final String KEY_SOFT_VIDEO_TUNE = "perf_soft_video_tune"; @@ -82,7 +77,6 @@ public class PlaybackPerformanceSetting { migrateExoLoadControl(); migrateExoBackBuffer(); migrateExoRebuffer(); - migrateExoFrameSchedulingParity(); migrateMpvRebuffer(); migrateMpvAutoBaseline(); // The former recommended-profile rollback must not run after the @@ -124,7 +118,6 @@ public class PlaybackPerformanceSetting { Prefers.put("exo_4k_compat", true); } else if (kernel == PlayerSetting.MPV) { MpvPerformanceSetting.applyRecommended(); - put(KEY_MPV_DV7_HANDLING, DV7_HANDLING_P81); } else { IjkPerformanceSetting.applyRecommended(); } @@ -140,7 +133,6 @@ public class PlaybackPerformanceSetting { Prefers.put("exo_4k_compat", true); } else if (kernel == PlayerSetting.MPV) { MpvPerformanceSetting.applyAuto(); - put(KEY_MPV_DV7_HANDLING, DV7_HANDLING_P81); } else { IjkPerformanceSetting.applyRecommended(); } @@ -175,7 +167,6 @@ public class PlaybackPerformanceSetting { Prefers.put("exo_4k_compat", true); } else if (kernel == PlayerSetting.MPV) { MpvPerformanceSetting.applyLightweight(); - put(KEY_MPV_DV7_HANDLING, DV7_HANDLING_P81); } else { IjkPerformanceSetting.applyLightweight(); } @@ -215,14 +206,10 @@ public class PlaybackPerformanceSetting { } public static String getProfileName() { - return getProfileName(PlayerSetting.getPlayer()); - } - - public static String getProfileName(int kernel) { - int profile = getProfile(kernel); + int profile = getProfile(); return switch (profile) { case PROFILE_AUTO -> { - int count = getOverrideCount(kernel); + int count = getOverrideCount(PlayerSetting.getPlayer()); yield count == 0 ? "自动" : "自动(已覆盖" + count + "项)"; } case PROFILE_COMPATIBLE, @@ -335,7 +322,7 @@ public class PlaybackPerformanceSetting { public static boolean isVideoDurationProgressEnabled() { ensureInitialized(); - return Prefers.getBoolean(KEY_VIDEO_DURATION_PROGRESS, false); + return Prefers.getBoolean(KEY_VIDEO_DURATION_PROGRESS, true); } public static void putVideoDurationProgressEnabled(boolean value) { @@ -399,14 +386,15 @@ public class PlaybackPerformanceSetting { public static boolean isDv7Hdr10FallbackEnabled() { ensureInitialized(); if (PlayerSetting.getPlayer() == PlayerSetting.MPV) { - return getMpvDv7HandlingMode() == DV7_HANDLING_HDR10; + return Prefers.getBoolean(KEY_DV7_HDR10_FALLBACK_LEGACY, true); } return getDv7HandlingMode() == DV7_HANDLING_HDR10; } public static void putDv7Hdr10FallbackEnabled(boolean value) { if (PlayerSetting.getPlayer() == PlayerSetting.MPV) { - putMpvDv7HandlingMode(value ? DV7_HANDLING_HDR10 : DV7_HANDLING_P81); + putCustom(KEY_DV7_HDR10_FALLBACK_LEGACY, value, + PlaybackPerformanceCatalog.DV7_HDR10_FALLBACK); } else { putDv7HandlingMode(value ? DV7_HANDLING_HDR10 : DV7_HANDLING_P81); } @@ -446,48 +434,6 @@ public class PlaybackPerformanceSetting { putCustom(KEY_DV7_HANDLING, clampDv7Handling(mode), PlaybackPerformanceCatalog.DV7_HDR10_FALLBACK); } - public static int getMpvDv7HandlingMode() { - ensureInitialized(); - if (Prefers.getPrefers().contains(KEY_MPV_DV7_HANDLING)) - return clampMpvDv7Handling(Prefers.getInt(KEY_MPV_DV7_HANDLING, DV7_HANDLING_P81)); - if (Prefers.getPrefers().contains(KEY_DV7_HDR10_FALLBACK_LEGACY)) { - return Prefers.getBoolean(KEY_DV7_HDR10_FALLBACK_LEGACY, true) - ? DV7_HANDLING_HDR10 : DV7_HANDLING_P81; - } - return DV7_HANDLING_P81; - } - - public static void putMpvDv7HandlingMode(int mode) { - putCustom(KEY_MPV_DV7_HANDLING, clampMpvDv7Handling(mode), - PlaybackPerformanceCatalog.DV7_HDR10_FALLBACK); - } - - public static String getMpvDv7HandlingText() { - return mpvDv7HandlingText(getMpvDv7HandlingMode()); - } - - static String mpvDv7HandlingText(int mode) { - return switch (clampMpvDv7Handling(mode)) { - case DV7_HANDLING_HDR10 -> "降级HDR10"; - case DV7_HANDLING_FEL -> "FEL 双层重建"; - default -> "升级P8.1"; - }; - } - - public static int nextMpvDv7HandlingMode(int mode) { - return (clampMpvDv7Handling(mode) + 1) % 3; - } - - public static String getMpvDv7HandlingOption() { - return switch (getMpvDv7HandlingMode()) { - case DV7_HANDLING_HDR10 -> "hdr10"; - // FEL needs the original BL, EL and RPU; it must not run P8.1's - // destructive enhancement-layer filter. - case DV7_HANDLING_FEL -> "preserve"; - default -> "p81"; - }; - } - public static boolean isSoftVideoTuneEnabled() { ensureInitialized(); return Prefers.getBoolean(KEY_SOFT_VIDEO_TUNE, true); @@ -499,11 +445,7 @@ public class PlaybackPerformanceSetting { public static String getSummary() { ensureInitialized(); - // The decoder exclusion is EXO-only, while the profile name follows the selected kernel. - // Appending it for MPV/IJK would report a hardware-decode fallback that cannot happen there. - String exclusions = PlayerSetting.getPlayer() == PlayerSetting.EXO - ? ExoDecoderRuntimeProfiles.process().blacklistSummary() : ""; - return exclusions.isEmpty() ? getProfileName() : getProfileName() + "," + exclusions; + return getProfileName(); } public static String getForwardBufferText() { @@ -586,7 +528,7 @@ public class PlaybackPerformanceSetting { private static void putRecommendedFlags() { put(KEY_CODEC_ASYNC_QUEUEING, true); put(KEY_DYNAMIC_SCHEDULING, true); - put(KEY_VIDEO_DURATION_PROGRESS, false); + put(KEY_VIDEO_DURATION_PROGRESS, true); put(KEY_LATE_DROP_INPUT, true); put(KEY_TRACK_LIMIT, true); put(KEY_ADAPTIVE_DOWNGRADE, true); @@ -607,10 +549,6 @@ public class PlaybackPerformanceSetting { return mode == DV7_HANDLING_HDR10 ? DV7_HANDLING_HDR10 : DV7_HANDLING_P81; } - static int clampMpvDv7Handling(int mode) { - return mode == DV7_HANDLING_FEL ? DV7_HANDLING_FEL : clampDv7Handling(mode); - } - private static void put(String key, boolean value) { Prefers.put(key, value); } @@ -736,20 +674,6 @@ public class PlaybackPerformanceSetting { return clampProfile(profile) != PROFILE_CUSTOM; } - private static void migrateExoFrameSchedulingParity() { - if (Prefers.getBoolean(KEY_EXO_FRAME_SCHEDULING_PARITY_MIGRATED)) return; - int profile = clampProfile(Prefers.getInt(profileKey(PlayerSetting.EXO), PROFILE_RECOMMENDED)); - if (shouldMigrateExoFrameSchedulingParity(profile) - && !isOverridden(PlayerSetting.EXO, PlaybackPerformanceCatalog.DURATION_PROGRESS)) { - put(KEY_VIDEO_DURATION_PROGRESS, false); - } - Prefers.put(KEY_EXO_FRAME_SCHEDULING_PARITY_MIGRATED, true); - } - - static boolean shouldMigrateExoFrameSchedulingParity(int profile) { - return clampProfile(profile) != PROFILE_CUSTOM; - } - private static void migrateMpvRebuffer() { if (Prefers.getBoolean(KEY_MPV_REBUFFER_MIGRATED)) return; int profile = clampProfile(Prefers.getInt(profileKey(PlayerSetting.MPV), PROFILE_RECOMMENDED)); @@ -961,22 +885,10 @@ public class PlaybackPerformanceSetting { private static int rawProfileValue(String key, int fallback) { try { - SharedPreferences prefs = Prefers.getPrefers(); - if (prefs.contains(key)) { - try { - return clampProfile(prefs.getInt(key, fallback)); - } catch (ClassCastException ignored) { - } - try { - return clampProfile((int) prefs.getLong(key, fallback)); - } catch (ClassCastException ignored) { - } - try { - return clampProfile((int) prefs.getFloat(key, fallback)); - } catch (ClassCastException ignored) { - } - } - return clampProfile(fallback); + Object value = Prefers.getPrefers().getAll().get(key); + return value instanceof Number + ? clampProfile(((Number) value).intValue()) + : clampProfile(fallback); } catch (Throwable ignored) { return clampProfile(fallback); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicy.java b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicy.java index ee8f986ca3..e04cd99bbe 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicy.java @@ -21,8 +21,7 @@ public final class PlaybackPerformanceUiPolicy { PlaybackPerformanceCatalog.MPV_HWDEC, PlaybackPerformanceCatalog.MPV_FRAME_RATE, PlaybackPerformanceCatalog.MPV_OPTION_PRIORITY, - PlaybackPerformanceCatalog.AUDIO_PASSTHROUGH, - PlaybackPerformanceCatalog.MPV_MULTICHANNEL_AUDIO); + PlaybackPerformanceCatalog.AUDIO_PASSTHROUGH); private static final Set IJK_COMMON = Set.of( PlaybackPerformanceCatalog.IJK_SCENE, PlaybackPerformanceCatalog.IJK_BUFFER, diff --git a/app/src/main/java/com/fongmi/android/tv/setting/PlayerButtonSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/PlayerButtonSetting.java index f711d22a7a..fa95e078e9 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/PlayerButtonSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/PlayerButtonSetting.java @@ -53,12 +53,11 @@ public class PlayerButtonSetting { private static final String ORDER = "player_button_order"; private static final String HIDDEN = "player_button_hidden"; - private static final String HIDDEN_SEEDED = "player_button_hidden_seeded"; private static final List DEFAULT = List.of( new Item(PLAYER, R.string.play_exo), new Item(DECODE, R.string.play_decode), new Item(PLAY_PARAMS, R.string.play_params), - new Item(MULTI_THREAD_PROXY, R.string.multi_thread_proxy_button, false), + new Item(MULTI_THREAD_PROXY, R.string.multi_thread_proxy_button), new Item(CODEC_CAPABILITY, R.string.codec_capability_short), new Item(SPEED, R.string.play_speed), new Item(SCALE, R.string.play_scale), @@ -140,7 +139,6 @@ public class PlayerButtonSetting { public static void reset() { Prefers.remove(ORDER); Prefers.remove(HIDDEN); - Prefers.remove(HIDDEN_SEEDED); } public static void applyOrder(ViewGroup container, Map views) { @@ -217,11 +215,6 @@ public class PlayerButtonSetting { private static Set getHidden() { Set hidden = new HashSet<>(); for (String id : split(Prefers.getString(HIDDEN))) if (contains(id)) hidden.add(id); - if (Prefers.getBoolean(HIDDEN_SEEDED)) return hidden; - // 首次运行只写入 DEFAULT 中标记为不显示的按钮,之后完全以用户在播放设置-播放器按钮中的选择为准。 - for (Item item : DEFAULT) if (!item.visible()) hidden.add(item.id()); - Prefers.put(HIDDEN, join(hidden)); - Prefers.put(HIDDEN_SEEDED, true); return hidden; } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/PlayerSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/PlayerSetting.java index e445451bca..7bd606c086 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/PlayerSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/PlayerSetting.java @@ -25,6 +25,10 @@ public class PlayerSetting { public static final int[] KERNEL_ORDER = {EXO, IJK, MPV, SYSTEM}; public static final int RENDER_SURFACE = 0; public static final int RENDER_TEXTURE = 1; + public static final int FFMPEG_MODE_NEXTLIB = 0; + public static final int FFMPEG_MODE_OFFICIAL = 1; + public static final int FFMPEG_MODE_SIMPLE = 2; + public static final int FFMPEG_MODE_AUTO = 3; public static final int MPV_RENDER_OPENGL = 0; public static final int MPV_RENDER_VULKAN = 1; public static final int AUDIO_BACKGROUND_ARTWORK = 0; @@ -58,6 +62,7 @@ public class PlayerSetting { private static final int DEFAULT_PLAY_CACHE_OPTION = 0; private static final String KEY_IMMERSIVE_AUDIO_PLAYBACK = "immersive_audio_playback"; private static final String KEY_FAILURE_FALLBACK = "player_failure_fallback"; + private static final String KEY_FFMPEG_MODE = "ffmpeg_mode"; private static final String KEY_CUSTOM_ASPECT_WIDTH = "custom_aspect_width"; private static final String KEY_CUSTOM_ASPECT_HEIGHT = "custom_aspect_height"; private static final String KEY_BRIGHTNESS_MIGRATED = "player_brightness_migrated"; @@ -68,10 +73,6 @@ public class PlayerSetting { private static final String KEY_DISPLAY_PROGRESS = "display_progress"; private static final String KEY_DISPLAY_MINI = "display_mini"; private static final String KEY_DISPLAY_TITLE = "display_title"; - private static final String KEY_DISPLAY_MEDIA_BADGES = "player_osd_media_badges"; - /** 控制栏隐藏时是否继续常驻显示媒体信息;总开关是「媒体信息位置」那一行。 */ - private static final String KEY_DISPLAY_MEDIA_BADGES_PERSIST = "player_osd_media_badges_persist"; - private static final String KEY_MEDIA_BADGE_POSITION = "player_osd_media_badge_position"; private static final String KEY_OSD_TITLE = "player_osd_title"; private static final String KEY_OSD_RESOLUTION = "player_osd_resolution"; private static final String KEY_OSD_TIME = "player_osd_time"; @@ -80,12 +81,6 @@ public class PlayerSetting { private static final String KEY_OSD_MINI = "player_osd_mini"; private static boolean legacyOsdMigrated; private static boolean legacyBrightnessMigrated; - /** - * 当前播放会话正在使用的内核(NONE 表示没有会话,按全局默认)。 - * 只存在于内存:进程内所有「现在用的是哪个内核」的判断都读它,退出播放后清空。 - * 由 PlayerManager 在建引擎/切引擎时写入,volatile 以便工作线程(取播放地址)读到最新值。 - */ - private static volatile int activePlayer = NONE; public static int getPlayer() { int player = Prefers.getInt("player", EXO); @@ -98,25 +93,6 @@ public class PlayerSetting { Prefers.put("player", sanitizePlayer(player)); } - /** - * 当前实际在跑的内核,与全局默认内核解耦。 - * 播放器里切内核只影响本次播放(并由 History 按剧集记住),不再回写全局默认, - * 因此运行期凡是问「现在用的是哪个内核」的地方都要读这里,而不是 getPlayer(); - * getPlayer() 只代表设置页里的全局默认值,也是没有播放会话时的兜底。 - */ - public static int getActivePlayer() { - int player = activePlayer; - return isPlayer(player) ? player : getPlayer(); - } - - public static void putActivePlayer(int player) { - activePlayer = isPlayer(player) ? player : NONE; - } - - public static void clearActivePlayer() { - activePlayer = NONE; - } - public static boolean isImmersiveAudioMode() { return Prefers.getBoolean("immersive_audio_mode", true); } @@ -449,24 +425,6 @@ public class PlayerSetting { Prefers.put("player_auto_play", autoPlay); } - /** Whether MPV should enter the HDMV Blu-ray menu instead of the main title. */ - public static boolean isBlurayMenu() { - return Prefers.getBoolean("playback_bluray_menu", false); - } - - public static void putBlurayMenu(boolean enabled) { - Prefers.put("playback_bluray_menu", enabled); - } - - /** Whether switching to another line must wait for an explicit play action. */ - public static boolean isDeferFlagSwitchPlayback() { - return Prefers.getBoolean("player_defer_flag_switch", false); - } - - public static void putDeferFlagSwitchPlayback(boolean defer) { - Prefers.put("player_defer_flag_switch", defer); - } - public static int getBackground() { int stored = Prefers.getInt("background", BackgroundPlaybackPolicy.ON); int normalized = BackgroundPlaybackPolicy.normalize(stored); @@ -572,75 +530,8 @@ public class PlayerSetting { Prefers.put(KEY_DISPLAY_TITLE, displayTitle); } - /** - * 媒体信息徽标的**总开关**,由设置页「媒体信息位置」那一行唯一控制(选角位=开、选关闭=关)。 - * 它与「屏显设置」里的常驻项是两件事:总开关关掉时,全屏唤醒控制栏也不显示。 - */ - public static boolean isDisplayMediaBadges() { - return Prefers.getBoolean(KEY_DISPLAY_MEDIA_BADGES); - } - - public static void putDisplayMediaBadges(boolean value) { - Prefers.put(KEY_DISPLAY_MEDIA_BADGES, value); - } - - /** - * 控制栏隐藏时媒体信息是否继续常驻(屏显设置里的那个媒体信息项 / 播放控件里的芯片)。 - * 总开关打开时,控制栏唤醒本来就显示,这个常驻开关只是额外让它在隐藏时也留在画面上。 - */ - public static boolean isDisplayMediaBadgesPersistent() { - migrateMediaBadgePersist(); - return Prefers.getBoolean(KEY_DISPLAY_MEDIA_BADGES_PERSIST); - } - - public static void putDisplayMediaBadgesPersistent(boolean value) { - Prefers.put(KEY_DISPLAY_MEDIA_BADGES_PERSIST, value); - } - - /** - * 媒体徽标所在角:0 左上、1 右上、2 左下、3 右下。默认右下, - * 因为左下/右下的习惯位置留给进度与网速,徽标在最外侧更少冲突。 - */ - public static int getMediaBadgePosition() { - return sanitizeMediaBadgePosition(Prefers.getInt(KEY_MEDIA_BADGE_POSITION, 3)); - } - - public static void putMediaBadgePosition(int position) { - Prefers.put(KEY_MEDIA_BADGE_POSITION, sanitizeMediaBadgePosition(position)); - } - - private static int sanitizeMediaBadgePosition(int position) { - return position < 0 || position > 3 ? 3 : position; - } - - /** - * 「媒体信息位置」单选里的关闭项。位置键只存四角(0..3), - * 「开 / 关」由 {@code player_osd_media_badges} 表达,因此关闭时位置键必须保持原值, - * 重新开启才会回到用户上一次选的那个角(默认右下角)。 - */ - public static final int MEDIA_BADGE_POSITION_OFF = 4; - - /** 「媒体信息位置」单选的选中项:开启时是四角,关闭时是关闭项。 */ - public static int getMediaBadgeChoice() { - return isDisplayMediaBadges() ? getMediaBadgePosition() : MEDIA_BADGE_POSITION_OFF; - } - - /** - * 应用「媒体信息位置」单选结果——这一行就是媒体信息的**总开关**: - * 选角位 = 打开(并记住该角,全屏唤醒控制栏即可看到);选「关闭」= 彻底关闭。 - * 它不影响「屏显设置」的其它项,也不要求屏显常驻打开:常驻是另一个独立开关。 - */ - public static void putMediaBadgeChoice(int which) { - if (which == MEDIA_BADGE_POSITION_OFF) { - putDisplayMediaBadges(false); - return; - } - putMediaBadgePosition(which); - putDisplayMediaBadges(true); - } - public static boolean[] getDisplayChecked() { - return new boolean[]{isDisplayTime(), isDisplayTraffic(), isDisplaySize(), isDisplayProgress(), isDisplayMini(), isDisplayTitle(), isOsdDiagnostics(), isDisplayMediaBadges() && isDisplayMediaBadgesPersistent()}; + return new boolean[]{isDisplayTime(), isDisplayTraffic(), isDisplaySize(), isDisplayProgress(), isDisplayMini(), isDisplayTitle(), isOsdDiagnostics()}; } public static void putDisplayChecked(boolean[] checked) { @@ -651,10 +542,6 @@ public class PlayerSetting { putDisplayMini(valueAt(checked, 4, isDisplayMini())); putDisplayTitle(valueAt(checked, 5, isDisplayTitle())); putOsdDiagnostics(valueAt(checked, 6, isOsdDiagnostics())); - boolean persistent = valueAt(checked, 7, isDisplayMediaBadgesPersistent()); - // 勾上「媒体信息」等于「打开总开关并常驻」;取消只取消常驻,唤醒控制栏时仍然可见。 - if (persistent) putDisplayMediaBadges(true); - putDisplayMediaBadgesPersistent(persistent); } public static boolean[] getLiveDisplayChecked() { @@ -808,6 +695,67 @@ public class PlayerSetting { KernelPerformanceSetting.putVideoPrefer(getPlayer(), videoPrefer); } + // AUTO 模式失败遍历顺序:能力从全到简(NextLib 有 ffmpeg 音视频兜底+解码调度调优;Simple 有音视频兜底;Official 纯官方)。 + public static final int[] FFMPEG_AUTO_ORDER = {FFMPEG_MODE_NEXTLIB, FFMPEG_MODE_SIMPLE, FFMPEG_MODE_OFFICIAL}; + + // 进程内运行时覆盖,仅在设置为 AUTO 时生效,用于失败链遍历具体模式;不写盘。 + private static volatile int ffmpegModeOverride = NONE; + + public static int getFFmpegMode() { + int defaultMode = getDefaultFFmpegMode(); + return sanitizeFFmpegMode(Prefers.getInt(KEY_FFMPEG_MODE, defaultMode), defaultMode); + } + + public static void putFFmpegMode(int mode) { + Prefers.put(KEY_FFMPEG_MODE, sanitizeFFmpegMode(mode, getDefaultFFmpegMode())); + clearFFmpegModeOverride(); + } + + // ExoUtil 构造渲染器时使用的具体模式。AUTO 时解析成运行时覆盖,无覆盖则取遍历首项。 + public static int getEffectiveFFmpegMode() { + int mode = getFFmpegMode(); + if (mode != FFMPEG_MODE_AUTO) return mode; + int override = ffmpegModeOverride; + return isConcreteFFmpegMode(override) ? override : FFMPEG_AUTO_ORDER[0]; + } + + public static boolean isAutoFFmpegMode() { + return getFFmpegMode() == FFMPEG_MODE_AUTO; + } + + public static void setFFmpegModeOverride(int mode) { + ffmpegModeOverride = isConcreteFFmpegMode(mode) ? mode : NONE; + } + + public static void clearFFmpegModeOverride() { + ffmpegModeOverride = NONE; + } + + static int getDefaultFFmpegMode() { + return sanitizeFFmpegMode(App.get().getResources().getInteger(R.integer.default_ffmpeg_mode), FFMPEG_MODE_SIMPLE); + } + + static int sanitizeFFmpegMode(int mode, int defaultMode) { + if (isFFmpegMode(mode)) return mode; + return isFFmpegMode(defaultMode) ? defaultMode : FFMPEG_MODE_SIMPLE; + } + + private static boolean isFFmpegMode(int mode) { + return isConcreteFFmpegMode(mode) || mode == FFMPEG_MODE_AUTO; + } + + private static boolean isConcreteFFmpegMode(int mode) { + return mode == FFMPEG_MODE_NEXTLIB || mode == FFMPEG_MODE_OFFICIAL || mode == FFMPEG_MODE_SIMPLE; + } + + public static boolean useNextLibFFmpeg() { + return getEffectiveFFmpegMode() == FFMPEG_MODE_NEXTLIB; + } + + public static void putUseNextLibFFmpeg(boolean useNextLib) { + putFFmpegMode(useNextLib ? FFMPEG_MODE_NEXTLIB : FFMPEG_MODE_OFFICIAL); + } + public static boolean isPreferAAC() { return isPreferAAC(getPlayer()); } @@ -1008,8 +956,7 @@ public class PlayerSetting { } public static boolean isOsdEnabled() { - return isOsdTitle() || isOsdTime() || isOsdSize() || isOsdProgress() || isOsdTraffic() || isOsdMini() || isOsdDiagnostics() - || (isDisplayMediaBadges() && isDisplayMediaBadgesPersistent()); + return isOsdTitle() || isOsdTime() || isOsdSize() || isOsdProgress() || isOsdTraffic() || isOsdMini() || isOsdDiagnostics(); } private static boolean valueAt(boolean[] checked, int index, boolean fallback) { @@ -1029,16 +976,6 @@ public class PlayerSetting { putDisplayTitle(Prefers.getBoolean(KEY_OSD_TITLE)); } - /** - * 拆分前 {@code player_osd_media_badges} 的语义就是「常驻显示」;新键缺失时把旧键值抄一次, - * 避免老用户升级后静默降级成「只在控制栏唤醒时出现」。新装用户旧键为 false, - * 抄过来仍是默认关闭;用户手动勾选 / 取消后新键存在,迁移不再生效。 - */ - private static void migrateMediaBadgePersist() { - if (Prefers.contains(KEY_DISPLAY_MEDIA_BADGES_PERSIST)) return; - Prefers.put(KEY_DISPLAY_MEDIA_BADGES_PERSIST, Prefers.getBoolean(KEY_DISPLAY_MEDIA_BADGES)); - } - private static boolean hasAny(String... keys) { for (String key : keys) if (Prefers.getPrefers().contains(key)) return true; return false; diff --git a/app/src/main/java/com/fongmi/android/tv/setting/ProxySetting.java b/app/src/main/java/com/fongmi/android/tv/setting/ProxySetting.java index dcd69d1837..f84f9dc53c 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/ProxySetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/ProxySetting.java @@ -103,7 +103,7 @@ public class ProxySetting { } private static void collectDebugUrls(Set urls) { - for (String origin : DebugLogStore.observedOrigins()) addUrls(origin, urls); + for (String line : DebugLogStore.snapshot()) addUrls(line, urls); } private static void addLocalFileUrls(String source, Set urls) { diff --git a/app/src/main/java/com/fongmi/android/tv/setting/Setting.java b/app/src/main/java/com/fongmi/android/tv/setting/Setting.java index 656e0a0771..0d7d8f37d7 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/Setting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/Setting.java @@ -17,7 +17,6 @@ import androidx.core.content.ContextCompat; import com.fongmi.android.tv.App; import com.fongmi.android.tv.BuildConfig; -import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.AiConfig; import com.fongmi.android.tv.bean.AudioConfig; import com.fongmi.android.tv.bean.DanmakuMatchCache; @@ -27,16 +26,11 @@ import com.fongmi.android.tv.bean.TmdbMatchCache; import com.fongmi.android.tv.bean.TmdbSeasonMatchCache; import com.fongmi.android.tv.bean.Update; import com.fongmi.android.tv.utils.AppCache; -import com.fongmi.android.tv.update.OciMirror; -import com.fongmi.android.tv.update.UpdateSource; -import com.fongmi.android.tv.utils.GithubProxy; import com.fongmi.android.tv.utils.WebViewUtil; -import com.fongmi.android.tv.utils.Util; import com.github.catvod.crawler.DebugLogStore; import com.github.catvod.crawler.SpiderDebug; import com.github.catvod.utils.Trans; import com.github.catvod.utils.Prefers; -import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; @@ -74,23 +68,10 @@ public class Setting { public static final int GLOBAL_HISTORY_OFF = 0; public static final int GLOBAL_HISTORY_AUTO = 1; public static final int GLOBAL_HISTORY_SEARCH = 2; - // 全屏菜单键动作:0/1 为既有取值,2 为新增「下一集」,只能追加不能重排。 - public static final int FULLSCREEN_MENU_KEY_CONTROL = 0; - public static final int FULLSCREEN_MENU_KEY_EPISODES = 1; - public static final int FULLSCREEN_MENU_KEY_NEXT_EPISODE = 2; public static final int DETAIL_INTERACTION_SYSTEM = 0; public static final int INTRO_SKIP_OFF = 0; public static final int INTRO_SKIP_AUTO = 1; public static final int INTRO_SKIP_CONFIRM = 2; - public static final int INTERFACE_FAILOVER_OFF = InterfaceFailoverPolicy.OFF; - public static final int INTERFACE_FAILOVER_AUTO = InterfaceFailoverPolicy.AUTO; - public static final int INTERFACE_FAILOVER_CONFIRM = InterfaceFailoverPolicy.CONFIRM; - public static final int INTRO_SKIP_KIND_RECAP = 1; - public static final int INTRO_SKIP_KIND_INTRO = 1 << 1; - public static final int INTRO_SKIP_KIND_OUTRO = 1 << 2; - public static final int INTRO_SKIP_KIND_PREVIEW = 1 << 3; - public static final int INTRO_SKIP_KIND_ALL = INTRO_SKIP_KIND_RECAP | INTRO_SKIP_KIND_INTRO | INTRO_SKIP_KIND_OUTRO | INTRO_SKIP_KIND_PREVIEW; - public static final int INTRO_SKIP_KIND_DEFAULT = INTRO_SKIP_KIND_RECAP | INTRO_SKIP_KIND_INTRO | INTRO_SKIP_KIND_OUTRO; public static final int DETAIL_INTERACTION_ORIGINAL = 1; public static final int DETAIL_THEME_CURRENT = DETAIL_STYLE_NATIVE; private static final Type STRING_LIST = new TypeToken>() {}.getType(); @@ -110,10 +91,7 @@ public class Setting { public static final int UI_SCALE_SMALLER = 3; public static final int UI_SCALE_MILD_COMPACT = 4; public static final int UI_SCALE_MORE_COMPACT = 5; - public static final int UI_SCALE_LARGE = 6; - public static final int UI_SCALE_LARGER = 7; - public static final int UI_SCALE_LARGEST = 8; - private static final int[] UI_SCALE_OPTIONS = {UI_SCALE_FOLLOW_SYSTEM, UI_SCALE_SMALLER, UI_SCALE_MORE_COMPACT, UI_SCALE_COMPACT, UI_SCALE_MILD_COMPACT, UI_SCALE_STANDARD, UI_SCALE_LARGE, UI_SCALE_LARGER, UI_SCALE_LARGEST}; + private static final int[] UI_SCALE_OPTIONS = {UI_SCALE_FOLLOW_SYSTEM, UI_SCALE_STANDARD, UI_SCALE_MILD_COMPACT, UI_SCALE_COMPACT, UI_SCALE_MORE_COMPACT, UI_SCALE_SMALLER}; public static final int WALL_CINEMA = 5; public static final int WALL_CINEMA_WARM = 6; @@ -404,14 +382,6 @@ public class Setting { Prefers.put("incognito", incognito); } - public static boolean isTouchOptimized() { - return Util.isLeanback() && Prefers.getBoolean("touch_optimized"); - } - - public static void putTouchOptimized(boolean enabled) { - Prefers.put("touch_optimized", enabled); - } - public static int getLanguage() { int language = Prefers.getInt("language", LANGUAGE_FOLLOW_SYSTEM); return isLanguage(language) ? language : LANGUAGE_FOLLOW_SYSTEM; @@ -494,11 +464,8 @@ public class Setting { float factor = getUiScaleFactor(scale); Configuration config = new Configuration(context.getResources().getConfiguration()); DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - // Scale from the density currently selected by the system. Using the device's - // stable density makes the same option behave differently on phones, head units - // and virtual machines whose default display size overrides the stable density. - int baseDensity = metrics.densityDpi; - int densityDpi = Math.max(DisplayMetrics.DENSITY_LOW, Math.round(baseDensity * factor)); + int stableDensity = DisplayMetrics.DENSITY_DEVICE_STABLE > 0 ? DisplayMetrics.DENSITY_DEVICE_STABLE : metrics.densityDpi; + int densityDpi = Math.max(DisplayMetrics.DENSITY_LOW, Math.round(stableDensity * factor)); config.densityDpi = densityDpi; config.fontScale = 1.0f; config.screenWidthDp = pxToDp(metrics.widthPixels, densityDpi); @@ -507,16 +474,13 @@ public class Setting { return context.createConfigurationContext(config); } - public static float getUiScaleFactor(int scale) { + private static float getUiScaleFactor(int scale) { return switch (scale) { - case UI_SCALE_SMALLER -> 0.8f; - case UI_SCALE_MORE_COMPACT -> 0.85f; - case UI_SCALE_COMPACT -> 0.9f; - case UI_SCALE_MILD_COMPACT -> 0.95f; - case UI_SCALE_STANDARD -> 1.0f; - case UI_SCALE_LARGE -> 1.1f; - case UI_SCALE_LARGER -> 1.2f; - case UI_SCALE_LARGEST -> 1.3f; + case UI_SCALE_STANDARD -> 0.8f; + case UI_SCALE_MILD_COMPACT -> 0.75f; + case UI_SCALE_COMPACT -> 0.7f; + case UI_SCALE_MORE_COMPACT -> 0.65f; + case UI_SCALE_SMALLER -> 0.6f; default -> 1.0f; }; } @@ -683,20 +647,6 @@ public class Setting { public static void logDebugEnvironment(String reason) { boolean hardwareAccelerated = (App.get().getApplicationInfo().flags & ApplicationInfo.FLAG_HARDWARE_ACCELERATED) != 0; - DebugLogStore.event(new com.github.catvod.crawler.diagnostics.DiagnosticEvent("env.device", "none", "process", 0, 0) - .observed("reason", reason).observed("appVersion", BuildConfig.VERSION_NAME).observed("versionCode", BuildConfig.VERSION_CODE) - .observed("buildTime", BuildConfig.BUILD_TIME).observed("buildTag", BuildConfig.BUILD_TAG) - .observed("gitRevision", BuildConfig.GIT_REVISION).observed("state", BuildConfig.GIT_STATE) - .observed("fingerprintDigest", com.fongmi.android.tv.player.NativeLibraryDiagnostics.digestText(Build.FINGERPRINT)) - .observed("media3Version", BuildConfig.MEDIA3_VERSION).observed("flavor", BuildConfig.FLAVOR_mode) - .observed("abi", BuildConfig.FLAVOR_abi).observed("process64Bit", android.os.Process.is64Bit()) - .observed("android", Build.VERSION.RELEASE).observed("api", Build.VERSION.SDK_INT) - .observed("targetSdk", App.get().getApplicationInfo().targetSdkVersion) - .observed("manufacturer", Build.MANUFACTURER).observed("model", Build.MODEL) - .observed("device", Build.DEVICE).observed("hardwareAccelerated", hardwareAccelerated) - .pin("device")); - com.fongmi.android.tv.player.NativeLibraryDiagnostics.request(); - com.fongmi.android.tv.player.PlaybackDiagnosticSession.captureActive(android.os.SystemClock.elapsedRealtime()); SpiderDebug.log("env", "reason=%s app=%s(%s) mode=%s abi=%s debug=%s hardware=%s android=%s sdk=%s incremental=%s manufacturer=%s brand=%s model=%s device=%s product=%s supportedAbis=%s", reason, BuildConfig.VERSION_NAME, @@ -768,32 +718,16 @@ public class Setting { Prefers.put("update", update); } - public static String getUpdateSource() { - return UpdateSource.normalize(Prefers.getString("update_source", UpdateSource.OCI)); + public static String getUpdateChannel() { + String channel = Prefers.getString("update_channel", Update.CHANNEL_STABLE); + return Update.CHANNEL_BETA.equals(channel) ? Update.CHANNEL_BETA : Update.CHANNEL_STABLE; } - public static void putUpdateSource(String source) { - Prefers.put("update_source", UpdateSource.normalize(source)); - } - - public static String getUpdateOciMirror() { - return OciMirror.find(Prefers.getString("update_oci_mirror", OciMirror.DEFAULT)).id; - } - - public static void putUpdateOciMirror(String mirror) { - Prefers.put("update_oci_mirror", OciMirror.find(mirror).id); - } - - public static String getUpdateOciMirrorUrl() { - return Prefers.getString("update_oci_mirror_url"); - } - - public static void putUpdateOciMirrorUrl(String url) { - Prefers.put("update_oci_mirror_url", url == null ? "" : url.trim()); + public static void putUpdateChannel(String channel) { + Prefers.put("update_channel", Update.CHANNEL_BETA.equals(channel) ? Update.CHANNEL_BETA : Update.CHANNEL_STABLE); } public static String getGithubProxy() { - migrateLegacyGithubProxy(); return Prefers.getString("github_proxy", com.fongmi.android.tv.utils.GithubProxy.defaultSources()); } @@ -801,14 +735,6 @@ public class Setting { Prefers.put("github_proxy", com.fongmi.android.tv.utils.GithubProxy.normalizeConfig(value)); } - public static String getGithubProxyMode() { - migrateLegacyGithubProxy(); - return GithubProxy.normalizeMode(Prefers.getString("github_proxy_mode", GithubProxy.MODE_FULL_URL)); - } - - public static void putGithubProxyMode(String mode) { - Prefers.put("github_proxy_mode", GithubProxy.normalizeMode(mode)); - } public static boolean isGithubProxyEnabled() { return Prefers.getBoolean("github_proxy_enabled", true); @@ -818,66 +744,6 @@ public class Setting { Prefers.put("github_proxy_enabled", enabled); } - /** - * 把旧的单选 GitHub 代理设置迁移成新的多源列表。 - * - *

{@code synchronized}:两个 getter 都会调它,而它先读后删旧键。不加锁时 - * 「关于」对话框与探测线程可能同时进来,两边都看到旧键存在,后写的 {@code putGithubProxy} - * 会盖掉前一次已经扩好的列表。窗口只有升级后第一次读,但那正是唯一一次机会。 - */ - private static synchronized void migrateLegacyGithubProxy() { - if (!Prefers.getPrefers().contains("update_github_proxy")) return; - - String proxy = Prefers.getString("update_github_proxy"); - if (GithubProxy.DIRECT.equals(proxy)) { - putGithubProxyEnabled(false); - } else { - String url = "custom".equals(proxy) - ? Prefers.getString("update_github_proxy_url") - : legacyGithubProxyUrl(proxy); - if (!url.isEmpty()) { - // 旧选择成为列表首位(即生效源),其余内置源保留在后面备用。 - // 先前这里用 putGithubProxy(url) 覆盖,会把刚合并出来的整份列表抹成一条。 - String merged = GithubProxy.addSources(Prefers.getString("github_proxy"), legacyGithubProxySources(url)); - putGithubProxy(GithubProxy.addSources(url, merged)); - putGithubProxyEnabled(true); - putGithubProxyMode(Prefers.getString("update_github_proxy_mode", GithubProxy.MODE_FULL_URL)); - } - } - - Prefers.remove("update_github_proxy"); - Prefers.remove("update_github_proxy_url"); - Prefers.remove("update_github_proxy_mode"); - } - - private static String legacyGithubProxyUrl(String proxy) { - return switch (proxy) { - case "github_chenc" -> "https://github.chenc.dev"; - case "gh_acmsz" -> "https://gh.acmsz.top"; - case "ghfast" -> "https://ghfast.top"; - case "gh_monlor" -> "https://gh.monlor.com"; - default -> ""; - }; - } - - /** - * 旧版本内置的四个代理加上用户自填的那个,作为迁移时要并入的候选源。 - * - *

{@code selectedUrl} 放在最前:并入后它就是生效源,与用户升级前的选择一致。 - * 自填地址无论当时选没选中都保留 —— 它是用户手输的,丢掉就再也找不回来。 - */ - private static String legacyGithubProxySources(String selectedUrl) { - List list = new ArrayList<>(); - if (selectedUrl != null && !selectedUrl.isEmpty()) list.add(selectedUrl); - list.add("https://github.chenc.dev"); - list.add("https://gh.acmsz.top"); - list.add("https://ghfast.top"); - list.add("https://gh.monlor.com"); - String custom = Prefers.getString("update_github_proxy_url"); - if (!custom.isEmpty()) list.add(custom); - return String.join("\n", list); - } - public static boolean isAdblock() { return Prefers.getBoolean("adblock", true); } @@ -894,15 +760,6 @@ public class Setting { Prefers.put("zhuyin", zhuyin); } - /** Appearance mode: -1 follows system, 0 forces light, 1 forces dark. */ - public static int getThemeMode() { - return Prefers.getInt("theme_mode", -1); - } - - public static void putThemeMode(int mode) { - Prefers.put("theme_mode", mode < 0 ? -1 : Math.min(mode, 1)); - } - public static int getThemeColor() { return Prefers.getInt("theme_color", -1); } @@ -911,14 +768,6 @@ public class Setting { Prefers.put("theme_color", color); } - public static boolean isThemeColorEnabled() { - return Prefers.getBoolean("theme_color_enabled"); - } - - public static void putThemeColorEnabled(boolean enabled) { - Prefers.put("theme_color_enabled", enabled); - } - public static int getWallColor() { return Prefers.getInt("wall_color", 0); } @@ -1024,11 +873,11 @@ public class Setting { Prefers.put("ai_ad_detection", enabled); } - public static synchronized TmdbMatchCache getTmdbMatchCache() { + public static TmdbMatchCache getTmdbMatchCache() { return TmdbMatchCache.objectFrom(AppCache.get(AppCache.KEY_TMDB_MATCH)); } - public static synchronized void putTmdbMatchCache(TmdbMatchCache cache) { + public static void putTmdbMatchCache(TmdbMatchCache cache) { AppCache.put(AppCache.KEY_TMDB_MATCH, App.gson().toJson(cache)); } @@ -1175,10 +1024,6 @@ public class Setting { return isTmdbMode(getDetailOpenMode()) && getTmdbModel() == TMDB_MODEL_NATIVE && TmdbConfig.objectFrom(getTmdbConfig()).isReady(); } - public static boolean isTmdbDetailModeConfigured() { - return isTmdbMode(getDetailOpenMode()) && getTmdbModel() == TMDB_MODEL_NATIVE; - } - public static int getDetailOpenMode() { int mode; if (Prefers.getPrefers().contains("detail_open_mode")) { @@ -1200,7 +1045,7 @@ public class Setting { mode = isTmdbEnabled() ? DETAIL_OPEN_ORIGINAL_ENHANCED : DETAIL_OPEN_DIRECT; migrateCurrentDetailTheme(mode); } - return mode; + return isTmdbMode(mode) && !isTmdbReady() ? DETAIL_OPEN_DIRECT : mode; } public static void putDetailOpenMode(int mode) { @@ -1351,27 +1196,11 @@ public class Setting { } public static int getGlobalHistoryMode() { - // Read the raw value so a legacy Boolean can be migrated without - // mistaking an Integer 0 for Boolean false during app startup. - Object value = Prefers.getPrefers().getAll().get("global_history_mode"); - if (value instanceof Boolean legacy) return legacy ? GLOBAL_HISTORY_AUTO : GLOBAL_HISTORY_OFF; - if (value instanceof Number number) return clampGlobalHistoryMode(number.intValue()); - return GLOBAL_HISTORY_OFF; + return clampGlobalHistoryMode(Prefers.getInt("global_history_mode", GLOBAL_HISTORY_OFF)); } public static void putGlobalHistoryMode(int mode) { - mode = clampGlobalHistoryMode(mode); - Prefers.put("global_history_mode", mode == GLOBAL_HISTORY_OFF ? -1 : mode); - } - - public static int getInterfaceFailoverMode() { - return InterfaceFailoverPolicy.clampMode(Prefers.getInt("interface_failover_mode", InterfaceFailoverPolicy.DEFAULT_MODE)); - } - - public static void putInterfaceFailoverMode(int mode) { - int value = InterfaceFailoverPolicy.clampMode(mode); - Prefers.put("interface_failover_mode", value); - if (value == InterfaceFailoverPolicy.OFF) VodConfig.cancelFailover(); + Prefers.put("global_history_mode", clampGlobalHistoryMode(mode)); } public static boolean isGlobalHistoryEnabled() { @@ -1427,9 +1256,7 @@ public class Setting { } public static int getFullscreenMenuKey() { - int menuKey = Prefers.getInt("fullscreen_menu_key", FULLSCREEN_MENU_KEY_CONTROL); - // 旧版本不认新增取值,钳制可避免设置页用越界下标取 string-array。 - return menuKey < FULLSCREEN_MENU_KEY_CONTROL || menuKey > FULLSCREEN_MENU_KEY_NEXT_EPISODE ? FULLSCREEN_MENU_KEY_CONTROL : menuKey; + return Prefers.getInt("fullscreen_menu_key", 0); } public static void putFullscreenMenuKey(int menuKey) { @@ -1438,7 +1265,7 @@ public class Setting { public static int getHomeMenuKey() { int menuKey = Prefers.getInt("home_menu_key", 0); - return menuKey < 0 || menuKey > 11 ? 0 : menuKey; + return menuKey < 0 || menuKey > 9 ? 0 : menuKey; } public static void putHomeMenuKey(int menuKey) { @@ -1453,21 +1280,14 @@ public class Setting { Prefers.put("play_back_to_detail", backToDetail); } - public static boolean isSubtitleAutoMatchEnabled() { - return Prefers.getBoolean("subtitle_auto_match", false); - } + public static boolean isSubtitleAutoMatchEnabled() { + return Prefers.getBoolean("subtitle_auto_match", false); + } - public static void putSubtitleAutoMatchEnabled(boolean enabled) { - Prefers.put("subtitle_auto_match", enabled); - } + public static void putSubtitleAutoMatchEnabled(boolean enabled) { + Prefers.put("subtitle_auto_match", enabled); + } - public static boolean isPlaybackOverlayEnabled() { - return Prefers.getBoolean("playback_overlay_enabled", true); - } - - public static void putPlaybackOverlayEnabled(boolean enabled) { - Prefers.put("playback_overlay_enabled", enabled); - } public static String getSubtitlePreferredLanguage() { return Prefers.getString("subtitle_preferred_language", "zh"); } @@ -1498,51 +1318,12 @@ public class Setting { case null, default -> false; }; } - public static String getSubtitleSourceEnvironment() { - String environment = Prefers.getString("subtitle_source_environment"); - if (!environment.isEmpty()) return environment; - String legacyToken = Prefers.getString("subtitle_assrt_token"); - if (legacyToken.isEmpty()) return ""; - JsonObject migrated = new JsonObject(); - migrated.addProperty("ASSRT_TOKEN", legacyToken); - environment = migrated.toString(); - Prefers.put("subtitle_source_environment", environment); - Prefers.put("subtitle_assrt_token", ""); - return environment; + public static String getSubtitleAssrtToken() { + return Prefers.getString("subtitle_assrt_token"); } - public static void putSubtitleSourceEnvironment(String environment) { - Prefers.put("subtitle_source_environment", environment == null ? "" : environment.trim()); - } - - public static String getSubtitleSourceEnvironment(String sourceKey) { - if (sourceKey == null || sourceKey.trim().isEmpty()) return ""; - try { - JsonObject root = com.google.gson.JsonParser.parseString(getSubtitleSourceEnvironment()).getAsJsonObject(); - if (root.has(sourceKey) && root.get(sourceKey).isJsonObject()) return root.getAsJsonObject(sourceKey).toString(); - if ("assrt".equals(sourceKey) && root.has("ASSRT_TOKEN")) { - JsonObject migrated = new JsonObject(); - migrated.add("ASSRT_TOKEN", root.get("ASSRT_TOKEN").deepCopy()); - return migrated.toString(); - } - } catch (RuntimeException ignored) { - } - return ""; - } - - public static void putSubtitleSourceEnvironment(String sourceKey, String environment) { - if (sourceKey == null || sourceKey.trim().isEmpty()) return; - JsonObject root; - try { - root = com.google.gson.JsonParser.parseString(getSubtitleSourceEnvironment()).getAsJsonObject(); - } catch (RuntimeException ignored) { - root = new JsonObject(); - } - if ("assrt".equals(sourceKey)) root.remove("ASSRT_TOKEN"); - String value = environment == null ? "" : environment.trim(); - if (value.isEmpty()) root.remove(sourceKey); - else root.add(sourceKey, com.google.gson.JsonParser.parseString(value).getAsJsonObject()); - putSubtitleSourceEnvironment(root.size() == 0 ? "" : root.toString()); + public static void putSubtitleAssrtToken(String token) { + Prefers.put("subtitle_assrt_token", token); } public static int getSubtitleAiMaxConcurrency() { @@ -1589,22 +1370,6 @@ public class Setting { putIntroSkipMode(enabled ? INTRO_SKIP_AUTO : INTRO_SKIP_OFF); } - /** - * 允许跳过的片段类型位掩码。默认回顾 + 片头 + 片尾,预告不默认开——预告在片尾之后, - * 跳掉它等于直接进下一集,属于更激进的行为,让用户自己选。 - */ - public static int getIntroSkipKinds() { - return Prefers.getInt("intro_skip_kinds", INTRO_SKIP_KIND_DEFAULT); - } - - public static void putIntroSkipKinds(int kinds) { - Prefers.put("intro_skip_kinds", kinds & INTRO_SKIP_KIND_ALL); - } - - public static boolean isIntroSkipKindEnabled(int kind) { - return (getIntroSkipKinds() & kind) != 0; - } - public static int getSearchUi() { return Prefers.getInt("search_ui", 1) == 0 ? 0 : 1; } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/SiteHealthStore.java b/app/src/main/java/com/fongmi/android/tv/setting/SiteHealthStore.java index 6f7c2eb3b2..c0b0c67179 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/SiteHealthStore.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/SiteHealthStore.java @@ -39,14 +39,6 @@ public class SiteHealthStore { private static boolean loaded; private static boolean dirty; - public static void recordHome(Site site, boolean success, int count, long cost, String error) { - recordProtocol(site == null ? null : site.getKey(), ProtocolStage.HOME, success, count, cost, error); - } - - public static void recordCategory(String key, boolean success, int count, long cost, String error) { - recordProtocol(key, ProtocolStage.CATEGORY, success, count, cost, error); - } - public static void recordSearch(Site site, boolean success, int count, long cost, String error) { if (skip(site)) return; synchronized (SiteHealthStore.class) { @@ -110,16 +102,6 @@ public class SiteHealthStore { } } - public static void recordPlayAttempt(String key) { - if (skip(key)) return; - synchronized (SiteHealthStore.class) { - Health health = get(key); - health.playAttempts++; - health.updatedAt = System.currentTimeMillis(); - markDirty(); - } - } - public static void recordPlay(String key, boolean success, String error) { if (skip(key)) return; synchronized (SiteHealthStore.class) { @@ -138,53 +120,6 @@ public class SiteHealthStore { } } - private static void recordProtocol(String key, ProtocolStage stage, boolean success, int count, long cost, String error) { - if (skip(key)) return; - synchronized (SiteHealthStore.class) { - Health health = get(key); - health.updatedAt = System.currentTimeMillis(); - boolean home = stage == ProtocolStage.HOME; - if (home) { - health.lastHomeCost = Math.max(0, cost); - health.lastHomeCount = Math.max(0, count); - if (success) { - health.homeSuccess++; - health.lastHomeSuccessAt = health.updatedAt; - if (count <= 0) { - health.homeEmpty++; - health.lastHomeError = REASON_EMPTY_RESULT; - health.addHomeReason(REASON_EMPTY_RESULT); - } - } else { - health.homeFail++; - health.lastHomeFailAt = health.updatedAt; - health.lastHomeError = trim(error); - health.addHomeReason(reason("HOME", error)); - } - } else { - health.lastCategoryCost = Math.max(0, cost); - health.lastCategoryCount = Math.max(0, count); - if (success) { - health.categorySuccess++; - health.lastCategorySuccessAt = health.updatedAt; - if (count <= 0) { - health.categoryEmpty++; - health.lastCategoryError = REASON_EMPTY_RESULT; - health.addCategoryReason(REASON_EMPTY_RESULT); - } - } else { - health.categoryFail++; - health.lastCategoryFailAt = health.updatedAt; - health.lastCategoryError = trim(error); - health.addCategoryReason(reason("CATEGORY", error)); - } - } - markDirty(); - } - } - - private enum ProtocolStage { HOME, CATEGORY } - public static void sortSites(List sites) { if (!Setting.isSiteHealthSort()) return; if (sites == null || sites.size() < 2) return; @@ -242,7 +177,7 @@ public class SiteHealthStore { rows.add(Row.from(siteKey, displayName(siteKey, names), health)); } rows.sort((a, b) -> Long.compare(b.updatedAt, a.updatedAt)); - return new Report(cid, rows, AdBlockStatsStore.getStats()); + return new Report(cid, rows); } } @@ -392,19 +327,11 @@ public class SiteHealthStore { public final Summary summary; public final List rows; public final String cid; - public final long adBlockedTotal; - public final Map adBlockedBySite; - public final Map adBlockedByRule; - public final Map adBlockedByPipeline; - private Report(String cid, List rows, com.fongmi.android.tv.bean.AdBlockStats adStats) { + private Report(String cid, List rows) { this.cid = cid; this.rows = Collections.unmodifiableList(rows); this.summary = Summary.from(rows); - this.adBlockedTotal = adStats.getTotalBlocked(); - this.adBlockedBySite = Collections.unmodifiableMap(new LinkedHashMap<>(adStats.getSiteBlocked())); - this.adBlockedByRule = Collections.unmodifiableMap(new LinkedHashMap<>(adStats.getRuleCounts())); - this.adBlockedByPipeline = Collections.unmodifiableMap(new LinkedHashMap<>(adStats.getPipelineCounts())); } public boolean isEmpty() { @@ -451,48 +378,39 @@ public class SiteHealthStore { public final String siteKey; public final String siteName; public final Status status; - public final Stage home; - public final Stage category; public final Stage search; public final Stage detail; public final Stage parse; public final Stage play; - public final int playAttempts; public final long updatedAt; - private Row(String siteKey, String siteName, Status status, Stage home, Stage category, Stage search, Stage detail, Stage parse, Stage play, int playAttempts, long updatedAt) { + private Row(String siteKey, String siteName, Status status, Stage search, Stage detail, Stage parse, Stage play, long updatedAt) { this.siteKey = siteKey; this.siteName = siteName; this.status = status; - this.home = home; - this.category = category; this.search = search; this.detail = detail; this.parse = parse; this.play = play; - this.playAttempts = playAttempts; this.updatedAt = updatedAt; } private static Row from(String siteKey, String siteName, Health health) { double adPenalty = calculateAdPenalty(siteKey); return new Row(siteKey, siteName, health.status(adPenalty), - new Stage("HOME", Math.max(0, health.homeSuccess - health.homeEmpty), health.homeFail + health.homeEmpty, health.homeEmpty, health.lastHomeCost, health.lastHomeFailAt, health.lastHomeError, health.homeReasons), - new Stage("CATEGORY", Math.max(0, health.categorySuccess - health.categoryEmpty), health.categoryFail + health.categoryEmpty, health.categoryEmpty, health.lastCategoryCost, health.lastCategoryFailAt, health.lastCategoryError, health.categoryReasons), new Stage("SEARCH", Math.max(0, health.searchSuccess - health.searchEmpty), health.searchFail + health.searchEmpty, health.searchEmpty, health.lastSearchCost, health.lastSearchFailAt, health.lastSearchError, health.searchReasons), new Stage("DETAIL", health.detailSuccess, health.detailFail, 0, health.lastDetailCost, health.lastDetailFailAt, health.lastDetailError, health.detailReasons), new Stage("PARSE", health.parseSuccess, health.parseFail, 0, health.lastParseCost, health.lastParseFailAt, health.lastParseError, health.parseReasons), new Stage("PLAY", health.playSuccess, health.playFail, 0, 0, health.lastPlayFailAt, health.lastPlayError, health.playReasons), - health.playAttempts, health.updatedAt); } public int sampleCount() { - return home.sampleCount() + category.sampleCount() + search.sampleCount() + detail.sampleCount() + parse.sampleCount() + play.sampleCount(); + return search.sampleCount() + detail.sampleCount() + parse.sampleCount() + play.sampleCount(); } public int failureCount() { - return home.failureCount() + category.failureCount() + search.failureCount() + detail.failureCount() + parse.failureCount() + play.failureCount(); + return search.failureCount() + detail.failureCount() + parse.failureCount() + play.failureCount(); } public String topFailureReason() { @@ -508,8 +426,6 @@ public class SiteHealthStore { } private void addReasonsTo(Map reasons) { - home.addReasonsTo(reasons); - category.addReasonsTo(reasons); search.addReasonsTo(reasons); detail.addReasonsTo(reasons); parse.addReasonsTo(reasons); @@ -593,12 +509,6 @@ public class SiteHealthStore { public static class Health { - private int homeSuccess; - private int homeFail; - private int homeEmpty; - private int categorySuccess; - private int categoryFail; - private int categoryEmpty; private int searchSuccess; private int searchFail; private int searchEmpty; @@ -608,19 +518,10 @@ public class SiteHealthStore { private int parseFail; private int playSuccess; private int playFail; - private int playAttempts; - private int lastHomeCount; - private int lastCategoryCount; private int lastSearchCount; - private long lastHomeCost; - private long lastCategoryCost; private long lastSearchCost; private long lastDetailCost; private long lastParseCost; - private long lastHomeSuccessAt; - private long lastHomeFailAt; - private long lastCategorySuccessAt; - private long lastCategoryFailAt; private long lastSearchSuccessAt; private long lastSearchFailAt; private long lastDetailSuccessAt; @@ -630,21 +531,17 @@ public class SiteHealthStore { private long lastPlaySuccessAt; private long lastPlayFailAt; private long updatedAt; - private String lastHomeError; - private String lastCategoryError; private String lastSearchError; private String lastDetailError; private String lastParseError; private String lastPlayError; - private Map homeReasons; - private Map categoryReasons; private Map searchReasons; private Map detailReasons; private Map parseReasons; private Map playReasons; private int total() { - return homeSuccess + homeFail + categorySuccess + categoryFail + searchSuccess + searchFail + detailSuccess + detailFail + parseSuccess + parseFail + playSuccess + playFail; + return searchSuccess + searchFail + detailSuccess + detailFail + parseSuccess + parseFail + playSuccess + playFail; } private Status status() { @@ -661,8 +558,8 @@ public class SiteHealthStore { } private double score() { - int success = homeSuccess + categorySuccess + searchSuccess + detailSuccess * 2 + playSuccess * 5; - int fail = homeFail + categoryFail + searchFail + detailFail * 2 + playFail * 5; + int success = searchSuccess + detailSuccess * 2 + playSuccess * 5; + int fail = searchFail + detailFail * 2 + playFail * 5; if (success + fail == 0) return 0; double score = 60.0 * (success - fail) / (success + fail + 4.0); score += Math.min(lastSearchCount, 20) * 1.2; @@ -673,14 +570,6 @@ public class SiteHealthStore { return score; } - private void addHomeReason(String reason) { - homeReasons = addReason(homeReasons, reason); - } - - private void addCategoryReason(String reason) { - categoryReasons = addReason(categoryReasons, reason); - } - private void addSearchReason(String reason) { searchReasons = addReason(searchReasons, reason); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/TmdbSourceState.java b/app/src/main/java/com/fongmi/android/tv/setting/TmdbSourceState.java deleted file mode 100644 index 909751c709..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/setting/TmdbSourceState.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.fongmi.android.tv.setting; - -/** Availability of user-visible TMDB data embedded in a source detail response. */ -public enum TmdbSourceState { - ABSENT_OR_INVALID, - IDENTITY_ONLY, - RENDERABLE -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleController.java b/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleController.java index 783f4baed3..e7d3b83a50 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleController.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleController.java @@ -86,7 +86,6 @@ public final class RealtimeSubtitleController { private volatile int modelDownloadProgress; private volatile String modelDownloadFile = ""; private volatile int generation; - private volatile String activeModelId = ""; private volatile long latestCapturedUs; private WeakReference playerView = new WeakReference<>(null); private WeakReference playerManager = new WeakReference<>(null); @@ -240,107 +239,63 @@ public final class RealtimeSubtitleController { if (enabled || preparing || player == null || player.isReleased()) return; playerManager = new WeakReference<>(player); preparing = true; - ModelSpec spec = selectedModel(); - modelDownloadProgress = isModelReady(spec) ? 100 : 0; + modelDownloadProgress = isModelReady() ? 100 : 0; modelDownloadFile = ""; int request = ++generation; resetTimeline(false); - notifyState(request, State.PREPARING, ""); - worker.execute(() -> prepareModel(request, spec, false)); - } - - public synchronized void switchModel(PlayerManager player) { - if (player == null || player.isReleased() || !enabled && !preparing) return; - playerManager = new WeakReference<>(player); - enabled = false; - preparing = true; ModelSpec spec = selectedModel(); - modelDownloadProgress = isModelReady(spec) ? 100 : 0; - modelDownloadFile = ""; - int request = ++generation; - timelineGeneration.incrementAndGet(); - audioQueue.clear(); - main.removeCallbacks(cueTicker); - resetTimeline(false); - notifyState(request, State.PREPARING, ""); - worker.execute(() -> prepareModel(request, spec, true)); - } - - private void prepareModel(int request, ModelSpec spec, boolean replacing) { - RealtimeSubtitleRecognizer previousRecognizer = replacing ? recognizer : null; - RealtimeSubtitleTranslator previousTranslator = replacing ? translator : null; - try { - if (replacing) resetRecognizer(previousRecognizer, previousTranslator); - ensureModel(spec, (percent, fileName) -> notifyState(request, State.PREPARING, String.valueOf(percent)), () -> request != generation); - if (request != generation) return; - validateMemory(spec); - if (!replacing) releaseRecognizer(); - RealtimeSubtitleRecognizer createdRecognizer = RealtimeSubtitleRecognizer.create(modelDirectory(spec), vadFile(), spec, new RealtimeSubtitleRecognizer.Listener() { - @Override - public void onResult(String text, long startUs, long endUs, int timelineToken) { - if (request != generation) return; - translateFinalResult(spec.id(), text, startUs, endUs, timelineToken); - } - - @Override - public void onError(Throwable error) { - if (request != generation) return; - worker.execute(() -> { - if (request != generation) return; - failRecognizer(error, request); - }); - } - }); - RealtimeSubtitleTranslator createdTranslator; + notifyState(State.PREPARING, ""); + worker.execute(() -> { try { - createdTranslator = new RealtimeSubtitleTranslator(AiConfig.objectFrom(Setting.getAiConfig())); - } catch (Throwable error) { - createdRecognizer.release(); - throw error; - } - synchronized (this) { - if (request != generation) { + ensureModel(spec, (percent, fileName) -> notifyState(State.PREPARING, String.valueOf(percent)), () -> request != generation); + if (request != generation) return; + validateMemory(spec); + releaseRecognizer(); + RealtimeSubtitleRecognizer createdRecognizer = RealtimeSubtitleRecognizer.create(modelDirectory(spec), vadFile(), spec, new RealtimeSubtitleRecognizer.Listener() { + @Override + public void onResult(String text, long startUs, long endUs, int timelineToken) { + translateFinalResult(spec.id(), text, startUs, endUs, timelineToken); + } + + @Override + public void onError(Throwable error) { + worker.execute(() -> failRecognizer(error)); + } + }); + RealtimeSubtitleTranslator createdTranslator; + try { + createdTranslator = new RealtimeSubtitleTranslator(AiConfig.objectFrom(Setting.getAiConfig())); + } catch (Throwable error) { createdRecognizer.release(); - createdTranslator.release(); - return; + throw error; } - recognizer = createdRecognizer; - translator = createdTranslator; - activeModelId = spec.id(); - previousChunkEndUs = C.TIME_UNSET; - enabled = true; - preparing = false; - } - releaseRecognizer(previousRecognizer, previousTranslator); - main.post(() -> { - if (request != generation || !enabled) return; - disableNativeSubtitle(); - startCueTicker(); - }); - notifyState(request, State.ON, ""); - } catch (Throwable e) { - if (request != generation || DOWNLOAD_CANCELLED.equals(e.getMessage())) return; - if (replacing && (previousRecognizer != null || previousTranslator != null)) { synchronized (this) { - if (request != generation) return; + if (request != generation) { + createdRecognizer.release(); + createdTranslator.release(); + return; + } + recognizer = createdRecognizer; + translator = createdTranslator; + previousChunkEndUs = C.TIME_UNSET; enabled = true; preparing = false; - if (!TextUtils.isEmpty(activeModelId)) Setting.putRealtimeSubtitleModel(activeModelId); } main.post(() -> { - if (request != generation || !enabled) return; + disableNativeSubtitle(); startCueTicker(); }); - notifyState(request, State.ON, message(e)); - return; + notifyState(State.ON, ""); + } catch (Throwable e) { + if (request != generation || DOWNLOAD_CANCELLED.equals(e.getMessage())) return; + enabled = false; + preparing = false; + releaseMediaSignals(); + releaseRecognizer(); + main.post(() -> restorePlayback(true)); + notifyState(State.ERROR, message(e)); } - enabled = false; - preparing = false; - releaseMediaSignals(); - releaseRecognizer(); - main.post(() -> restorePlayback(true)); - notifyState(request, State.ERROR, message(e)); - } + }); } public synchronized void disable() { @@ -397,7 +352,6 @@ public final class RealtimeSubtitleController { private void drainAudio() { if (!draining.compareAndSet(false, true)) return; - int drainGeneration = generation; worker.execute(() -> { try { AudioChunk chunk; @@ -405,7 +359,7 @@ public final class RealtimeSubtitleController { if (chunk.generation() == generation && chunk.timelineGeneration() == timelineGeneration.get()) transcribe(chunk); } } catch (Throwable e) { - failRecognizer(e, drainGeneration); + failRecognizer(e); } finally { draining.set(false); if (enabled && !audioQueue.isEmpty()) drainAudio(); @@ -431,9 +385,8 @@ public final class RealtimeSubtitleController { current.translate(sourceLanguage, text, translated -> enqueueCue(translated, startUs, endUs, timelineToken)); } - private synchronized void failRecognizer(Throwable error, int expectedGeneration) { + private void failRecognizer(Throwable error) { synchronized (this) { - if (expectedGeneration != generation) return; if (!enabled && !preparing) return; generation++; timelineGeneration.incrementAndGet(); @@ -681,21 +634,10 @@ public final class RealtimeSubtitleController { } private void releaseRecognizer() { - RealtimeSubtitleRecognizer currentRecognizer = recognizer; - RealtimeSubtitleTranslator currentTranslator = translator; - recognizer = null; - translator = null; - releaseRecognizer(currentRecognizer, currentTranslator); - } - - private void resetRecognizer(RealtimeSubtitleRecognizer recognizer, RealtimeSubtitleTranslator translator) { - if (translator != null) translator.reset(); - if (recognizer != null) recognizer.reset(); - } - - private void releaseRecognizer(RealtimeSubtitleRecognizer recognizer, RealtimeSubtitleTranslator translator) { if (translator != null) translator.release(); if (recognizer != null) recognizer.release(); + recognizer = null; + translator = null; previousChunkEndUs = C.TIME_UNSET; } @@ -786,14 +728,6 @@ public final class RealtimeSubtitleController { else main.post(action); } - private void notifyState(int expectedGeneration, State state, String message) { - main.post(() -> { - if (expectedGeneration != generation) return; - Listener callback = listener.get(); - if (callback != null) callback.onStateChanged(state, message == null ? "" : message); - }); - } - private void notifyState(State state, String message) { main.post(() -> { Listener callback = listener.get(); diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizer.java b/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizer.java index 09ce64a680..f4c5016c4b 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizer.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizer.java @@ -25,6 +25,7 @@ import java.io.File; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -51,6 +52,7 @@ final class RealtimeSubtitleRecognizer { private OfflineRecognizer offlineRecognizer; private Vad vad; private ExecutorService recognitionExecutor; + private Future recognitionFuture; private volatile boolean released; private long streamBaseUs = UNSET_US; private long vadBaseUs = UNSET_US; @@ -90,7 +92,7 @@ final class RealtimeSubtitleRecognizer { thread.setPriority(Thread.NORM_PRIORITY - 1); return thread; }); - recognitionExecutor.execute(this::recognizeLoop); + recognitionFuture = recognitionExecutor.submit(this::recognizeLoop); } } catch (RuntimeException | Error error) { release(); @@ -123,7 +125,15 @@ final class RealtimeSubtitleRecognizer { released = true; decodeGeneration.incrementAndGet(); speechQueue.clear(); - awaitRecognitionTermination(recognitionExecutor); + if (recognitionFuture != null) { + try { + recognitionFuture.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Throwable ignored) { + } + } + if (recognitionExecutor != null) recognitionExecutor.shutdownNow(); if (onlineStream != null) onlineStream.release(); if (onlineRecognizer != null) onlineRecognizer.release(); if (vad != null) vad.release(); @@ -134,25 +144,6 @@ final class RealtimeSubtitleRecognizer { offlineRecognizer = null; } - // A thread interruption is not proof that JNI decode has returned. Keep native objects - // alive and the owner occupied until the actual worker exits; preserve interrupt status. - static void awaitRecognitionTermination(ExecutorService executor) { - if (executor == null) return; - executor.shutdown(); - boolean interrupted = false; - try { - while (true) { - try { - if (executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS)) return; - } catch (InterruptedException e) { - interrupted = true; - } - } - } finally { - if (interrupted) Thread.currentThread().interrupt(); - } - } - private void acceptStreaming(float[] samples, long startUs, long endUs, int timelineToken) { if (streamBaseUs == UNSET_US) streamBaseUs = startUs; onlineStream.acceptWaveform(samples, SAMPLE_RATE); diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactory.java b/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactory.java new file mode 100644 index 0000000000..dbb12416d0 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactory.java @@ -0,0 +1,106 @@ +package com.fongmi.android.tv.subtitle; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.setting.Setting; + +import java.io.File; +import java.util.Objects; + +public final class RealtimeSubtitleSpeechRecognitionFactory implements SpeechRecognitionFactory { + + private final File root; + private final RealtimeSubtitleModelCatalog.ModelSpec spec; + + public RealtimeSubtitleSpeechRecognitionFactory() { + this(defaultRoot(), selectedModel()); + } + + private RealtimeSubtitleSpeechRecognitionFactory( + File root, RealtimeSubtitleModelCatalog.ModelSpec spec) { + this.root = Objects.requireNonNull(root, "root"); + this.spec = Objects.requireNonNull(spec, "spec"); + } + + public static RealtimeSubtitleSpeechRecognitionFactory forRoot(File root) { + return new RealtimeSubtitleSpeechRecognitionFactory(root, selectedModel()); + } + + public static boolean isSelectedModelReady() { + return new RealtimeSubtitleSpeechRecognitionFactory().isReady(); + } + + @Override + public boolean isReady() { + for (RealtimeSubtitleModelCatalog.ModelFile model + : RealtimeSubtitleModelCatalog.downloads(spec)) { + File target = modelFile(model); + if (!RealtimeSubtitleModelVerifier.isVerified( + target, model.size(), model.sha256())) { + return false; + } + } + return true; + } + + @Override + public Session create(Listener listener) { + Objects.requireNonNull(listener, "listener"); + if (!isReady()) throw new IllegalStateException("speech model is not ready"); + RealtimeSubtitleRecognizer recognizer = RealtimeSubtitleRecognizer.create( + modelDirectory(), vadFile(), spec, new RealtimeSubtitleRecognizer.Listener() { + @Override + public void onResult(String text, long startUs, long endUs, + int timelineToken) { + listener.onResult(text, startUs, endUs, timelineToken); + } + + @Override + public void onError(Throwable error) { + listener.onError(error); + } + }); + return new Session() { + private boolean closed; + + @Override + public void accept(float[] samples, long startUs, long endUs, + int timelineToken) { + if (!closed) recognizer.accept(samples, startUs, endUs, timelineToken); + } + + @Override + public void reset() { + if (!closed) recognizer.reset(); + } + + @Override + public void close() { + if (closed) return; + closed = true; + recognizer.release(); + } + }; + } + + private File modelFile(RealtimeSubtitleModelCatalog.ModelFile model) { + File parent = model.equals(RealtimeSubtitleModelCatalog.vad()) + ? root : modelDirectory(); + return new File(parent, model.relativePath()); + } + + private File modelDirectory() { + return new File(root, spec.directory()); + } + + private File vadFile() { + return new File(root, RealtimeSubtitleModelCatalog.vad().relativePath()); + } + + private static File defaultRoot() { + return new File(App.get().getFilesDir(), "realtime_subtitle"); + } + + private static RealtimeSubtitleModelCatalog.ModelSpec selectedModel() { + return RealtimeSubtitleModelCatalog.find(Setting.getRealtimeSubtitleModel()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/SpeechRecognitionFactory.java b/app/src/main/java/com/fongmi/android/tv/subtitle/SpeechRecognitionFactory.java new file mode 100644 index 0000000000..2c65c6f55e --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/SpeechRecognitionFactory.java @@ -0,0 +1,23 @@ +package com.fongmi.android.tv.subtitle; + +public interface SpeechRecognitionFactory { + + interface Listener { + void onResult(String text, long startUs, long endUs, int timelineToken); + + void onError(Throwable error); + } + + interface Session extends AutoCloseable { + void accept(float[] samples, long startUs, long endUs, int timelineToken); + + void reset(); + + @Override + void close(); + } + + boolean isReady(); + + Session create(Listener listener); +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleMatchService.java b/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleMatchService.java index ad39102169..59c7a66892 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleMatchService.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleMatchService.java @@ -27,13 +27,13 @@ public final class SubtitleMatchService { private final SubtitleContextBuilder contextBuilder; private final SubtitleQueryPlanner queryPlanner; + private final SubtitleProviderRegistry registry; private final SubtitleRanker ranker; private final Map> jobs; private final Map generations; - private volatile SubtitleProviderRegistry registry; public SubtitleMatchService() { - this(new SubtitleContextBuilder(), new SubtitleQueryPlanner(), null, new SubtitleRanker()); + this(new SubtitleContextBuilder(), new SubtitleQueryPlanner(), new SubtitleProviderRegistry(), new SubtitleRanker()); } SubtitleMatchService(SubtitleContextBuilder contextBuilder, SubtitleQueryPlanner queryPlanner, SubtitleProviderRegistry registry, SubtitleRanker ranker) { @@ -45,23 +45,6 @@ public final class SubtitleMatchService { this.generations = new ConcurrentHashMap<>(); } - /** - * 字幕提供方注册表的首次初始化会同步启动 CPython 并导入 webhtv_logging / app 模块 - * (设备实测约 0.9s,debuggerd 抓栈显示主线程正停在 libpython3.10.so)。 - * 默认构造器过去在这里 eager 取单例,于是三个 Activity 的构造期 - * (VideoActivity / TmdbDetailActivity -> SubtitlePlaybackSession -> DefaultController - * -> SubtitleMatchService)都要付这份成本,直接堵住播放页首帧。 - * 现在改为惰性解析,且所有调用点都在 Task 的后台线程内。 - */ - private SubtitleProviderRegistry registry() { - SubtitleProviderRegistry current = registry; - if (current != null) return current; - synchronized (this) { - if (registry == null) registry = SubtitleProviderRegistry.get(); - return registry; - } - } - public void autoMatch(SubtitleRequest request, Listener listener) { if (request == null) return; if (request.isManualOnly()) { @@ -69,6 +52,12 @@ public final class SubtitleMatchService { dispatch(listener, request, SubtitleMatchResult.skipped("manual_only")); return; } + if (registry.enabledProviders().isEmpty()) { + Log.w(TAG, "auto skipped reason=provider_unavailable title=" + request.getVodName()); + dispatch(listener, request, SubtitleMatchResult.skipped("provider_unavailable")); + return; + } + Log.i(TAG, "auto start title=" + request.getVodName() + " episode=" + request.getEpisodeName() + " trigger=" + request.getTrigger()); String playbackKey = request.getPlaybackKey(); cancel(playbackKey); int generation = generations.merge(playbackKey, 1, Integer::sum); @@ -82,20 +71,20 @@ public final class SubtitleMatchService { public void manualSearch(SubtitleRequest request, String keyword, Listener listener) { if (request == null) return; + if (registry.enabledProviders().isEmpty()) { + Log.w(TAG, "manual skipped reason=provider_unavailable title=" + request.getVodName()); + dispatch(listener, request, SubtitleMatchResult.skipped("provider_unavailable")); + return; + } String text = keyword == null ? "" : keyword.trim(); Log.i(TAG, "manual start title=" + request.getVodName() + " episode=" + request.getEpisodeName() + " keyword=" + text); Task.searchExecutor().submit(() -> { SubtitleMatchResult result; try { - if (registry().enabledProviders().isEmpty()) { - Log.w(TAG, "manual skipped reason=provider_unavailable title=" + request.getVodName()); - dispatch(listener, request, SubtitleMatchResult.skipped("provider_unavailable")); - return; - } SubtitleContext context = contextBuilder.build(request); List queries = TextUtils.isEmpty(text) ? queryPlanner.build(context) : queryPlanner.buildManual(context, text); Log.i(TAG, "manual context title=" + context.getCanonicalTitle() + " original=" + context.getOriginalTitle() + " year=" + context.getYear() + " tmdb=" + context.hasTmdbIdentity() + " custom=" + !TextUtils.isEmpty(text) + " queries=" + queries.size()); - List ranked = ranker.rank(registry().search(queries, context), context); + List ranked = ranker.rank(registry.search(queries, context), context); Log.i(TAG, "manual ranked count=" + ranked.size() + " best=" + best(ranked)); result = SubtitleMatchResult.noMatch(ranked, ranked.isEmpty() ? "empty_result" : "manual_candidates"); } catch (Throwable e) { @@ -113,7 +102,7 @@ public final class SubtitleMatchService { SubtitleMatchResult result; try { SubtitleContext context = contextBuilder.build(request); - SubtitleAsset asset = registry().resolve(candidate, context); + SubtitleAsset asset = registry.resolve(candidate, context); Log.i(TAG, "resolve result matched=" + (asset != null) + " provider=" + candidate.getProvider() + " id=" + candidate.getCandidateId()); result = asset == null ? SubtitleMatchResult.noMatch(List.of(candidate), "resolve_failed") : SubtitleMatchResult.matched(candidate, asset, List.of(candidate)); } catch (Throwable e) { @@ -133,14 +122,6 @@ public final class SubtitleMatchService { private void runAutoMatch(SubtitleRequest request, int generation, Listener listener) { SubtitleMatchResult result; - // 这条检查从 autoMatch 搬进来:调用方在 Task.scheduler 线程上,而注册表的首次初始化 - // 要同步启动 CPython。搬到这里后,探测和启动都落在 Task.searchExecutor 的后台线程上。 - if (registry().enabledProviders().isEmpty()) { - Log.w(TAG, "auto skipped reason=provider_unavailable title=" + request.getVodName()); - jobs.remove(request.getPlaybackKey()); - dispatch(listener, request, SubtitleMatchResult.skipped("provider_unavailable")); - return; - } try { SubtitleContext context = contextBuilder.build(request); result = matchContext(context, "auto"); @@ -161,11 +142,11 @@ public final class SubtitleMatchService { private SubtitleMatchResult matchContext(SubtitleContext context, String phase) throws Exception { List queries = queryPlanner.build(context); Log.i(TAG, phase + " context title=" + context.getCanonicalTitle() + " original=" + context.getOriginalTitle() + " year=" + context.getYear() + " tmdb=" + context.hasTmdbIdentity() + " queries=" + queries.size()); - List ranked = ranker.rank(registry().search(queries, context), context); + List ranked = ranker.rank(registry.search(queries, context), context); SubtitleCandidate selected = ranker.pickBest(ranked, context); Log.i(TAG, phase + " ranked count=" + ranked.size() + " selected=" + best(selected) + " best=" + best(ranked)); if (selected == null) return SubtitleMatchResult.noMatch(ranked, ranked.isEmpty() ? "empty_result" : "below_threshold"); - SubtitleAsset asset = registry().resolve(selected, context); + SubtitleAsset asset = registry.resolve(selected, context); Log.i(TAG, phase + " resolve matched=" + (asset != null) + " selected=" + best(selected)); return asset == null ? SubtitleMatchResult.noMatch(ranked, "resolve_failed") : SubtitleMatchResult.matched(selected, asset, ranked); } diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleTmdbResolver.java b/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleTmdbResolver.java index 7233111d67..6f72e794a9 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleTmdbResolver.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleTmdbResolver.java @@ -1,9 +1,7 @@ package com.fongmi.android.tv.subtitle; import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.TmdbConfig; import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.subtitle.model.ResolvedMediaIdentity; import com.fongmi.android.tv.subtitle.model.SubtitleRequest; @@ -24,7 +22,7 @@ public final class SubtitleTmdbResolver { TmdbItem tmdbItem = request.getTmdbItem(); TmdbEpisode tmdbEpisode = request.getTmdbEpisode(); if (tmdbItem == null && request.isAllowTmdbLookup() && !SubtitleStrings.isEmpty(request.getSiteKey()) && !SubtitleStrings.isEmpty(request.getVodId())) { - tmdbItem = Setting.getTmdbMatchCache().find(request.getSiteKey(), request.getVodId(), request.getVodName(), TmdbLanguagePolicy.requestLanguage(TmdbConfig.effectiveCurrent(), Setting.getLanguage())); + tmdbItem = Setting.getTmdbMatchCache().find(request.getSiteKey(), request.getVodId(), request.getVodName()); } ResolvedMediaIdentity.Builder builder = ResolvedMediaIdentity.builder(); diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/AssrtSubtitleProvider.java b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/AssrtSubtitleProvider.java index bb679bd1b8..e50ade470e 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/AssrtSubtitleProvider.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/AssrtSubtitleProvider.java @@ -4,7 +4,7 @@ import android.text.TextUtils; import android.util.Log; import com.fongmi.android.tv.App; -import com.fongmi.android.tv.subtitle.source.SubtitleSourceEnvironment; +import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.subtitle.SubtitleTitleParser; import com.fongmi.android.tv.subtitle.cache.SubtitleAssetStore; import com.fongmi.android.tv.subtitle.model.SubtitleAsset; @@ -55,7 +55,7 @@ public final class AssrtSubtitleProvider implements SubtitleProvider { @Override public boolean isEnabled() { - boolean enabled = !TextUtils.isEmpty(SubtitleSourceEnvironment.resolveToken("assrt", "ASSRT_TOKEN")); + boolean enabled = !TextUtils.isEmpty(Setting.getSubtitleAssrtToken()); if (!enabled) Log.w(TAG, "assrt disabled reason=empty_token"); return enabled; } @@ -65,7 +65,7 @@ public final class AssrtSubtitleProvider implements SubtitleProvider { List items = new ArrayList<>(); if (!isEnabled() || query == null || TextUtils.isEmpty(query.getText())) return items; Log.i(TAG, "assrt search request q=" + query.getText()); - String url = API_BASE + "/sub/search?token=" + encode(SubtitleSourceEnvironment.resolveToken("assrt", "ASSRT_TOKEN")) + "&q=" + encode(query.getText()) + "&is_file=1&cnt=15"; + String url = API_BASE + "/sub/search?token=" + encode(Setting.getSubtitleAssrtToken()) + "&q=" + encode(query.getText()) + "&is_file=1&cnt=15"; try (Response response = OkHttp.client().newCall(new Request.Builder().url(url).get().build()).execute()) { if (!response.isSuccessful() || response.body() == null) { Log.w(TAG, "assrt search http_failed code=" + response.code() + " q=" + query.getText()); @@ -110,7 +110,7 @@ public final class AssrtSubtitleProvider implements SubtitleProvider { String id = safeString(payload, "id"); if (TextUtils.isEmpty(id)) id = candidate.getCandidateId(); Log.i(TAG, "assrt detail request id=" + id); - String url = API_BASE + "/sub/detail?token=" + encode(SubtitleSourceEnvironment.resolveToken("assrt", "ASSRT_TOKEN")) + "&id=" + encode(id); + String url = API_BASE + "/sub/detail?token=" + encode(Setting.getSubtitleAssrtToken()) + "&id=" + encode(id); try (Response response = OkHttp.client().newCall(new Request.Builder().url(url).get().build()).execute()) { if (!response.isSuccessful() || response.body() == null) { Log.w(TAG, "assrt detail http_failed code=" + response.code() + " id=" + id); diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProvider.java b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProvider.java index 1a7c4b7bc0..f81c41bb5a 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProvider.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProvider.java @@ -11,10 +11,6 @@ public interface SubtitleProvider { String getName(); - default String getKey() { - return getName(); - } - boolean isEnabled(); default boolean isQueryIndependent() { diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProviderRegistry.java b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProviderRegistry.java index ab12f41a87..18bc406e7a 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProviderRegistry.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/SubtitleProviderRegistry.java @@ -2,15 +2,6 @@ package com.fongmi.android.tv.subtitle.provider; import android.util.Log; -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.subtitle.source.SubtitleScriptRuntimeFactory; -import com.fongmi.android.tv.subtitle.source.SubtitleSourceManager; -import com.github.catvod.utils.Asset; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - import com.fongmi.android.tv.subtitle.model.SubtitleAsset; import com.fongmi.android.tv.subtitle.model.SubtitleCandidate; import com.fongmi.android.tv.subtitle.model.SubtitleContext; @@ -31,18 +22,11 @@ public final class SubtitleProviderRegistry { private static final String TAG = "SubtitleMatch"; private static final int MAX_SEARCH_THREADS = 8; - private static final SubtitleProviderRegistry INSTANCE = new SubtitleProviderRegistry(); private final Map providers; - private SubtitleSourceManager builtinSourceManager; public SubtitleProviderRegistry() { - this.providers = new LinkedHashMap<>(); - if (!installBuiltinSources()) registerLegacyProviders(); - } - - public static SubtitleProviderRegistry get() { - return INSTANCE; + this(new AssrtSubtitleProvider(), new XunleiSubtitleProvider(), new ShooterSubtitleProvider()); } SubtitleProviderRegistry(SubtitleProvider... providers) { @@ -50,52 +34,11 @@ public final class SubtitleProviderRegistry { if (providers != null) for (SubtitleProvider provider : providers) register(provider); } - private boolean installBuiltinSources() { - if (App.get() == null) return false; - try { - JsonObject manifest = JsonParser.parseString(Asset.read("subtitle_sources/builtin/manifest.json")).getAsJsonObject(); - JsonArray subtitles = manifest.has("subtitles") && manifest.get("subtitles").isJsonArray() - ? manifest.getAsJsonArray("subtitles") : new JsonArray(); - builtinSourceManager = new SubtitleSourceManager(this, new SubtitleScriptRuntimeFactory()); - return !builtinSourceManager.install("builtin", manifest.toString(), "assets://subtitle_sources/builtin/manifest.json").isEmpty(); - } catch (Throwable error) { - try { - Log.w(TAG, "builtin subtitle sources unavailable; using legacy providers", error); - } catch (Throwable ignored) { - } - builtinSourceManager = null; - return false; - } - } - - private void registerLegacyProviders() { - register(new AssrtSubtitleProvider()); - register(new XunleiSubtitleProvider()); - register(new ShooterSubtitleProvider()); - } - - public void destroy() { - if (builtinSourceManager != null) builtinSourceManager.destroy(); - } - public void register(SubtitleProvider provider) { if (provider == null || isEmpty(provider.getName())) return; providers.put(provider.getName(), provider); } - public void unregister(String providerName) { - if (isEmpty(providerName)) return; - providers.remove(providerName); - } - - public List providers() { - return new ArrayList<>(providers.values()); - } - - public List providerNames() { - return new ArrayList<>(providers.keySet()); - } - public List enabledProviders() { List items = new ArrayList<>(); for (SubtitleProvider provider : providers.values()) if (provider.isEnabled()) items.add(provider); diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/T3SubtitleProvider.java b/app/src/main/java/com/fongmi/android/tv/subtitle/provider/T3SubtitleProvider.java deleted file mode 100644 index e15d98ee43..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/provider/T3SubtitleProvider.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.fongmi.android.tv.subtitle.provider; - -import com.fongmi.android.tv.subtitle.model.SubtitleAsset; -import com.fongmi.android.tv.subtitle.model.SubtitleCandidate; -import com.fongmi.android.tv.subtitle.model.SubtitleContext; -import com.fongmi.android.tv.subtitle.model.SubtitleQuery; -import com.fongmi.android.tv.subtitle.source.SubtitleProtocolMapper; -import com.fongmi.android.tv.subtitle.source.SubtitleScriptRuntime; -import com.fongmi.android.tv.subtitle.source.SubtitleSourceConfig; -import com.fongmi.android.tv.subtitle.source.SubtitleSourceEnvironment; - -import java.util.List; - -public final class T3SubtitleProvider implements SubtitleProvider { - - private final SubtitleSourceConfig source; - private final SubtitleScriptRuntime runtime; - private final String runtimeKey; - private final com.google.gson.JsonObject templateParams; - - public T3SubtitleProvider(SubtitleSourceConfig source, SubtitleScriptRuntime runtime) { - this(source, runtime, source == null ? "" : source.getKey()); - } - - public T3SubtitleProvider(SubtitleSourceConfig source, SubtitleScriptRuntime runtime, String runtimeKey) { - if (source == null) throw new IllegalArgumentException("source is null"); - if (runtime == null) throw new IllegalArgumentException("runtime is null"); - this.source = source; - this.runtime = runtime; - this.runtimeKey = runtimeKey == null ? "" : runtimeKey; - this.templateParams = source.getParams() == null ? new com.google.gson.JsonObject() : source.getParams().deepCopy(); - } - - public void initialize(String scriptPath) throws Exception { - refreshSensitiveParams(); - SubtitleProtocolMapper.requireSuccess(runtime.init(runtimeKey, scriptPath, source.getParams().toString())); - } - - public void destroy() { - runtime.destroy(runtimeKey); - } - - @Override - public String getName() { - return source.getKey(); - } - - @Override - public String getKey() { - return source.getKey(); - } - - @Override - public boolean isEnabled() { - return source.isEnabled(); - } - - @Override - public boolean isQueryIndependent() { - return source.isQueryIndependent(); - } - - @Override - public List search(SubtitleQuery query, SubtitleContext context) throws Exception { - refreshSensitiveParams(); - String request = SubtitleProtocolMapper.createSearchRequest(source, query, context); - return SubtitleProtocolMapper.parseCandidates(source, query, runtime.search(runtimeKey, request)); - } - - @Override - public SubtitleAsset resolve(SubtitleCandidate candidate, SubtitleContext context) throws Exception { - refreshSensitiveParams(); - String request = SubtitleProtocolMapper.createResolveRequest(source, candidate, context); - return SubtitleProtocolMapper.parseAsset(source, candidate, runtime.resolve(runtimeKey, request)); - } - - private void refreshSensitiveParams() { - source.setParams(SubtitleSourceEnvironment.resolve(source.getKey(), templateParams)); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/PythonSubtitleScriptRuntime.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/PythonSubtitleScriptRuntime.java deleted file mode 100644 index 7e9752b68e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/PythonSubtitleScriptRuntime.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import com.fongmi.chaquo.Loader; -import com.fongmi.chaquo.Spider; - -import java.util.concurrent.ConcurrentHashMap; - -public final class PythonSubtitleScriptRuntime implements SubtitleScriptRuntime { - - private final Loader loader; - private final ConcurrentHashMap spiders; - - public PythonSubtitleScriptRuntime() { - this(new Loader()); - } - - PythonSubtitleScriptRuntime(Loader loader) { - this.loader = loader; - this.spiders = new ConcurrentHashMap<>(); - } - - @Override - public String init(String sourceKey, String scriptPath, String config) { - if (sourceKey == null || sourceKey.isBlank()) { - throw new IllegalArgumentException("sourceKey is empty"); - } - if (scriptPath == null || scriptPath.isBlank()) { - throw new IllegalArgumentException("scriptPath is empty"); - } - Spider previous = spiders.remove(sourceKey); - destroy(previous); - Spider spider = loader.spider(scriptPath); - spiders.put(sourceKey, spider); - try { - return spider.subtitleInit(config); - } catch (Throwable error) { - spiders.remove(sourceKey, spider); - destroy(spider); - throw error; - } - } - - @Override - public String search(String sourceKey, String request) { - return require(sourceKey).subtitleSearch(request); - } - - @Override - public String resolve(String sourceKey, String request) { - return require(sourceKey).subtitleResolve(request); - } - - @Override - public void destroy(String sourceKey) { - destroy(spiders.remove(sourceKey)); - } - - public void destroyAll() { - for (String sourceKey : spiders.keySet()) destroy(sourceKey); - } - - private Spider require(String sourceKey) { - Spider spider = spiders.get(sourceKey); - if (spider == null) throw new IllegalStateException("Subtitle source is not initialized: " + sourceKey); - return spider; - } - - private static void destroy(Spider spider) { - if (spider == null) return; - try { - spider.destroy(); - } catch (Throwable ignored) { - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleProtocolMapper.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleProtocolMapper.java deleted file mode 100644 index a273fbb7a7..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleProtocolMapper.java +++ /dev/null @@ -1,203 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import androidx.media3.common.C; - -import com.fongmi.android.tv.player.PlayerHelper; -import com.fongmi.android.tv.subtitle.model.SubtitleAsset; -import com.fongmi.android.tv.subtitle.model.SubtitleCandidate; -import com.fongmi.android.tv.subtitle.model.SubtitleContext; -import com.fongmi.android.tv.subtitle.model.SubtitleMatchType; -import com.fongmi.android.tv.subtitle.model.SubtitleQuery; -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.List; -import java.util.Locale; -import java.util.UUID; - -public final class SubtitleProtocolMapper { - - private SubtitleProtocolMapper() { - } - - public static void requireSuccess(String response) { - envelope(response); - } - - public static String createSearchRequest(SubtitleSourceConfig source, SubtitleQuery query, SubtitleContext context) { - JsonObject request = baseRequest("search", source, context); - JsonObject value = new JsonObject(); - value.addProperty("text", query == null ? "" : query.getText()); - value.addProperty("language", query == null ? "" : query.getLanguage()); - value.addProperty("source", query == null || query.getSource() == null ? "" : query.getSource().name()); - value.addProperty("strictness", query == null || query.getStrictness() == null ? "" : query.getStrictness().name()); - value.addProperty("year", query == null ? 0 : query.getYear()); - value.addProperty("season", query == null ? -1 : query.getSeasonNumber()); - value.addProperty("episode", query == null ? -1 : query.getEpisodeNumber()); - request.add("query", value); - return request.toString(); - } - - public static String createResolveRequest(SubtitleSourceConfig source, SubtitleCandidate candidate, SubtitleContext context) { - JsonObject request = baseRequest("resolve", source, context); - JsonObject value = new JsonObject(); - if (candidate != null) { - value.addProperty("id", candidate.getCandidateId()); - value.addProperty("name", candidate.getDisplayName()); - value.addProperty("language", candidate.getLanguage()); - value.addProperty("format", candidate.getFormat()); - value.add("payload", parsePayload(candidate.getProviderPayload())); - } - request.add("candidate", value); - return request.toString(); - } - - public static List parseCandidates(SubtitleSourceConfig source, SubtitleQuery query, String response) { - JsonElement data = envelope(response); - JsonArray array; - if (data.isJsonArray()) array = data.getAsJsonArray(); - else if (data.isJsonObject() && data.getAsJsonObject().has("items") && data.getAsJsonObject().get("items").isJsonArray()) array = data.getAsJsonObject().getAsJsonArray("items"); - else array = new JsonArray(); - List result = new ArrayList<>(); - for (JsonElement element : array) { - if (!element.isJsonObject()) continue; - JsonObject item = element.getAsJsonObject(); - String id = string(item, "id"); - if (id.isEmpty()) continue; - String payload = item.has("payload") ? item.get("payload").toString() : ""; - result.add(new SubtitleCandidate( - source.getKey(), id, string(item, "name"), string(item, "language"), - string(item, "format"), string(item, "releaseInfo"), integer(item, "score"), - integer(item, "year"), integer(item, "season", -1), integer(item, "episode", -1), - matchType(string(item, "matchType")), query == null ? "" : query.getText(), - bool(item, "requiresResolve"), payload)); - } - return result; - } - - public static SubtitleAsset parseAsset(SubtitleSourceConfig source, SubtitleCandidate candidate, String response) { - JsonElement data = envelope(response); - if (!data.isJsonObject()) throw new IllegalStateException("Subtitle resolve data must be an object"); - JsonObject item = data.getAsJsonObject(); - String uri = string(item, "url"); - if (uri.isEmpty()) throw new IllegalStateException("Subtitle resolve response has no URL"); - String name = first(string(item, "fileName"), candidate == null ? "" : candidate.getDisplayName()); - String language = first(string(item, "language"), candidate == null ? "" : candidate.getLanguage()); - String format = first(string(item, "format"), candidate == null ? "" : candidate.getFormat()); - long expiresAt = longValue(item, "expiresAt"); - String mime = PlayerHelper.getSubtitleMimeType(name.isEmpty() ? "subtitle." + format : name); - return new SubtitleAsset(uri, "", name, language, mime, C.SELECTION_FLAG_DEFAULT, false, expiresAt); - } - - private static JsonObject baseRequest(String action, SubtitleSourceConfig source, SubtitleContext context) { - JsonObject request = new JsonObject(); - request.addProperty("action", action); - request.addProperty("requestId", UUID.randomUUID().toString()); - JsonObject sourceValue = new JsonObject(); - sourceValue.addProperty("key", source == null ? "" : source.getKey()); - sourceValue.add("params", source == null ? new JsonObject() : source.getParams()); - request.add("source", sourceValue); - request.add("context", context(context)); - return request; - } - - private static JsonObject context(SubtitleContext context) { - JsonObject value = new JsonObject(); - if (context == null) return value; - value.addProperty("playbackKey", context.getPlaybackKey()); - value.addProperty("siteKey", context.getSiteKey()); - value.addProperty("vodId", context.getVodId()); - value.addProperty("mediaType", context.getMediaType()); - value.addProperty("mediaPath", context.getMediaPath()); - JsonArray aliases = new JsonArray(); - for (String alias : context.getAliases()) aliases.add(alias == null ? "" : alias); - value.add("aliases", aliases); - value.addProperty("canonicalTitle", context.getCanonicalTitle()); - value.addProperty("originalTitle", context.getOriginalTitle()); - value.addProperty("year", context.getYear()); - value.addProperty("season", context.getSeasonNumber()); - value.addProperty("episode", context.getEpisodeNumber()); - value.addProperty("episodeTitle", context.getEpisodeTitle()); - value.addProperty("preferredLanguage", context.getPreferredLanguage()); - value.addProperty("originalLanguage", context.getOriginalLanguage()); - value.addProperty("originCountry", context.getOriginCountry()); - value.addProperty("networkStream", context.isNetworkStream()); - return value; - } - - private static JsonElement envelope(String response) { - if (response == null || response.isBlank()) throw new IllegalStateException("Empty subtitle protocol response"); - JsonElement parsed; - try { - parsed = JsonParser.parseString(response); - } catch (Exception error) { - throw new IllegalStateException("Invalid subtitle protocol response", error); - } - if (!parsed.isJsonObject()) throw new IllegalStateException("Subtitle protocol response must be an object"); - JsonObject object = parsed.getAsJsonObject(); - int code = integer(object, "code"); - if (code != 0) throw new IllegalStateException(first(string(object, "message"), "subtitle_protocol_error_" + code)); - return object.has("data") && !object.get("data").isJsonNull() ? object.get("data") : new JsonObject(); - } - - private static JsonElement parsePayload(String payload) { - if (payload == null || payload.isBlank()) return new JsonObject(); - try { - return JsonParser.parseString(payload); - } catch (Exception ignored) { - return new com.google.gson.JsonPrimitive(payload); - } - } - - private static SubtitleMatchType matchType(String value) { - if (value == null || value.isBlank()) return SubtitleMatchType.METADATA_FUZZY; - try { - return SubtitleMatchType.valueOf(value.trim().toUpperCase(Locale.ROOT)); - } catch (Exception ignored) { - return SubtitleMatchType.METADATA_FUZZY; - } - } - - private static String string(JsonObject object, String key) { - try { - return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : ""; - } catch (Exception ignored) { - return ""; - } - } - - private static int integer(JsonObject object, String key) { - return integer(object, key, 0); - } - - private static int integer(JsonObject object, String key, int fallback) { - try { - return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsInt() : fallback; - } catch (Exception ignored) { - return fallback; - } - } - - private static long longValue(JsonObject object, String key) { - try { - return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsLong() : 0L; - } catch (Exception ignored) { - return 0L; - } - } - - private static boolean bool(JsonObject object, String key) { - try { - return object.has(key) && !object.get(key).isJsonNull() && object.get(key).getAsBoolean(); - } catch (Exception ignored) { - return false; - } - } - - private static String first(String first, String second) { - return first == null || first.isEmpty() ? second == null ? "" : second : first; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntime.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntime.java deleted file mode 100644 index e770006002..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntime.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -public interface SubtitleScriptRuntime { - String init(String sourceKey, String scriptPath, String config) throws Exception; - String search(String sourceKey, String request) throws Exception; - String resolve(String sourceKey, String request) throws Exception; - void destroy(String sourceKey); -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntimeFactory.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntimeFactory.java deleted file mode 100644 index 08df0815a3..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleScriptRuntimeFactory.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -public final class SubtitleScriptRuntimeFactory { - - private final SubtitleScriptRuntime pythonRuntime; - - public SubtitleScriptRuntimeFactory() { - this(new PythonSubtitleScriptRuntime()); - } - - SubtitleScriptRuntimeFactory(SubtitleScriptRuntime pythonRuntime) { - this.pythonRuntime = pythonRuntime; - } - - public SubtitleScriptRuntime create(String api) { - String value = api == null ? "" : api.trim().toLowerCase(); - if ("py".equals(value) || "python".equals(value)) return pythonRuntime; - throw new IllegalArgumentException("Unsupported subtitle runtime: " + api); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceConfig.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceConfig.java deleted file mode 100644 index ef999257ec..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import com.google.gson.JsonObject; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -public final class SubtitleSourceConfig { - private String key="", name="", api="", ext="", sha256=""; - private int type, enabled=1, priority, timeout=15, protocolVersion=1; - private boolean queryIndependent; - private List languages=Collections.emptyList(), formats=Collections.emptyList(); - private Map headers=Collections.emptyMap(); - private JsonObject params=new JsonObject(); - public String getKey(){return key;} public String getName(){return name;} public String getApi(){return api;} public String getExt(){return ext;} public String getSha256(){return sha256;} - public int getType(){return type;} public boolean isEnabled(){return enabled!=0;} public int getPriority(){return priority;} public int getTimeout(){return Math.max(1,Math.min(timeout,60));} public int getProtocolVersion(){return protocolVersion;} public boolean isQueryIndependent(){return queryIndependent;} - public List getLanguages(){return languages==null?Collections.emptyList():languages;} public List getFormats(){return formats==null?Collections.emptyList():formats;} public Map getHeaders(){return headers==null?Collections.emptyMap():headers;} public JsonObject getParams(){return params==null?new JsonObject():params;} public void setParams(JsonObject params){this.params=params==null?new JsonObject():params;} - public boolean valid(){return !key.trim().isEmpty()&&!name.trim().isEmpty()&&(type==3||type==4)&&!api.trim().isEmpty()&&(type!=3||!ext.trim().isEmpty())&&protocolVersion==1;} -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceEnvironment.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceEnvironment.java deleted file mode 100644 index 8c608ccdfc..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceEnvironment.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import com.fongmi.android.tv.setting.Setting; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.util.Map; - -public final class SubtitleSourceEnvironment { - - private SubtitleSourceEnvironment() { - } - - public static JsonObject resolve(String sourceKey, JsonObject params) { - return resolve(params, load(sourceKey)); - } - - static JsonObject resolve(JsonObject params, JsonObject environment) { - JsonObject result = params == null ? new JsonObject() : params.deepCopy(); - environment = environment == null ? new JsonObject() : environment; - for (Map.Entry entry : result.entrySet()) { - if (!entry.getValue().isJsonPrimitive() || !entry.getValue().getAsJsonPrimitive().isString()) continue; - String name = placeholderName(entry.getValue().getAsString()); - if (name == null) continue; - JsonElement value = environment.get(name); - entry.setValue(value == null || value.isJsonNull() ? new com.google.gson.JsonPrimitive("") : value.deepCopy()); - } - return result; - } - - private static String placeholderName(String value) { - if (value == null || value.length() < 4 || !value.startsWith("${") || !value.endsWith("}")) return null; - String name = value.substring(2, value.length() - 1); - if (name.isEmpty() || name.charAt(0) < 'A' || name.charAt(0) > 'Z') return null; - for (int i = 1; i < name.length(); i++) { - char valueChar = name.charAt(i); - if ((valueChar < 'A' || valueChar > 'Z') && (valueChar < '0' || valueChar > '9') && valueChar != '_') return null; - } - return name; - } - - public static String resolveToken(String sourceKey, String name) { - JsonElement value = load(sourceKey).get(name); - return value == null || value.isJsonNull() ? "" : value.getAsString(); - } - - private static JsonObject load(String sourceKey) { - String json = Setting.getSubtitleSourceEnvironment(sourceKey); - if (json.isEmpty()) return new JsonObject(); - try { - JsonElement element = JsonParser.parseString(json); - return element.isJsonObject() ? element.getAsJsonObject() : new JsonObject(); - } catch (RuntimeException e) { - return new JsonObject(); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceManager.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceManager.java deleted file mode 100644 index e60cef9463..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceManager.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import com.fongmi.android.tv.subtitle.provider.SubtitleProviderRegistry; -import com.fongmi.android.tv.subtitle.provider.T3SubtitleProvider; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -public final class SubtitleSourceManager { - - private final SubtitleProviderRegistry registry; - private final SubtitleScriptRuntimeFactory runtimeFactory; - private final Set reservedProviderNames; - private final List activeProviders = new ArrayList<>(); - - public SubtitleSourceManager(SubtitleProviderRegistry registry, SubtitleScriptRuntimeFactory runtimeFactory) { - if (registry == null) throw new IllegalArgumentException("registry is null"); - if (runtimeFactory == null) throw new IllegalArgumentException("runtimeFactory is null"); - this.registry = registry; - this.runtimeFactory = runtimeFactory; - this.reservedProviderNames = new HashSet<>(); - reservedProviderNames.addAll(registry.providerNames()); - } - - public synchronized List install(String subscriptionId, String json, String baseUrl) { - String namespace = subscriptionId == null || subscriptionId.isBlank() ? "subscription" : subscriptionId.trim(); - SubtitleSubscription subscription = SubtitleSubscription.parse(json, baseUrl); - List staged = new ArrayList<>(); - Set names = new HashSet<>(reservedProviderNames); - try { - for (SubtitleSourceConfig source : subscription.getSubtitles()) { - if (!source.isEnabled() || source.getType() != 3 || !names.add(source.getKey())) continue; - SubtitleScriptRuntime runtime = runtimeFactory.create(source.getApi()); - T3SubtitleProvider provider = new T3SubtitleProvider(source, runtime, namespace + ":" + source.getKey()); - provider.initialize(SubtitleSubscription.resolve(baseUrl, source.getExt())); - staged.add(provider); - } - } catch (Exception error) { - destroy(staged); - throw new IllegalStateException("Unable to initialize subtitle subscription", error); - } - unregister(activeProviders); - destroy(activeProviders); - activeProviders.clear(); - activeProviders.addAll(staged); - for (T3SubtitleProvider provider : activeProviders) registry.register(provider); - return Collections.unmodifiableList(new ArrayList<>(activeProviders)); - } - - public synchronized List getActiveProviders() { - return Collections.unmodifiableList(new ArrayList<>(activeProviders)); - } - - public synchronized void destroy() { - unregister(activeProviders); - destroy(activeProviders); - activeProviders.clear(); - } - - private void unregister(List providers) { - for (T3SubtitleProvider provider : providers) registry.unregister(provider.getName()); - } - - private static void destroy(List providers) { - for (T3SubtitleProvider provider : providers) { - try { - provider.destroy(); - } catch (Throwable ignored) { - } - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSubscription.java b/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSubscription.java deleted file mode 100644 index e727c71906..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/source/SubtitleSubscription.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import com.google.gson.Gson; -import java.net.URI; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -public final class SubtitleSubscription { - private String protocol="webhtv-subtitle", name=""; private int version=1; private List subtitles=Collections.emptyList(); - public static SubtitleSubscription parse(String json,String base){ SubtitleSubscription v=new Gson().fromJson(json,SubtitleSubscription.class); if(v==null||v.version!=1) throw new IllegalArgumentException("PROTOCOL_UNSUPPORTED"); List out=new ArrayList<>(); Set keys=new HashSet<>(); if(v.subtitles!=null) for(SubtitleSourceConfig s:v.subtitles) if(s!=null&&s.valid()&&keys.add(s.getKey())) out.add(s); out.sort((a,b)->Integer.compare(b.getPriority(),a.getPriority())); v.subtitles=out; return v; } - public List getSubtitles(){return Collections.unmodifiableList(subtitles);} public int getVersion(){return version;} public String getName(){return name;} public static String resolve(String base,String ext){ if(ext==null||ext.isEmpty()) return ""; if(ext.matches("^[a-zA-Z][a-zA-Z0-9+.-]*:.*")) return ext; return URI.create(base).resolve(ext).toString(); } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java b/app/src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java deleted file mode 100644 index 8171f09baa..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.GradientDrawable; -import android.graphics.drawable.StateListDrawable; -import android.view.View; -import android.widget.TextView; - -/** - * Row rendering for the “Appearance & language” dialog. - * - *

Why this exists (measured defect, not a style preference): both - * {@code AppearanceDialog} flavours used to paint every row with the fixed light - * {@code selector_git_cloud_card} ({@code #F8F9FA} fill, {@code #DADCE0} stroke, - * {@code #E8F0FE}/{@code #0B57D0} focus) while the row text already came from - * {@link ThemeController#current()}. The TV flavour compiles the dark token table - * ({@code values/webhtv_tokens.xml} is overridden by the dark table and the flavour - * ships no light table), so on TV the label rendered {@code #E2E2E9} and the value - * {@code #C4C6D0} on a {@code #F8F9FA} card. Measured on device - * ({@code 192.168.50.3:5559}, 1920x1080): label 1.16:1 and value 1.36:1 against the - * row background - i.e. the reported "text is unreadable". The same fixed card also - * ignored any custom theme profile. - * - *

The row therefore has to follow the same palette as its own text. It cannot be a - * static {@code ?attr/color*} drawable: {@code ?attr/color*} is a compile-time - * resource, Android exposes no public API to rewrite it at runtime, and the whole - * point of the profile feature is that a user override must reach this dialog. So the - * fill/stroke are built here from {@link ThemeController#current()} - the same single - * source of truth the row text already used - and {@link ThemeController#bindTheme} - * then re-colours the already-attached rows whenever the user changes the profile. - * - *

The non-focused fill is {@code colorSurfaceContainerHigh}, the role - * {@code shape_shell_proxy_dialog} gives the dialog panel. That is deliberate: a row - * that reuses the panel colour reads as an inset list inside the panel (the Material 3 - * "surface container" pattern), and on the shipped palettes it clears WCAG AA for both - * row texts by a wide margin - dark 9.05:1 / 6.85:1, light 13.23:1 / 7.19:1. - */ -public final class AppearanceRowTheme { - - private static final int CORNER_DP = 8; - - private AppearanceRowTheme() { - } - - /** - * The row background for the current palette. - * - *

Focus is the only state that needs its own colour: the row is a TV remote - * target, so it must show a ring even before it is pressed. The ring is drawn in - * {@code colorPrimary} inside a {@code colorPrimaryContainer} fill rather than as a - * primary fill, because the focused row also keeps its own on-surface text - a - * primary fill would put that text at 1.3-2.4:1 on the shipped palettes. Measured - * on the shipped dark table: focused fill vs row text 7.09:1, ring vs fill 5.32:1, - * ring vs the surrounding panel 6.78:1. - */ - public static Drawable background(Context context, ThemeTokens tokens) { - ThemeTokens safe = tokens == null ? ThemeTokens.dark() : tokens; - StateListDrawable states = new StateListDrawable(); - states.addState(new int[]{android.R.attr.state_focused}, - shape(context, safe.colorPrimaryContainer(), safe.colorPrimary(), 2)); - states.addState(new int[]{android.R.attr.state_pressed}, - shape(context, safe.colorPrimaryContainer(), safe.colorPrimary(), 2)); - states.addState(new int[]{}, - shape(context, safe.colorSurfaceContainerHigh(), safe.colorOutlineVariant(), 1)); - return states; - } - - /** - * Applies the palette to one row: background plus the two text colours the row owns. - * - *

{@code title} and {@code summary} may be {@code null} so the caller can colour a - * partially built row. - */ - public static void apply(View row, TextView title, TextView summary, ThemeTokens tokens) { - ThemeTokens safe = tokens == null ? ThemeTokens.dark() : tokens; - if (row != null) row.setBackground(background(row.getContext(), safe)); - if (title != null) title.setTextColor(safe.colorOnSurface()); - if (summary != null) summary.setTextColor(safe.colorOnSurfaceVariant()); - } - - private static GradientDrawable shape(Context context, int fill, int stroke, int strokeDp) { - float density = context.getResources().getDisplayMetrics().density; - GradientDrawable drawable = new GradientDrawable(); - drawable.setShape(GradientDrawable.RECTANGLE); - drawable.setColor(fill); - drawable.setCornerRadius(CORNER_DP * density); - if (strokeDp > 0) drawable.setStroke(Math.max(1, Math.round(strokeDp * density)), stroke); - return drawable; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/LiveListScrim.java b/app/src/main/java/com/fongmi/android/tv/theme/LiveListScrim.java deleted file mode 100644 index a4830ad810..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/LiveListScrim.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; - -/** - * 直播页频道抽屉(列表)的黑色遮盖层。 - * - *

强度来自主题 profile 的 {@code liveListScrimOpacity}(见 - * {@link ThemeController#configuredLiveListScrimOpacity(boolean)}):未设置时回落到 - * {@link #DEFAULT_OPACITY}。遮盖层是整块统一的一块纯黑,与 {@code shape_live_list.xml} 的 - * {@code black_80} 逐值一致;滑块是线性映射——100% 时整块纯黑,20% 时整块只剩 20% 黑, - * 左右边缘一致。色相恒为纯黑:主题 profile 只能缩放 alpha,不能改色相, - * {@code shape_live_focused.xml} 的焦点环对比度前提因此始终成立。 - */ -public final class LiveListScrim { - - /** 未设置主题槽时的默认强度;0.80 对应整块 80% 黑。 */ - public static final float DEFAULT_OPACITY = 0.80f; - - private LiveListScrim() { - } - - /** The scrim for the current theme snapshot, or {@code null} when unavailable. */ - public static Drawable create(Context context) { - if (context == null) return null; - float opacity = resolveOpacity(ThemeController.configuredLiveListScrimOpacity(!ThemeController.isNight(context))); - return new ColorDrawable(withAlpha(opacity)); - } - - static float resolveOpacity(Float configured) { - float value = configured == null ? DEFAULT_OPACITY : configured; - return Math.max(0f, Math.min(1f, value)); - } - - static int withAlpha(float opacity) { - int alpha = Math.max(0, Math.min(255, Math.round(opacity * 255f))); - return alpha << 24; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/PlaybackMenuScrim.java b/app/src/main/java/com/fongmi/android/tv/theme/PlaybackMenuScrim.java deleted file mode 100644 index 14f28a92d2..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/PlaybackMenuScrim.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.GradientDrawable; - -/** - * 播放页底部菜单的黑色遮盖层。 - * - *

强度来自主题 profile 的 {@code playerScrimOpacity}(见 - * {@link ThemeController#configuredPlayerScrimOpacity(boolean)}):未设置时回落到 - * {@link #DEFAULT_OPACITY},顶部恒为底部的 {@link #TOP_RATIO},保留从视频到控制条的 - * 柔和过渡。色相固定取 {@code colorPlayerScrim} 的 RGB:主题 profile 只能改 alpha, - * 不能把播放菜单染成不可读的配色。 - */ -public final class PlaybackMenuScrim { - - /** 未设置主题槽时的默认强度;0.80 对应顶部 60% / 底部 80% 黑。 */ - public static final float DEFAULT_OPACITY = 0.80f; - - /** 顶部相对底部的强度比例。 */ - static final float TOP_RATIO = 0.75f; - - private static final float CORNER_DP = 8f; - - private PlaybackMenuScrim() { - } - - /** The scrim for the current theme snapshot, or {@code null} when unavailable. */ - public static Drawable create(Context context) { - if (context == null) return null; - int rgb = ThemeController.current().colorPlayerScrim() & 0x00FFFFFF; - float opacity = resolveOpacity(ThemeController.configuredPlayerScrimOpacity(!ThemeController.isNight(context))); - GradientDrawable drawable = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, gradientColors(rgb, opacity)); - float radius = CORNER_DP * context.getResources().getDisplayMetrics().density; - drawable.setCornerRadii(new float[]{radius, radius, radius, radius, 0f, 0f, 0f, 0f}); - return drawable; - } - - static float resolveOpacity(Float configured) { - float value = configured == null ? DEFAULT_OPACITY : configured; - return Math.max(0f, Math.min(1f, value)); - } - - static int[] gradientColors(int rgb, float opacity) { - float clamped = Math.max(0f, Math.min(1f, opacity)); - return new int[]{withAlpha(rgb, clamped * TOP_RATIO), withAlpha(rgb, clamped)}; - } - - static int withAlpha(int rgb, float opacity) { - int alpha = Math.max(0, Math.min(255, Math.round(opacity * 255f))); - return alpha << 24 | (rgb & 0x00FFFFFF); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/PlayerSettingsRowTheme.java b/app/src/main/java/com/fongmi/android/tv/theme/PlayerSettingsRowTheme.java deleted file mode 100644 index 309f218726..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/PlayerSettingsRowTheme.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.GradientDrawable; -import android.graphics.drawable.StateListDrawable; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.core.widget.TintableImageSourceView; - -/** - * Theme-aware rendering for the player OSD/button settings rows. - * - *

The legacy rows paired a fixed light card with dark-theme foregrounds. - * This renderer owns both sides of the color pair. Its text and icons opt out - * of baseline recoloring: an active dark color can equal a different light - * baseline role and must not be interpreted twice. - */ -public final class PlayerSettingsRowTheme { - - private static final int ROW_RADIUS_DP = 8; - private static final int SWITCH_RADIUS_DP = 24; - - private PlayerSettingsRowTheme() { - } - - /** Applies the row surface and the visible/hidden label role. */ - public static void apply(View row, TextView name, ThemeTokens tokens, boolean visible) { - ThemeTokens safe = tokens == null ? ThemeTokens.dark() : tokens; - if (row != null) row.setBackground(rowBackground(row.getContext(), safe)); - if (name != null) { - name.setTag("webhtv:ignore"); - name.setAlpha(1f); - name.setTextColor(ThemeEditorUi.readable( - visible ? safe.colorOnSurface() : safe.colorOnSurfaceVariant(), safe.colorSurfaceContainerHigh())); - } - } - - /** Applies the semantic surface and icon roles to the eye/eye-slash control. */ - public static void applySwitch(ImageView view, ThemeTokens tokens) { - if (view == null) return; - ThemeTokens safe = tokens == null ? ThemeTokens.dark() : tokens; - view.setTag("webhtv:ignore"); - view.setBackground(switchBackground(view.getContext(), safe)); - ColorStateList tint = switchTint(safe); - if (view instanceof TintableImageSourceView support) support.setSupportImageTintList(tint); - view.setImageTintList(tint); - } - - static Drawable rowBackground(Context context, ThemeTokens tokens) { - // Keep the fill stable when a child takes focus: its label does not own - // the child's drawable state. The ring alone identifies the focused row. - int fill = tokens.colorSurfaceContainerHigh(); - int ring = ThemeEditorUi.readable(tokens.colorFocus(), fill); - StateListDrawable states = new StateListDrawable(); - states.addState(new int[]{android.R.attr.state_focused}, - shape(context, fill, ring, 3, ROW_RADIUS_DP)); - states.addState(new int[]{android.R.attr.state_pressed}, - shape(context, fill, ring, 3, ROW_RADIUS_DP)); - states.addState(new int[]{}, - shape(context, tokens.colorSurfaceContainerHigh(), tokens.colorOutlineVariant(), 1, ROW_RADIUS_DP)); - return states; - } - - static Drawable switchBackground(Context context, ThemeTokens tokens) { - int ring = ThemeEditorUi.readable(tokens.colorFocus(), tokens.colorPrimaryContainer()); - StateListDrawable states = new StateListDrawable(); - states.addState(new int[]{android.R.attr.state_focused}, - shape(context, tokens.colorPrimaryContainer(), ring, 3, SWITCH_RADIUS_DP)); - states.addState(new int[]{android.R.attr.state_pressed}, - shape(context, tokens.colorPrimaryContainer(), ring, 3, SWITCH_RADIUS_DP)); - states.addState(new int[]{}, - shape(context, tokens.colorSurfaceContainerHighest(), tokens.colorOutlineVariant(), 1, SWITCH_RADIUS_DP)); - return states; - } - - private static ColorStateList switchTint(ThemeTokens tokens) { - int[][] states = new int[][]{ - {android.R.attr.state_focused}, - {android.R.attr.state_pressed}, - {} - }; - int[] colors = new int[]{ - tokens.colorOnPrimaryContainer(), - tokens.colorOnPrimaryContainer(), - ThemeEditorUi.readable(tokens.colorOnSurfaceVariant(), tokens.colorSurfaceContainerHighest()) - }; - return new ColorStateList(states, colors); - } - - private static GradientDrawable shape(Context context, int fill, int stroke, int widthDp, int radiusDp) { - float density = context.getResources().getDisplayMetrics().density; - GradientDrawable drawable = new GradientDrawable(); - drawable.setShape(GradientDrawable.RECTANGLE); - drawable.setColor(fill); - drawable.setCornerRadius(radiusDp * density); - drawable.setStroke(Math.max(1, Math.round(widthDp * density)), stroke); - return drawable; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java deleted file mode 100644 index 19aa45a7f4..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java +++ /dev/null @@ -1,1239 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.content.res.Resources; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.DrawableWrapper; -import android.graphics.drawable.GradientDrawable; -import android.graphics.drawable.InsetDrawable; -import android.graphics.drawable.StateListDrawable; -import android.os.Looper; -import android.os.SystemClock; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.R; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.card.MaterialCardView; -import com.google.android.material.shape.MaterialShapeDrawable; - -import java.util.ArrayDeque; -import java.util.Deque; -import java.util.Locale; -import java.util.Map; -import java.util.WeakHashMap; - -/** - * Controlled runtime channel for user theme overrides. - * - *

Android offers no public API to rewrite a compiled {@code ?attr/color*} - * value, so the binder walks the already-created view tree and swaps only colors - * that provably came from a semantic role: a view color is rewritten when it - * exactly equals a frozen baseline role value and is not stateful. Everything - * else - player surfaces, media, posters, brand and health colors, focus - * selectors, unknown colors - is left untouched. - * - *

This runs on the main thread only, is a no-op when the active tokens equal - * the baseline, and never touches a view more than once per token signature. - */ -public final class ThemeBinder { - - private static final String IGNORE_TAG = "webhtv:ignore"; - private static final String[] EXEMPT_CLASS_MARKERS = { - "surface", "texture", "video", "player", "danmaku", "subtitle", - "karaoke", "wall", "logo", "rating", "media", - }; - - /** - * 唯一允许改写焦点态描边的「应用表面」selector 名字(见 {@link #isAppSurfaceFocusRing})。 - * - *

用资源名字而非 {@code R.drawable.*}:这些资源只存在于 leanback 源集,本类在 main 源集 - * 会被 mobile 变体一起编译,硬引用会让 mobile javac 失败。mobile 上解析为 0,白名单为空。 - * - *

新增成员前必须**同时**满足三条,缺一不可: - *

    - *
  1. 宿主画在调色板表面(不是视频画面或固定深色玻璃);
  2. - *
  3. 焦点态环色走 {@code ?attr/tvFocusRing}——不能是 {@code ?attr/colorOnPrimary} 等 - * 「填充色的配对 on-色」。后者是给「焦点态整块填充 {@code colorPrimary}」的控件用的, - * 环与填充必须成对;把它们改写成本类写入的 {@code colorFocus()} 会让环与填充同色 - * (默认色板里 focus == primary),焦点环直接消失。 - * 因此 {@code selector_config_history_item} / {@code selector_site_item} / - * {@code selector_group_button} 这类「primary 填充 + onPrimary 环」的**不在白名单**;
  4. - *
  5. 焦点态宽度已是 {@code @dimen/webhtv_focus_ring_width}(本类无法读回旧宽度,只能按 - * token 重写,宽度未收敛的族必须排除,否则会重新露出卡片圆角外的月牙)。
  6. - *
- */ - private static final String[] APP_SURFACE_FOCUS_RINGS = { - "selector_item", - "selector_item_round", - "selector_vod", - "selector_vod_oval", - "selector_keyboard", - "selector_search_hot_word", - "selector_video_item", - "selector_tv_search_field", - "selector_empty_action", - }; - - /** - * 装载 {@link #ITEM_OVERLAY_FILLS} 的宿主 selector:生产上这些 shape 都是作为它们的 - * {@code } 子项被取到的。 - * - *

为什么指纹必须同时收录**两条**取图路径:同一个 XML 在「直接 {@code getDrawable}」与 - * 「作为 {@code } 子项解析」两种上下文中,{@code ?attr/colorOutline} 这类属性可能解析到 - * 不同取值(实测 {@code shape_tv_search_field} 的常态子项在两条路径下栅格化结果不同,而 - * {@code shape_tv_search_field_focused} 因描边走 {@code ?attr/tvFocusRing} 恰好相同)。 - * 只收一条路径,另一条路径上的宿主就永远匹配不上、功能静默失效。 - */ - private static final String[] ITEM_OVERLAY_PARENTS = { - "selector_item", - "selector_item_round", - "selector_search_hot_word", - "selector_tv_search_field", - }; - - /** - * 唯一允许改写**实心填充**的「条目遮罩」drawable 名字。 - * - *

为什么需要一条独立的名单,而不是按颜色反查角色:这七个 drawable 的填充是 - * {@code @color/black_40}({@code #66000000}),而这个取值在 leanback 里被 28 个 drawable - * 共用——键盘键底({@code shape_keyboard_normal})、卡片信息条带({@code shape_vod_name})、 - * 控件底座({@code shape_widget})、实验区玻璃族都在用它。按颜色反查({@link ThemeColorIndex}) - * 会把它们一起改掉,而那几族正是「刻意固定在不可控底材上的半透明黑」,跟随主题就是可读性回归。 - * 因此这里只认资源名字,并且与焦环族一样用「签名 + 像素指纹」两级判定来确认身份。 - * - *

名单只收编译期填充就是 {@code @color/black_40} 的那几个:它们接上遮罩槽后取值不变, - * 接线本身不改变外观。常态底座({@code shape_item_normal} 一族,填充 {@code @color/black_20}) - * 是另一档强度,单独见 {@link #ITEM_OVERLAY_REST_FILLS}。 - * {@code shape_item_selected}({@code black_30})、{@code shape_keyboard_focused} - * ({@code black_30})仍然排除:它们是选中档/键底的固定取值,不是条目底座。 - * - *

与 {@link #APP_SURFACE_FOCUS_RINGS} 一样按名字解析({@code getIdentifier}):这些资源只在 - * leanback 源集,本类在 main 源集会被 mobile 变体一起编译,硬引用 R.drawable.* 会让 mobile - * javac 失败;mobile 上解析为 0,名单为空,行为与现状一致。 - */ - private static final String[] ITEM_OVERLAY_FILLS = { - "shape_item_focused", - "shape_item_activated", - "shape_item_round_focused", - "shape_item_round_activated", - "shape_search_hot_word_focused", - "shape_tv_search_field", - "shape_tv_search_field_focused", - }; - - /** - * 常态(未聚焦、未选中)的条目底座:{@code shape_item_normal} / - * {@code shape_item_round_normal} / {@code shape_search_hot_word_normal}。 - * - *

为什么要单独一张名单、单独一档强度:它们的编译期填充是 {@code @color/black_20} - * ({@code #33000000}),恰好是遮罩底色 {@code @color/black_40}({@code #66000000})的 - * 一半 alpha。把它们并进 {@link #ITEM_OVERLAY_FILLS} 后整块写入遮罩色,空档案下的 - * 默认外观就会从 20% 黑跳到 40% 黑——那是外观变化而不是主题化,违反「默认外观字节不变」。 - * 因此这一档按半强度跟随遮罩槽:默认 {@code 0x66 / 2 = 0x33} 与编译期取值逐位相等, - * 用户改过槽位时又整体跟着走,「常态比聚焦态浅一半」的层次也不会丢。 - * - *

与 {@link #ITEM_OVERLAY_FILLS} 分成两张表而不是一张表加权重字段:指纹集合必须按强度 - * 分开缓存,否则 {@code shape_item_normal}({@code black_20})与 {@code shape_item_focused} - * ({@code black_40})的指纹会落进同一格,判定时再无从区分强度。 - */ - private static final String[] ITEM_OVERLAY_REST_FILLS = { - "shape_item_normal", - "shape_item_round_normal", - "shape_search_hot_word_normal", - }; - - /** 条目遮罩的两档强度(百分比 alpha):全强度(聚焦/选中)与半强度(常态底座)。 */ - private static final int ITEM_OVERLAY_FULL = 100; - private static final int ITEM_OVERLAY_REST = 50; - - /** 编译期条目遮罩填充 {@code @color/black_40}:全强度成员的身份判据。 */ - private static final int COMPILED_OVERLAY_FILL = 0x66000000; - /** 编译期常态底座填充 {@code @color/black_20}:半强度成员的身份判据。 */ - private static final int COMPILED_REST_FILL = 0x33000000; - - private static final Map APPLIED = new WeakHashMap<>(); - /** - * 视图的白名单判定缓存(命中与未命中都记)。只有「背景实例没换且环色 token 没变」时 - * 才复用结论——RecyclerView 会复用 item 视图,换 drawable 或换绑主题都必须重新判定。 - */ - private static final Map RING_DECIDED = new WeakHashMap<>(); - /** {@link #ringColorToken} 解析不出环色时的哨兵值(不是合法 ARGB,与 0x00000000 区分)。 */ - private static final int RING_COLOR_UNSET = 0x01000000; - - /** - * 一次白名单判定的输入与结论,用于判断缓存是否仍然适用。 - * - *

背景实例用 {@link java.lang.ref.WeakReference} 引用:drawable 会把自己设为宿主的 - * callback,若值里强引用它,就等于「值 → 键」,{@link WeakHashMap} 的键永远不可达回收, - * 长列表反复回收视图时会持续累积。 - */ - private record RingDecision(java.lang.ref.WeakReference states, int ringColor, boolean member) { - } - - /** - * 每个 {@link Resources} 的白名单焦点环「签名 → 像素指纹」集合,避免每个视图都重复解析资源。 - * 指纹把描边也计入,用于排除"填充/圆角/内边距相同但描边不同"的资源(描边无公开 getter)。 - */ - private static final Map>>> WHITELIST_FINGERPRINTS = new WeakHashMap<>(); - /** - * 条目遮罩白名单的「签名 → 像素指纹」集合,按 {@code (Resources, 描边相关主题属性)} 解析一次后缓存。 - * - *

缓存键与 {@link #whitelistFingerprints} 同源:白名单成员带描边,而描边色由视图主题解析 - * ({@code ?attr/tvFocusRing} / {@code ?attr/colorOutline})。只按 {@link Resources} 做键会让结果 - * 与首次调用顺序绑定——先到的主题写入,后到的主题永远拿不到自己的指纹,功能静默失效。 - */ - private static final Map>>>> - ITEM_OVERLAY_FINGERPRINTS = new WeakHashMap<>(); - /** - * 每个 {@link GradientDrawable} 实例的白名单归属结论。 - * - *

为什么要按**实例**记忆而不再每次重算:判定用像素指纹,而指纹里必然包含填充色 - * ({@code shape_item_focused} 与 {@code shape_item_selected} 的圆角、内边距、焦点环完全 - * 相同,只有填充 {@code black_40} vs {@code black_30} 不同——漏掉填充就会误改后者)。本类自己 - * 会把成员的填充改写成用户槽位的颜色,于是第二轮判定时它已经「不像自己」了。实例不变, - * 归属就不变,所以记住第一次(尚未被本类改写时)的结论即可;只有背景实例被换掉 - * (RecyclerView 复用、重新充气)才会重新判定。 - */ - private static final Map ITEM_OVERLAY_MEMBER = new WeakHashMap<>(); - - /** - * 一次条目遮罩归属判定的输入与结论(与 {@link RingDecision} 同一用意:结论必须可失效)。 - * - *

{@code weight} 是强度而不是布尔值:{@link #ITEM_OVERLAY_FULL}(填充写整块遮罩色)/ - * {@link #ITEM_OVERLAY_REST}(填充写半强度,见 {@link #ITEM_OVERLAY_REST_FILLS})/ {@code 0} - * (不是成员)。用强度一档表达,判定的缓存键与焦点环那条路径保持同构。 - */ - private record ItemOverlayDecision(int strokeKey, int weight) { - } - /** 指纹位图边长(px):足够容纳描边差异,又足够便宜。 */ - private static final int FINGERPRINT_SIZE = 48; - private static final Map WATCHED = new WeakHashMap<>(); - private static final Map ROOTS = new WeakHashMap<>(); - private static final Map REBUILT = new WeakHashMap<>(); - - private static long cachedSignature; - - private static volatile long lastBindMillis; - private static volatile int lastBoundViews; - private static volatile int lastWalkedViews; - - private ThemeBinder() { - } - - /** Elapsed time of the most recent non-trivial bind, for the performance gate. */ - public static long lastBindMillis() { - return lastBindMillis; - } - - /** Number of views rewritten by the most recent non-trivial bind. */ - public static int lastBoundViews() { - return lastBoundViews; - } - - public static void bind(View root, ThemeTokens baseline, ThemeTokens active) { - if (root == null || baseline == null || active == null) return; - if (Looper.myLooper() != Looper.getMainLooper()) return; - if (baseline.equals(active)) { - // Default profile: the static Layer 1 theme is already correct, so the - // binder must cost nothing and change nothing. - lastBindMillis = 0L; - lastBoundViews = 0; - return; - } - long started = SystemClock.elapsedRealtimeNanos(); - long signature = signature(active); - if (signature != cachedSignature) { - REBUILT.clear(); - cachedSignature = signature; - } - ThemeColorIndex index = ThemeColorIndex.of(baseline); - // 选中文字色只有「真的被档案改过」才往下传:空档案与只改了别的槽位的档案都必须 - // 逐位保持编译期文字色,否则夜间表(on_item_overlay = #062E6F,而条目宿主实际文字 - // 是 on_wallpaper = #FFFFFF)会在用户仅调整无关槽位时突然变深。 - int overlayText = active.colorOnItemOverlay() == baseline.colorOnItemOverlay() ? 0 : active.colorOnItemOverlay(); - int bound = walk(root, index, active, signature, overlayText); - watchRoot(root, index, active, signature, overlayText); - lastBoundViews = bound; - lastBindMillis = (SystemClock.elapsedRealtimeNanos() - started) / 1_000_000L; - } - - private static int walk(View root, ThemeColorIndex index, ThemeTokens active, long signature, int overlayText) { - int bound = 0; - int walked = 0; - Deque queue = new ArrayDeque<>(); - queue.add(root); - while (!queue.isEmpty()) { - View view = queue.poll(); - if (view == null) continue; - walked++; - if (isExempt(view)) continue; - if (bindView(view, index, active, signature, overlayText)) bound++; - if (view instanceof RecyclerView recyclerView) watchChildren(recyclerView, index, active, signature, overlayText); - if (view instanceof ViewGroup group) { - for (int child = 0; child < group.getChildCount(); child++) { - queue.add(group.getChildAt(child)); - } - } - } - lastWalkedViews = walked; - return bound; - } - - /** - * Fragments and async content attach after the Activity bind points. The root - * is re-walked only when its descendant count actually changed, which keeps the - * scroll path free of repeated traversals. - */ - private static void watchRoot(View root, ThemeColorIndex index, ThemeTokens active, long signature, int overlayText) { - if (ROOTS.containsKey(root)) return; - int[] state = {countDescendants(root)}; - ROOTS.put(root, state); - root.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { - if (state[0] == countDescendants(view)) return; - state[0] = countDescendants(view); - walk(view, index, active, signature, overlayText); - }); - } - - private static int countDescendants(View view) { - if (!(view instanceof ViewGroup group)) return 1; - int count = 1; - for (int child = 0; child < group.getChildCount(); child++) count += countDescendants(group.getChildAt(child)); - return count; - } - - private static void watchChildren(RecyclerView recyclerView, ThemeColorIndex index, ThemeTokens active, long signature, int overlayText) { - if (WATCHED.containsKey(recyclerView)) return; - RecyclerView.OnChildAttachStateChangeListener listener = new RecyclerView.OnChildAttachStateChangeListener() { - @Override - public void onChildViewAttachedToWindow(View view) { - walk(view, index, active, signature, overlayText); - } - - @Override - public void onChildViewDetachedFromWindow(View view) { - APPLIED.remove(view); - } - }; - WATCHED.put(recyclerView, listener); - recyclerView.addOnChildAttachStateChangeListener(listener); - } - - private static boolean bindView(View view, ThemeColorIndex index, ThemeTokens active, long signature, int overlayText) { - Long applied = APPLIED.get(view); - if (applied != null && applied == signature) return false; - ThemeRole explicit = ThemeRole.fromTag(view.getTag()); - boolean changed = false; - if (view instanceof TextView textView) changed |= bindText(textView, explicit, index, active, overlayText); - if (view instanceof ImageView imageView) changed |= bindImageTint(imageView, explicit, index, active); - if (view instanceof MaterialCardView cardView) changed |= bindCard(cardView, explicit, index, active); - if (view instanceof MaterialButton button) changed |= bindButton(button, explicit, index, active); - changed |= bindBackgroundTint(view, explicit, index, active); - changed |= bindBackground(view, explicit, index, active); - changed |= bindForeground(view, explicit, index, active); - if (changed) APPLIED.put(view, signature); - return changed; - } - - private static boolean bindText(TextView view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active, int overlayText) { - boolean changed = false; - ColorStateList colors = rewrite(view.getTextColors(), explicit, index, active); - if (colors != null) { - view.setTextColor(colors); - changed = true; - } - ColorStateList hint = rewrite(view.getHintTextColors(), explicit, index, active); - if (hint != null) { - view.setHintTextColor(hint); - changed = true; - } - // 选中文字色排在角色改写之后:两者都可能落在同一个 TextView 上,遮罩宿主的文字 - // 语义优先(它压在遮罩上,而不是压在调色板表面)。 - if (overlayText != 0 && sitsOnItemOverlay(view)) { - ColorStateList overlayColors = rewriteOverlayText(view.getTextColors(), overlayText); - if (overlayColors != null) { - view.setTextColor(overlayColors); - changed = true; - } - ColorStateList overlayHint = rewriteOverlayText(view.getHintTextColors(), overlayText); - if (overlayHint != null) { - view.setHintTextColor(overlayHint); - changed = true; - } - } - return changed; - } - - /** - * 判定一段文字是不是画在条目遮罩上:自身是遮罩宿主,或自身无背景而**直接**父容器是遮罩宿主。 - * - *

为什么要看父容器:电视首页的功能按钮行({@code adapter_func.xml})、设置页条目 - * ({@code activity_setting.xml})、搜索页范围按钮({@code activity_search.xml})都是 - * 「容器挂 {@code selector_item}、文字是子 {@code TextView}」的形状——只认自身背景会漏掉 - * 它们中的绝大多数。只向上看一层,不递归到任意祖先:再往上就可能走出条目、落进整页背景, - * 那时「文字压在遮罩上」的前提已经不成立。 - * - *

判定复用{@link #itemOverlayFillWeight}那条填充白名单,而**不是**按颜色反查 - * {@code @color/black_40}:{@code black_40} 被 28 个 drawable 共用,按色反查会连带把 - * 键盘键底、搜索框与玻璃面板的文字一起改掉。名单外一律返回 false。 - */ - private static boolean sitsOnItemOverlay(View view) { - if (isItemOverlayHost(view)) return true; - if (view.getBackground() != null) return false; - return view.getParent() instanceof View parent && isItemOverlayHost(parent); - } - - /** 背景是不是条目 selector,且至少一个状态子项命中遮罩白名单。 */ - private static boolean isItemOverlayHost(View view) { - Drawable background = unwrap(view.getBackground()); - if (!(background instanceof StateListDrawable states)) return false; - for (int i = 0; i < states.getStateCount(); i++) { - Drawable child = unwrap(states.getStateDrawable(i)); - if (child instanceof GradientDrawable shape && itemOverlayFillWeight(view, shape) != 0) return true; - } - return false; - } - - /** - * 把条目宿主文字色里的选中/聚焦态换成用户设定的遮罩文字色,其余状态保持原值。 - * - *

为什么必须在运行时做:条目宿主的文字在 XML 里直接写 {@code ?attr/webhtvColorOnWallpaper} - * (单色、无状态),仓库里没有任何「条目宿主 + {@code ?attr/colorOnPrimary}」的颜色状态列表 - * 可改——引擎在 layout 里直接写字面属性,绕开了 {@code res/color/} 那一层。反过来硬改 - * {@code home_menu_text} / {@code site_item_text} 会把 colorPrimary 实心底上的文字换掉, - * 那些文件的注释已说明必须用 colorOnPrimary,改了就是对比度回归。 - */ - private static ColorStateList rewriteOverlayText(ColorStateList source, int color) { - if (source == null || color == 0) return null; - if (!source.isStateful()) { - return source.getDefaultColor() == color ? null : ColorStateList.valueOf(color); - } - int[][] states = ThemeColorIndex.stateSkeleton(); - int[] colors = new int[states.length]; - boolean changed = false; - for (int position = 0; position < states.length; position++) { - int original = resolvedColor(source, states[position]); - boolean onOverlay = false; - for (int state : states[position]) { - if (state == android.R.attr.state_focused || state == android.R.attr.state_selected) onOverlay = true; - } - colors[position] = onOverlay ? color : original; - changed |= onOverlay; - } - return changed ? new ColorStateList(states, colors) : null; - } - - private static boolean bindImageTint(ImageView view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { - ColorStateList tint = rewrite(view.getImageTintList(), explicit, index, active); - if (tint == null) return false; - view.setImageTintList(tint); - return true; - } - - private static boolean bindCard(MaterialCardView view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { - boolean changed = false; - ColorStateList background = rewrite(view.getCardBackgroundColor(), explicit, index, active); - if (background != null) { - view.setCardBackgroundColor(background); - changed = true; - } - ColorStateList stroke = rewrite(ColorStateList.valueOf(view.getStrokeColor()), - index.uniqueRoleFor(view.getStrokeColor()) == null ? ThemeRole.OUTLINE : null, index, active); - if (stroke != null) { - view.setStrokeColor(stroke); - changed = true; - } - return changed; - } - - /** - * Filled and outlined Material buttons keep their fill in a background tint and - * their border in a stroke colour rather than in a GradientDrawable, so the - * generic drawable path never reached them and a themed button stayed on the - * compiled palette. Both channels are still resolved from the same semantic - * attributes and obey the identical exact-match rule. - */ - private static boolean bindButton(MaterialButton view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { - boolean changed = false; - ColorStateList stroke = rewrite(view.getStrokeColor(), explicit, index, active); - if (stroke != null) { - view.setStrokeColor(stroke); - changed = true; - } - ColorStateList icon = rewrite(view.getIconTint(), explicit, index, active); - if (icon != null) { - view.setIconTint(icon); - changed = true; - } - return changed; - } - - /** - * Covers every view that fills itself through {@code app:backgroundTint} - the - * Material button family, chips, FABs and text-input boxes - with the same - * "only an exact baseline role colour" guard the rest of the binder uses. - */ - private static boolean bindBackgroundTint(View view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { - ColorStateList tint = rewrite(view.getBackgroundTintList(), explicit, index, active); - if (tint == null) return false; - view.setBackgroundTintList(tint); - return true; - } - - private static boolean bindBackground(View view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { - return bindDrawable(view.getBackground(), explicit, index, active, view); - } - - /** - * Rewrites a view's {@code android:foreground} drawable. - * - *

电视卡片把焦点环画在 foreground 而不是 background:{@code selector_vod} / - * {@code selector_vod_oval} / {@code selector_episode_card} 等都以 - * {@code android:foreground} 覆盖在内容之上,因此只走 background 的旧路径永远碰不到 - * 卡片族的焦点环,用户改「焦点色」时它们恒为原始颜色。 - */ - private static boolean bindForeground(View view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { - return bindDrawable(view.getForeground(), explicit, index, active, view); - } - - /** - * Rewrites a window-level background drawable. - * - *

{@code MaterialAlertDialogBuilder} builds the dialog panel as a - * {@link MaterialShapeDrawable} wrapped in an {@link InsetDrawable} and hands it to - * {@code Window.setBackgroundDrawable}. That drawable belongs to the window, not to - * any {@code View}, so the ordinary view-tree walk can never reach it - which is why - * a themed dialog changed its text but kept the compiled panel colour. The builder - * exposes the very same instance through {@code getBackground()}, so it is rewritten - * here through one shared code path instead of a second colour model. - */ - public static boolean bindWindowBackground(Drawable drawable, ThemeTokens baseline, ThemeTokens active) { - if (drawable == null || baseline == null || active == null) return false; - if (Looper.myLooper() != Looper.getMainLooper()) return false; - if (baseline.equals(active)) return false; - // A window background belongs to no View, so there is no theme to resolve a ring - // against; the 4-argument overload keeps the fill-only path this entry point has - // always had. - boolean changed = bindDrawable(drawable, null, ThemeColorIndex.of(baseline), active); - // dialogOpacity is documented as "the dialog/BottomSheet shell only, never the text - // alpha", so it is applied to this window background and nowhere else. Without this - // the editor's dialog-opacity slider changed only the preview swatch and the Web - // snapshot while real dialogs stayed fully opaque. - changed |= applyShellOpacity(drawable, active.dialogOpacity()); - return changed; - } - - /** - * Scales the alpha of a window background fill, preserving whatever transparency the - * drawable already had. A shell opacity of 1.0 is the shipped default and must stay a - * strict no-op so the frozen palette path remains byte-identical. - */ - private static boolean applyShellOpacity(Drawable drawable, float opacity) { - if (drawable == null || opacity >= 1f) return false; - if (opacity < 0f) return false; - if (drawable instanceof InsetDrawable inset) { - return applyShellOpacity(inset.getDrawable(), opacity); - } - if (drawable instanceof MaterialShapeDrawable shape) { - ColorStateList fill = shape.getFillColor(); - if (fill == null) return false; - int original = fill.getDefaultColor(); - int target = scaleAlpha(original, opacity); - if (target == original) return false; - shape.setFillColor(ColorStateList.valueOf(target)); - return true; - } - if (drawable instanceof GradientDrawable shape) { - ColorStateList color = shape.getColor(); - if (color == null) return false; - int original = color.getDefaultColor(); - int target = scaleAlpha(original, opacity); - if (target == original) return false; - shape.setColor(target); - return true; - } - return false; - } - - /** Package-private so the alpha arithmetic can be asserted without an Android device. */ - static int scaleAlpha(int color, float opacity) { - int alpha = Math.round(((color >>> 24) & 0xFF) * Math.max(0f, Math.min(1f, opacity))); - return (color & 0x00FFFFFF) | (Math.max(0, Math.min(255, alpha)) << 24); - } - - /** - * Rewrites the solid fill of a background drawable, unwrapping the transparent - * wrappers Android and Material put around real panels. - * - *

Two families are covered by the same exact-match rule as everything else: - * {@link GradientDrawable} (classic XML shapes) and - * {@link MaterialShapeDrawable} (Chip, text-input box, bottom sheet and the - * AlertDialog panel). Material's alert background is additionally wrapped in an - * {@link InsetDrawable} carrying the window insets, so wrappers are unwrapped - * recursively instead of being treated as an unknown drawable. - */ - private static boolean bindDrawable(Drawable drawable, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { - return bindDrawable(drawable, explicit, index, active, null); - } - - /** - * Same pass, with the owning View available so a focus-ring selector can be resolved - * against that view's own theme. - */ - private static boolean bindDrawable(Drawable drawable, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active, View owner) { - if (drawable == null) return false; - if (drawable instanceof InsetDrawable inset) { - return bindDrawable(inset.getDrawable(), explicit, index, active, owner); - } - if (drawable instanceof DrawableWrapper wrapper) { - return bindDrawable(wrapper.getDrawable(), explicit, index, active, owner); - } - if (owner != null && drawable instanceof StateListDrawable states) { - return bindStateList(owner, states, explicit, active); - } - if (drawable instanceof GradientDrawable shape) { - // 条目遮罩白名单必须先判:成员完全由用户槽位拥有,不允许再落到「按基线色反查角色」 - // 那条通用路径上——否则用户把遮罩设成某个角色色时,通用路径会按角色表改写它。 - if (owner != null) { - int weight = itemOverlayFillWeight(owner, shape); - if (weight != 0) { - return setItemOverlayFill(shape, overlayFillColor(active.colorItemOverlay(), weight)); - } - } - ColorStateList color = rewrite(shape.getColor(), explicit, index, active); - if (color == null) return false; - shape.setColor(color); - return true; - } - if (drawable instanceof MaterialShapeDrawable shape) { - ColorStateList color = rewrite(shape.getFillColor(), explicit, index, active); - if (color == null) return false; - shape.setFillColor(color); - return true; - } - return false; - } - - /** - * 一次状态列表上的两条白名单改写(焦点环 + 条目遮罩)。 - * - *

两条判定**都必须在本方法改写任何东西之前**完成,因为两者的身份判据都取自 drawable 的 - * 原始外观,而各自的改写会破坏对方的判据: - *

    - *
  • 焦点环改写 {@code setStroke} 会改掉描边色,而遮罩族的像素指纹里含描边;
  • - *
  • 遮罩改写 {@code setColor} 会改掉填充,而环族的形状签名里含填充。
  • - *
- * 先判后改,两边结论就都与调用顺序无关。 - */ - private static boolean bindStateList(View view, StateListDrawable states, ThemeRole explicit, ThemeTokens active) { - int ringColor = active.colorFocus(); - boolean ringMember = ringColor != 0 - && (explicit == ThemeRole.FOCUS || isAppSurfaceFocusRing(view, states)); - java.util.Map overlays = active.colorItemOverlay() == 0 - ? java.util.Map.of() : itemOverlayStates(view, states); - if (!ringMember && overlays.isEmpty()) return false; - boolean changed = false; - if (ringMember) changed |= bindFocusRingStates(states, view, ringColor); - changed |= applyItemOverlay(states, overlays, active.colorItemOverlay()); - return changed; - } - - /** - * 把 {@code states} 里每一个子项的实心填充改写成用户选定的条目遮罩色。 - * - *

覆盖**全部**状态子项而不是只有 focused:{@code selector_item} 的 selected 分支是 - * {@code shape_item_activated},它同样是「压在条目上的那层半透明深色」,用户改遮罩槽时必须 - * 一起变,否则同一行按钮在聚焦态和选中态会露出两种底色。normal 分支 - * ({@code shape_item_normal})按半强度写入(见 {@link #ITEM_OVERLAY_REST_FILLS}): - * 它的编译期填充是 {@code black_20},整块写入会让默认外观从 20% 黑跳到 40% 黑,而半强度下 - * 默认取值 {@code 0x33} 与编译期逐位相同。 - */ - private static boolean applyItemOverlay(StateListDrawable states, java.util.Map overlays, int color) { - if (overlays.isEmpty() || color == 0) return false; - // 容器先 mutate:子 drawable 可能与其他视图共享 ConstantState,不先切断会让首个宿主的 - // 改写连带影响其它宿主(与 setRingStroke 路径同一理由)。注意 mutate 之后必须**按索引重新 - // 取子 drawable**:StateListDrawable.mutate() 会逐个 mutate 子项,而被 mutate 过的 - // GradientDrawable 不再保证还是先前那个实例,抓住旧引用会静默漏改(实测:聚焦态漏改、 - // 选中态改对了,因为只有前者先被焦点环路径 mutate 过)。 - states.mutate(); - boolean changed = false; - for (java.util.Map.Entry entry : overlays.entrySet()) { - changed |= setItemOverlayFill(states.getStateDrawable(entry.getKey()), - overlayFillColor(color, entry.getValue())); - } - return changed; - } - - /** - * 判定 {@code states} 里哪些状态子项属于条目遮罩白名单,以及各自属于哪一档强度。 - * - *

判定在 {@code mutate()} 之前进行:填充在指纹里,被本类改写之后就认不出来了。 - * 返回「子项下标 → 强度」的映射,正常态子项因此在同一个宿主里拿到与聚焦态不同的强度。 - */ - private static java.util.Map itemOverlayStates(View view, StateListDrawable states) { - java.util.Map members = new java.util.LinkedHashMap<>(); - for (int i = 0; i < states.getStateCount(); i++) { - Drawable child = unwrap(states.getStateDrawable(i)); - if (!(child instanceof GradientDrawable shape)) continue; - int weight = itemOverlayFillWeight(view, shape); - if (weight != 0) members.put(i, weight); - } - return members; - } - - /** - * 应用焦点环:只改 {@code state_focused} 子项的描边。 - * - *

调用方必须先完成白名单判定(见 {@link #bindStateList}),因为本方法会 - * {@code mutate()} 并改掉描边,之后像素指纹就不再匹配,任何后置判定都会失效。 - * - *

环宽按唯一 token {@code @dimen/webhtv_focus_ring_width} 重写:{@code GradientDrawable} - * 没有公开的 stroke getter,而白名单内每个 selector 的焦点态宽度本就等于该 token, - * 重写同一 token 是无变化,真正变化的只有环色。 - */ - private static boolean bindFocusRingStates(StateListDrawable states, View view, int color) { - if (color == 0) return false; - boolean changed = false; - // 容器先 mutate:StateListDrawable 的子 drawable 可能与其他视图共享 ConstantState, - // 不先切断会让首个宿主的改写连带影响其它宿主。 - states.mutate(); - for (int i = 0; i < states.getStateCount(); i++) { - int[] stateSet = states.getStateSet(i); - boolean focusedState = false; - for (int state : stateSet) if (state == android.R.attr.state_focused) focusedState = true; - if (!focusedState) continue; - if (setRingStroke(states.getStateDrawable(i), view, color)) changed = true; - } - return changed; - } - - /** - * Whether {@code states} is one of the whitelisted app-surface focus-ring selectors. - * - *

判定走「焦点环公开签名」比对(见 {@link #ringSignature})。注意必须在 - * {@code mutate()} **之前**调用:{@code mutate()} 会就地改写子 drawable 的填充/描边, - * 签名随即变化,之后再比就永远不匹配了。 - * - *

白名单之外还有一道**主题前置判定**:视图自身主题解析出的 {@code ?attr/tvFocusRing} - * 必须就是 {@code R.color.tv_item_focus_ring}。原因是白名单里的 - * {@code selector_video_item} 是**双宿主**资源——选集芯片在普通页面用它,而 - * {@code dialog_episode_list} / {@code dialog_quick_search} 两处固定深色玻璃对话框的根节点 - * 用 {@code @style/ThemeOverlay.WebHTV.GlassFocusRings} 把 {@code tvFocusRing} 重绑到了 - * {@code tv_player_focus_ring}。玻璃子树里只看内容签名会命中并改写成 {@code colorFocus()} - * (浅色表深蓝 {@code #0B57D0} 贴在深靛玻璃上仅 1.83:1,等于不可见)。 - * - *

这条判据单独使用会**恒真**(所有相关主题都把 {@code tvFocusRing} 绑到该资源), - * 所以只能作为白名单的必要条件(AND),用来把玻璃子树挡在外面。 - * - *

白名单本身为什么是**资源名字**而不是 {@code R.drawable.*}:这些 selector 只存在于 - * {@code app/src/leanback/res},而本类位于 {@code app/src/main}(两个 flavor 共享), - * 直接引用 {@code R.drawable.selector_item} 会让 mobile 变体的 javac 直接失败 - * (与 {@code DanmakuDialog} 的 {@code @id/loadHost} 泄漏同属一类)。因此按名字解析: - * mobile 上解析为 0 → 白名单为空 → 一律不改写,行为与该 flavor 现状一致。 - * - *

白名单之外必须排除的族,以及它们为什么不能跟随主题: - *

    - *
  • 使用 {@code ?attr/tvPlayerRing} 的固定深色玻璃族({@code selector_glass_icon_action}、 - * {@code selector_control_sheet_button}、{@code shape_subtitle_focused/pressed})—— - * 浅色表 FOCUS 深蓝 {@code #0B57D0} 画在深靛玻璃上只有 1.83:1,改了等于不可见;
  • - *
  • 焦点态宽度尚未收敛到 token 的 3dp 卡片族({@code selector_episode_card} 等)—— - * 它们的声明圆角是按「宿主圆角 − 环宽/2」以 3dp 推导并经栅格化验算的,改宽度会重新 - * 露出「卡片圆角外的月牙」;
  • - *
  • 本来就没有描边的焦点态(如实心填充 + layer-list 的图标按钮)——改了就凭空多出一圈环。
  • - *
- * 白名单内每个 selector 的焦点态宽度都已经是 {@code @dimen/webhtv_focus_ring_width} - * (由 {@code TvFocusRingContractTest} 钉住),因此读不到旧宽度也没有关系。 - */ - private static boolean isAppSurfaceFocusRing(View view, StateListDrawable states) { - if (view == null || states == null) return false; - // 缓存只对「同一个背景实例 + 同一个传入主题」有效:RecyclerView 会复用 item 视图, - // 描边改写或换绑主题都会让先前的结论失效。 - Context context = view.getContext(); - if (context == null) return false; - int ringColor = ringColorToken(context); - if (ringColor == RING_COLOR_UNSET) return false; - RingDecision cached = RING_DECIDED.get(view); - if (cached != null && cached.ringColor() == ringColor && cached.states().get() == states) { - return cached.member(); - } - // 先做主题前置判定:玻璃子树会把 tvFocusRing 重绑到视频层环色(浅色表 #447BF5), - // 那里必须保持原样,否则深蓝环贴固定深色玻璃不可见(实测 1.83:1)。 - boolean member = ringColor == context.getResources().getColor(R.color.tv_item_focus_ring, null) - && matchesWhitelist(context, states); - RING_DECIDED.put(view, new RingDecision(new java.lang.ref.WeakReference<>(states), ringColor, member)); - return member; - } - - /** 视图主题解析出的 {@code ?attr/tvFocusRing} 颜色;解析不出时返回 {@link #RING_COLOR_UNSET}。 */ - private static int ringColorToken(Context context) { - android.util.TypedValue value = new android.util.TypedValue(); - try { - if (!context.getTheme().resolveAttribute(R.attr.tvFocusRing, value, true)) return RING_COLOR_UNSET; - if (value.type >= android.util.TypedValue.TYPE_FIRST_COLOR_INT - && value.type <= android.util.TypedValue.TYPE_LAST_COLOR_INT) return value.data; - if (value.resourceId != 0) return context.getResources().getColor(value.resourceId, null); - } catch (RuntimeException ignored) { - return RING_COLOR_UNSET; - } - return RING_COLOR_UNSET; - } - - /** - * 焦点环的公开可读签名:填充色 + 圆角 + 内边距 + 是否椭圆。 - * - *

为什么用内容签名而不是 {@code ConstantState} 身份比较:身份比较依赖 - * {@code Resources} 对同一 (id, 主题) 复用同一个 ConstantState,这在真机上成立,但在 - * Robolectric 下不成立(每次 {@code getDrawable} 都可能新建 ConstantState),于是守卫测试 - * 无法验证正向路径。签名只用 {@link GradientDrawable} 的公开 getter。 - * - *

必须包含填充色:{@code shape_search_hot_word_focused} 与 - * {@code selector_glass_icon_action} / {@code shape_subtitle_*} 同为 8dp 圆角、内边距相近, - * 只有实心色不同({@code black_40} vs {@code white_15});漏掉颜色就会把固定深色玻璃族 - * 误判成应用表面族并改写成 colorFocus(浅色表在深靛玻璃上 1.83:1,等于不可见)。 - */ - private static String ringSignature(Drawable drawable) { - if (drawable instanceof DrawableWrapper wrapper) return ringSignature(wrapper.getDrawable()); - if (!(drawable instanceof GradientDrawable shape)) return null; - ColorStateList fill = shape.getColor(); - Rect padding = new Rect(); - shape.getPadding(padding); - float radius = shape.getCornerRadius(); - boolean oval = shape.getShape() == GradientDrawable.OVAL; - return (fill == null ? "null" : Integer.toHexString(fill.getDefaultColor())) - + '|' + (oval ? "oval" : Float.toString(radius)) - + '|' + padding.left + ',' + padding.top + ',' + padding.right + ',' + padding.bottom; - } - - /** 焦点态子项的签名;找不到焦点态时返回 null(这类 selector 本就不该被改写)。 */ - private static String focusedRingSignature(StateListDrawable states) { - for (int i = 0; i < states.getStateCount(); i++) { - for (int state : states.getStateSet(i)) { - if (state != android.R.attr.state_focused) continue; - String signature = ringSignature(states.getStateDrawable(i)); - if (signature != null) return signature; - } - } - return null; - } - - private static boolean matchesWhitelist(Context context, StateListDrawable states) { - String probe = focusedRingSignature(states); - if (probe == null) return false; - // 两级判定:先用廉价签名做预筛(绝大多数视图在这里就返回 false,不做任何绘制), - // 只有签名命中时才做一次小位图指纹确认。 - // - // 为什么还需要第二级:描边宽度/颜色没有公开 getter,而 - // {@code selector_lab_icon_btn} 的焦点态与 {@code shape_search_hot_word_focused} - // 填充/圆角/内边距完全相同(black_40 + 8dp + 无内边距),只有描边不同 - //(2dp {@code @color/accent} vs token {@code ?attr/tvFocusRing})。位图指纹把描边也 - // 算进去,因此能把这类"签名相同但描边不同"的资源排除掉。 - java.util.Set expected = whitelistFingerprints(context, ringColorToken(context)).get(probe); - if (expected == null) return false; - Long actual = ringFingerprint(states); - return actual != null && expected.contains(actual); - } - - /** 焦点态栅格化后的像素指纹;拿不到可复制实例时返回 null(此时按不匹配处理)。 */ - private static Long ringFingerprint(StateListDrawable states) { - Drawable focused = null; - for (int i = 0; i < states.getStateCount(); i++) { - for (int state : states.getStateSet(i)) { - if (state == android.R.attr.state_focused) focused = states.getStateDrawable(i); - } - if (focused != null) break; - } - if (focused == null) return null; - // 复制一份再绘制:直接给共享实例 setBounds 会污染其它宿主。 - Drawable.ConstantState state = focused.getConstantState(); - if (state == null) return null; - Drawable copy = state.newDrawable(); - if (copy == null) return null; - return fingerprint(copy); - } - - private static Long fingerprint(Drawable drawable) { - android.graphics.Bitmap bitmap = android.graphics.Bitmap.createBitmap( - FINGERPRINT_SIZE, FINGERPRINT_SIZE, android.graphics.Bitmap.Config.ARGB_8888); - try { - drawable.setBounds(0, 0, FINGERPRINT_SIZE, FINGERPRINT_SIZE); - drawable.draw(new android.graphics.Canvas(bitmap)); - long hash = 1125899906842597L; - for (int y = 0; y < FINGERPRINT_SIZE; y++) { - for (int x = 0; x < FINGERPRINT_SIZE; x++) { - hash = 31 * hash + bitmap.getPixel(x, y); - } - } - return hash; - } catch (RuntimeException ignored) { - return null; - } finally { - bitmap.recycle(); - } - } - - /** - * 白名单每个签名对应的像素指纹集合,按 {@code (Resources, 环色 token)} 解析一次后缓存。 - * - *

缓存键**必须**带上环色:{@code ContextThemeWrapper.getResources()} 返回的是同一个 - * {@link Resources} 实例,但白名单资源的描边色取决于解析主题(应用表面 {@code #0B57D0} - * vs 玻璃子树 {@code #447BF5})。只按 {@code Resources} 做键会让结果与首次调用顺序绑定: - * 先到者写入,后到者永远拿不到自己的指纹,功能静默失效。 - */ - private static Map> whitelistFingerprints(Context context, int ringColor) { - Resources resources = context.getResources(); - Map>> byRing = WHITELIST_FINGERPRINTS.get(resources); - if (byRing == null) { - byRing = new java.util.HashMap<>(); - WHITELIST_FINGERPRINTS.put(resources, byRing); - } - Map> cached = byRing.get(ringColor); - if (cached != null) return cached; - Map> resolved = new java.util.HashMap<>(); - for (String name : APP_SURFACE_FOCUS_RINGS) { - int id = resources.getIdentifier(name, "drawable", context.getPackageName()); - if (id == 0) continue; - Drawable candidate; - try { - candidate = context.getDrawable(id); - } catch (RuntimeException ignored) { - continue; - } - if (!(candidate instanceof StateListDrawable)) continue; - // 用原始实例取签名与指纹(指纹内部自己复制,不污染原实例)。 - String signature = focusedRingSignature((StateListDrawable) candidate); - Long print = ringFingerprint((StateListDrawable) candidate); - if (signature == null || print == null) continue; - resolved.computeIfAbsent(signature, key -> new java.util.HashSet<>()).add(print); - } - byRing.put(ringColor, resolved); - return resolved; - } - - // ------------------------------------------------------------------ 条目遮罩(ITEM_OVERLAY) - - /** 取到真正持有填充的那层 drawable(selector 子项可能是 InsetDrawable/包装器)。 */ - private static Drawable unwrap(Drawable drawable) { - if (drawable instanceof DrawableWrapper wrapper) return unwrap(wrapper.getDrawable()); - return drawable; - } - - /** - * Writes the user's overlay colour into one drawable's fill. - * - *

只用公开方法({@code mutate} + {@code setColor}):{@code GradientDrawable} 没有公开的 - * {@code getStroke*},但填充有公开 getter,因此这里不需要重写任何读不到的量。 - */ - private static boolean setItemOverlayFill(Drawable drawable, int color) { - if (color == 0) return false; - if (drawable instanceof InsetDrawable inset) return setItemOverlayFill(inset.getDrawable(), color); - if (drawable instanceof DrawableWrapper wrapper) return setItemOverlayFill(wrapper.getDrawable(), color); - if (drawable instanceof GradientDrawable shape) { - ColorStateList current = shape.getColor(); - if (current != null && current.getDefaultColor() == color) return false; - shape.mutate(); - shape.setColor(color); - return true; - } - if (drawable instanceof MaterialShapeDrawable shape) { - ColorStateList current = shape.getFillColor(); - if (current != null && current.getDefaultColor() == color) return false; - shape.setFillColor(ColorStateList.valueOf(color)); - return true; - } - return false; - } - - /** - * 判定 {@code shape} 属于条目遮罩白名单的哪一档强度,不属于时返回 {@code 0}。 - * - *

返回值是强度而不是布尔值({@link #ITEM_OVERLAY_FULL} / {@link #ITEM_OVERLAY_REST}): - * 同一份用户遮罩色在「聚焦/选中底座」上写整块、在「常态底座」上写半块,见 - * {@link #overlayFillColor}。 - * - *

两级判定,与 {@link #isAppSurfaceFocusRing} 同构但**不共用**:环族的签名取自焦点态描边, - * 遮罩族的签名取自填充本身,两者放进同一个集合会互相污染。 - *

    - *
  1. 形状签名(填充 + 圆角 + 内边距 + 是否椭圆)先做廉价预筛;
  2. - *
  3. 签名命中后做一次 48×48 的像素指纹确认——{@code shape_search_hot_word_focused} 与 - * {@code shape_tv_search_field_focused} 的填充/圆角/内边距完全相同({@code black_40} - * + 28dp + 无内边距中有差异),只有描边宽度/颜色不同,而描边没有公开 getter, - * 只能靠栅格化把它们区分开。
  4. - *
- * - *

这里不像环族那样再加一道「视图主题解析出的取值」前置判定:条目遮罩是单一用户槽位, - * 在两类宿主(页面内容区、固定深色玻璃)上语义相同,不存在 {@code selector_video_item} 那种 - * 双宿主分流问题。 - */ - private static int itemOverlayFillWeight(View view, Drawable drawable) { - if (!(drawable instanceof GradientDrawable shape)) return 0; - if (view == null || view.getContext() == null) return 0; - int strokeKey = itemOverlayStrokeKey(view.getContext()); - ItemOverlayDecision cached = ITEM_OVERLAY_MEMBER.get(shape); - if (cached != null && cached.strokeKey() == strokeKey) return cached.weight(); - int weight = matchesItemOverlayWhitelist(view, shape, strokeKey); - ITEM_OVERLAY_MEMBER.put(shape, new ItemOverlayDecision(strokeKey, weight)); - return weight; - } - - /** - * 按强度把用户选的遮罩色换算成这一档的实际填充色。 - * - *

只缩放 alpha、保持色相与彩度:默认 {@code 0x66} 的半强度正好是 {@code 0x33}, - * 与编译期 {@code @color/black_20} 逐位相等——「默认外观字节不变」是靠这条换算成立的。 - * 用户设了不透明色({@code #RRGGBB} 解析后 alpha=0xFF)时,常态底座拿到 50% 的同色, - * 聚焦/选中态拿到整色,条目「常态比聚焦浅」的层次随之保留。 - */ - private static int overlayFillColor(int color, int weight) { - if (color == 0 || weight >= ITEM_OVERLAY_FULL) return color; - int alpha = (color >>> 24) * weight / ITEM_OVERLAY_FULL; - return (color & 0x00FFFFFF) | (alpha << 24); - } - - /** 条目遮罩白名单成员的描边取值指纹:两个随主题解析的描边属性 + 环宽 token。 */ - private static int itemOverlayStrokeKey(Context context) { - return 31 * (31 * ringColorToken(context) + outlineColorToken(context)) - + focusRingWidthPx(context); - } - - private static int matchesItemOverlayWhitelist(View view, GradientDrawable shape, int strokeKey) { - if (view == null) return 0; - Context context = view.getContext(); - if (context == null) return 0; - String probe = ringSignature(shape); - if (probe == null) return 0; - Long actual = fingerprint(shape); - if (actual == null) return 0; - java.util.Map>> tiers = itemOverlayFingerprints(context, strokeKey); - // 两档按固定顺序命中:同一张指纹不可能同时属于两档(填充分别是 black_40 / black_20), - // 固定顺序只是让结论与 Map 的遍历顺序无关。 - for (int weight : new int[] {ITEM_OVERLAY_FULL, ITEM_OVERLAY_REST}) { - Map> tier = tiers.get(weight); - if (tier == null) continue; - java.util.Set expected = tier.get(probe); - if (expected != null && expected.contains(actual)) return weight; - } - return 0; - } - - /** - * 白名单成员的「签名 → 指纹」集合,按 {@code (Resources, 描边相关主题属性)} 解析一次后缓存。 - * - *

签名与指纹都取自**未经本类改写**的实例:{@code mutate()} 之前取,因此填充仍是 - * {@code black_40}。指纹内部自己复制一份再绘制,不会污染共享的 ConstantState。 - */ - private static Map>> itemOverlayFingerprints(Context context, int strokeKey) { - Resources resources = context.getResources(); - Map>>> byStroke = ITEM_OVERLAY_FINGERPRINTS.get(resources); - if (byStroke == null) { - byStroke = new java.util.HashMap<>(); - ITEM_OVERLAY_FINGERPRINTS.put(resources, byStroke); - } - Map>> cached = byStroke.get(strokeKey); - if (cached != null) return cached; - Map>> resolved = new java.util.HashMap<>(); - collectWhitelistDrawables(context, ITEM_OVERLAY_FILLS, ITEM_OVERLAY_FULL, resolved); - collectWhitelistDrawables(context, ITEM_OVERLAY_REST_FILLS, ITEM_OVERLAY_REST, resolved); - // 第二条路径:作为 selector 子项解析(生产上宿主就是这么拿到这些 shape 的)。 - collectSelectorChildren(context, resolved); - byStroke.put(strokeKey, resolved); - return resolved; - } - - /** 按名字把一条白名单解析成「签名 → 指纹」集合并收进对应强度格;解析不到的名字跳过(mobile 变体)。 */ - private static void collectWhitelistDrawables(Context context, String[] names, int weight, - Map>> into) { - for (String name : names) { - int id = context.getResources().getIdentifier(name, "drawable", context.getPackageName()); - if (id == 0) continue; - Drawable candidate; - try { - candidate = context.getDrawable(id); - } catch (RuntimeException ignored) { - continue; - } - if (!(candidate instanceof GradientDrawable shape)) continue; - String signature = ringSignature(shape); - Long print = fingerprint(shape); - if (signature == null || print == null) continue; - into.computeIfAbsent(weight, key -> new java.util.HashMap<>()) - .computeIfAbsent(signature, key -> new java.util.HashSet<>()).add(print); - } - } - - /** - * 把 {@link #ITEM_OVERLAY_PARENTS} 每个 selector 的**状态子项**的签名与指纹收进 {@code into}。 - * - *

这里不收名字、只按编译期填充归一档:{@code black_40} 归全强度、{@code black_20} - * 归半强度,其余({@code black_30} 的选中档等)直接跳过。之所以在 selector 子项路径上能用 - * 填充当判据,是因为这四个父 selector 的状态子项只有自己那几档形状:{@code selector_item} / - * {@code selector_item_round} 是 focused+activated+normal,{@code selector_search_hot_word} 是 - * focused+normal,{@code selector_tv_search_field} 两态都是 {@code black_40} 成员。 - * 形状签名(填充 + 圆角 + 内边距)随后再区分具体是哪一个,因此这条判据只决定强度、不决定身份。 - */ - private static void collectSelectorChildren(Context context, - Map>> into) { - for (String parent : ITEM_OVERLAY_PARENTS) { - int id = context.getResources().getIdentifier(parent, "drawable", context.getPackageName()); - if (id == 0) continue; - Drawable loaded; - try { - loaded = context.getDrawable(id); - } catch (RuntimeException ignored) { - continue; - } - if (!(loaded instanceof StateListDrawable states)) continue; - for (int i = 0; i < states.getStateCount(); i++) { - Drawable child = unwrap(states.getStateDrawable(i)); - if (!(child instanceof GradientDrawable shape)) continue; - // 只收白名单成员的子项:编译期填充决定强度档,形状签名随后区分具体身份。 - // black_30 的选中档(shape_item_selected / shape_keyboard_focused)在这里被排除。 - ColorStateList fill = shape.getColor(); - if (fill == null) continue; - int weight = overlayWeightOfCompiledFill(fill.getDefaultColor()); - if (weight == 0) continue; - String signature = ringSignature(shape); - Long print = fingerprint(shape); - if (signature == null || print == null) continue; - into.computeIfAbsent(weight, key -> new java.util.HashMap<>()) - .computeIfAbsent(signature, key -> new java.util.HashSet<>()).add(print); - } - } - } - - /** 编译期填充 ⇒ 条目遮罩强度:{@code black_40} 全强度、{@code black_20} 常态半强度,其余 0。 */ - private static int overlayWeightOfCompiledFill(int fill) { - if (fill == COMPILED_OVERLAY_FILL) return ITEM_OVERLAY_FULL; - if (fill == COMPILED_REST_FILL) return ITEM_OVERLAY_REST; - return 0; - } - - /** 视图主题解析出的 {@code ?attr/colorOutline};解析不出时返回 0(与「未设置」区分开即可)。 */ - private static int outlineColorToken(Context context) { - android.util.TypedValue value = new android.util.TypedValue(); - try { - // colorOutline 由 Material 定义,main 源集的 R 不生成该符号;用 Material 的 R 解析。 - if (!context.getTheme().resolveAttribute(com.google.android.material.R.attr.colorOutline, value, true)) return 0; - if (value.type >= android.util.TypedValue.TYPE_FIRST_COLOR_INT - && value.type <= android.util.TypedValue.TYPE_LAST_COLOR_INT) return value.data; - if (value.resourceId != 0) return context.getResources().getColor(value.resourceId, null); - } catch (RuntimeException ignored) { - return 0; - } - return 0; - } - - /** - * Applies the themed focus ring colour to one state drawable. - * - *

{@code GradientDrawable} 没有公开的 stroke 宽度/颜色 getter(已对 android-36 的 - * {@code android.jar} 用 javap 核实:只有 {@code setStroke} 系列,没有 {@code getStroke*}), - * 而仓库契约禁止在 binder 里用反射或隐藏 API,所以宽度只能按唯一 token 重新写入。 - * - *

因此调用方必须先把资源收窄到{@link #APP_SURFACE_FOCUS_RINGS}白名单:只有那些 - * selector 的焦点态宽度本就等于该 token,重写宽度才真是无变化。白名单之外若也调用本方法, - * 就会把 3dp 卡片环等未收敛族强制改成 1.5dp(会重新露出卡片圆角外的月牙)。 - */ - private static boolean setRingStroke(Drawable drawable, View view, int color) { - if (drawable instanceof DrawableWrapper wrapper) return setRingStroke(wrapper.getDrawable(), view, color); - if (drawable instanceof GradientDrawable shape) { - shape.mutate(); - shape.setStroke(focusRingWidthPx(view), color); - return true; - } - return false; - } - - /** The single TV ring width token, in pixels, resolved through the view's own resources. */ - private static int focusRingWidthPx(View view) { - return Math.max(1, Math.round(view.getResources().getDimension(R.dimen.webhtv_focus_ring_width))); - } - - private static int focusRingWidthPx(Context context) { - return Math.max(1, Math.round(context.getResources().getDimension(R.dimen.webhtv_focus_ring_width))); - } - - /** - * Returns a recolored copy of the list, preserving its state behaviour, or null - * when the colour is unknown/unchanged or the state shape cannot be rebuilt. - */ - private static ColorStateList rewrite(ColorStateList source, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { - if (source == null) return null; - if (!source.isStateful()) { - Integer replacement = singleColor(source.getDefaultColor(), explicit, index, active); - return replacement == null ? null : ColorStateList.valueOf(replacement); - } - ColorStateList cached = REBUILT.get(source); - if (cached != null) return cached; - int[][] states = ThemeColorIndex.stateSkeleton(); - int[] colors = new int[states.length]; - boolean changed = false; - // Material state lists usually vary alpha only. Resolve the role from the - // default colour first, then keep each state's original alpha so pressed / - // disabled / focused states stay distinguishable. - int defaultColor = source.getDefaultColor(); - Integer defaultReplacement = singleColor(defaultColor, explicit, index, active); - for (int position = 0; position < states.length; position++) { - int original = resolvedColor(source, states[position]); - Integer replacement = singleColor(original, explicit, index, active); - if (replacement == null && defaultReplacement != null && sameRgb(original, defaultColor)) { - replacement = withAlpha(defaultReplacement, original >>> 24); - } - colors[position] = replacement == null ? original : replacement; - changed |= replacement != null; - } - if (!changed) return null; - // ColorStateList exposes a public (int[][], int[]) constructor, so the list - // can be rebuilt directly without XML inflation or hidden platform APIs. - ColorStateList rebuilt = new ColorStateList(states, colors); - REBUILT.put(source, rebuilt); - return rebuilt; - } - - private static boolean sameRgb(int first, int second) { - return (first & 0x00FFFFFF) == (second & 0x00FFFFFF); - } - - private static int withAlpha(int color, int alpha) { - return (color & 0x00FFFFFF) | ((alpha & 0xFF) << 24); - } - - private static int resolvedColor(ColorStateList source, int[] state) { - int color = source.getColorForState(state, source.getDefaultColor()); - return color == 0 ? source.getDefaultColor() : color; - } - - private static Integer singleColor(int current, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { - if (explicit != null) { - Integer baseline = index.baselineOf(explicit); - int target = explicit.colorOf(active); - if (target == 0 || target == current) return null; - if (baseline != null && baseline != current) return null; - return target; - } - return index.replacementFor(current, active); - } - - private static boolean isExempt(View view) { - Object tag = view.getTag(); - if (tag instanceof String value && IGNORE_TAG.equals(value.trim().toLowerCase(Locale.ROOT))) return true; - String name = view.getClass().getName().toLowerCase(Locale.ROOT); - for (String marker : EXEMPT_CLASS_MARKERS) { - if (name.contains(marker)) return true; - } - return false; - } - - private static long signature(ThemeTokens tokens) { - // ThemeTokens is a content-based record; its generated hashCode is stable - // across the desugared Android runtime. Java-16 record reflection is - // deliberately avoided because it does not exist on Android API < 33. - return tokens.hashCode(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorIndex.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorIndex.java deleted file mode 100644 index a1db7ce536..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorIndex.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.fongmi.android.tv.theme; - -import java.util.Collections; -import java.util.EnumMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; - -/** - * Maps a concrete ARGB value back to the semantic role it played in the frozen - * baseline palette. - * - *

The binder never guesses: a view color is only rewritten when it exactly - * equals a baseline role value, and only when every role sharing that value - * resolves to the same active color. Ambiguous or unknown colors are left alone. - * - *

条目遮罩族({@code ITEM_OVERLAY}/{@code ON_ITEM_OVERLAY})不入表:它们的默认值 - * {@code #66000000}({@code @color/black_40})与白字在仓库里被大量固定资源共用,入表会让 - * 一次遮罩色编辑连带改掉刻意固定的深玻璃族。见 {@link ThemeRole#rewritesBaseline()}。 - */ -public final class ThemeColorIndex { - - private final Map> rolesByColor; - private final Map baselineByRole; - - private ThemeColorIndex(Map> rolesByColor, Map baselineByRole) { - this.rolesByColor = rolesByColor; - this.baselineByRole = baselineByRole; - } - - public static ThemeColorIndex of(ThemeTokens baseline) { - Map> byColor = new LinkedHashMap<>(); - Map byRole = new EnumMap<>(ThemeRole.class); - if (baseline != null) { - for (ThemeRole role : ThemeRole.values()) { - if (!role.rewritesBaseline()) continue; - int color = role.colorOf(baseline); - if (color == 0) continue; - byRole.put(role, color); - byColor.computeIfAbsent(color, ignored -> new LinkedHashSet<>()).add(role); - } - } - Map> frozen = new LinkedHashMap<>(); - byColor.forEach((color, roles) -> frozen.put(color, Collections.unmodifiableSet(roles))); - return new ThemeColorIndex(Collections.unmodifiableMap(frozen), Collections.unmodifiableMap(byRole)); - } - - public boolean isEmpty() { - return rolesByColor.isEmpty(); - } - - public Set rolesFor(int color) { - Set roles = rolesByColor.get(color); - return roles == null ? Set.of() : roles; - } - - /** - * The single role a baseline color belongs to, or {@code null} when the color - * is unknown or shared by several roles. - */ - public ThemeRole uniqueRoleFor(int color) { - Set roles = rolesFor(color); - return roles.size() == 1 ? roles.iterator().next() : null; - } - - /** - * The replacement color for a baseline view color, or {@code null} when the - * color must be left untouched. - * - *

When several roles share the baseline color the replacement is only - * returned if every one of those roles agrees on the active value; otherwise - * the correct role cannot be determined and the view keeps its static color. - */ - public Integer replacementFor(int color, ThemeTokens active) { - Set roles = rolesFor(color); - if (roles.isEmpty() || active == null) return null; - Integer candidate = null; - for (ThemeRole role : roles) { - int resolved = role.colorOf(active); - if (candidate == null) candidate = resolved; - else if (candidate != resolved) return null; - } - return candidate == null || candidate == color ? null : candidate; - } - - /** Baseline value of a role, used for explicit role tags. */ - public Integer baselineOf(ThemeRole role) { - return baselineByRole.get(role); - } - - /** - * A practical, ordered set of common state combinations. Android exposes no - * public accessor for a ColorStateList's original state array, so a stateful - * list is rebuilt against this skeleton: every entry keeps the colour the - * original list resolves to for that state, and unknown states therefore keep - * their original value instead of being dropped. - */ - public static final int[][] STATE_SKELETON = { - {-android.R.attr.state_enabled}, - {android.R.attr.state_enabled, android.R.attr.state_pressed}, - {android.R.attr.state_enabled, android.R.attr.state_focused}, - {android.R.attr.state_enabled, android.R.attr.state_hovered}, - {android.R.attr.state_enabled, android.R.attr.state_selected}, - {android.R.attr.state_enabled, android.R.attr.state_activated}, - {android.R.attr.state_enabled, android.R.attr.state_checked}, - {android.R.attr.state_enabled, -android.R.attr.state_checked, android.R.attr.state_checkable}, - {android.R.attr.state_enabled, android.R.attr.state_expanded}, - {android.R.attr.state_enabled, -android.R.attr.state_expanded}, - {android.R.attr.state_enabled}, - {}, - }; - - /** The ordered state skeleton used when a stateful list must be recolored. */ - public static int[][] stateSkeleton() { - int[][] copy = new int[STATE_SKELETON.length][]; - for (int index = 0; index < STATE_SKELETON.length; index++) copy[index] = STATE_SKELETON[index].clone(); - return copy; - } - -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorPickerDialog.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorPickerDialog.java deleted file mode 100644 index 9535a20c74..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorPickerDialog.java +++ /dev/null @@ -1,202 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.graphics.drawable.GradientDrawable; -import android.text.InputType; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.EditText; -import android.widget.LinearLayout; -import android.widget.SeekBar; -import android.widget.TextView; - -import androidx.appcompat.app.AlertDialog; - -import com.fongmi.android.tv.R; - -import java.util.Locale; - -/** - * Colour picker with both hue/saturation/value sliders and exact hex entry. - * - *

Values are validated with {@link ThemeProfileValidator#normalizeColor} before - * the callback fires, so an invalid string can never reach the editor draft. The - * dialog itself owns the parsing; callers only receive a normalised {@code #RRGGBB}. - */ -public final class ThemeColorPickerDialog { - - /** Receives the validated colour when the user confirms. */ - public interface Listener { - void onColorPicked(String hex); - } - - private ThemeColorPickerDialog() { - } - - public static AlertDialog create(Context context, String title, String initial, Listener listener) { - return create(context, title, initial, ThemeController.current(), listener); - } - - /** The editor passes its local snapshot; opening a picker never publishes the draft globally. */ - public static AlertDialog create(Context context, String title, String initial, ThemeTokens tokens, Listener listener) { - int start = ThemeProfileValidator.parseColor(initial, 0xFF000000); - float[] hsv = new float[3]; - android.graphics.Color.colorToHSV(start, hsv); - - LinearLayout root = column(context); - root.setTag("webhtv:ignore"); - View swatch = new View(context); - root.addView(swatch, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(context, 44))); - - SeekBar hue = slider(context, root, 360, Math.round(hsv[0])); - SeekBar saturation = slider(context, root, 100, Math.round(hsv[1] * 100f)); - SeekBar brightness = slider(context, root, 100, Math.round(hsv[2] * 100f)); - for (SeekBar bar : new SeekBar[]{hue, saturation, brightness}) { - bar.setProgressTintList(android.content.res.ColorStateList.valueOf(tokens.colorPrimary())); - bar.setThumbTintList(android.content.res.ColorStateList.valueOf(tokens.colorPrimary())); - bar.setMinimumHeight(dp(context, 48)); - } - - // These are platform widgets created from the Activity context, so they carry no - // Material role and would otherwise keep the framework default colour (the editor - // rendered them as Material's #49454F on whatever surface the user picked). - TextView hexLabel = new TextView(context); - hexLabel.setText(R.string.theme_editor_hex_label); - hexLabel.setTextColor(tokens.colorOnSurface()); - root.addView(hexLabel); - - EditText hexInput = new EditText(context); - hexInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); - hexInput.setSingleLine(true); - hexInput.setHint(R.string.theme_editor_hex_hint); - hexInput.setTextColor(tokens.colorOnSurface()); - hexInput.setHintTextColor(tokens.colorOnSurfaceVariant()); - hexInput.setBackgroundTintList(android.content.res.ColorStateList.valueOf(tokens.colorPrimary())); - root.addView(hexInput); - - TextView error = new TextView(context); - error.setTextColor(ThemeEditorUi.readable(tokens.colorError(), tokens.colorSurface())); - root.addView(error); - - Button useHex = new Button(context); - useHex.setText(R.string.theme_editor_hex_apply); - useHex.setAllCaps(false); - useHex.setTextColor(tokens.colorOnSurface()); - useHex.setBackground(outlinedPill(context, tokens)); - int pillPadding = dp(context, 10); - useHex.setPadding(pillPadding, pillPadding, pillPadding, pillPadding); - root.addView(useHex); - - java.util.function.IntConsumer showColor = color -> { - GradientDrawable shape = new GradientDrawable(); - shape.setCornerRadius(dp(context, 10)); - shape.setColor(color); - swatch.setBackground(shape); - hexInput.setText(toHex(color)); - error.setText(""); - }; - // Integer HSV sliders are deliberately approximate. Only an actual slider edit - // may quantize a color; opening or confirming an exact HEX must stay lossless. - Runnable syncFromHsv = () -> showColor.accept(android.graphics.Color.HSVToColor( - new float[]{hue.getProgress(), saturation.getProgress() / 100f, brightness.getProgress() / 100f})); - - SeekBar.OnSeekBarChangeListener onChange = new SeekBar.OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - if (fromUser) syncFromHsv.run(); - } - - @Override - public void onStartTrackingTouch(SeekBar seekBar) { - } - - @Override - public void onStopTrackingTouch(SeekBar seekBar) { - } - }; - hue.setOnSeekBarChangeListener(onChange); - saturation.setOnSeekBarChangeListener(onChange); - brightness.setOnSeekBarChangeListener(onChange); - - useHex.setOnClickListener(view -> { - String normalized = ThemeProfileValidator.normalizeColor(hexInput.getText().toString()); - if (normalized == null) { - error.setText(R.string.theme_editor_hex_invalid); - return; - } - int exact = ThemeProfileValidator.parseColor(normalized, 0xFF000000); - float[] parsed = new float[3]; - android.graphics.Color.colorToHSV(exact, parsed); - hue.setProgress(Math.round(parsed[0])); - saturation.setProgress(Math.round(parsed[1] * 100f)); - brightness.setProgress(Math.round(parsed[2] * 100f)); - showColor.accept(exact); - }); - - showColor.accept(start); - AlertDialog dialog = new WebHtvAlertDialogBuilder(context) - .setTitle(title) - .setView(root) - .setPositiveButton(R.string.theme_editor_confirm, null) - .setNegativeButton(R.string.theme_editor_cancel, null) - .create(); - // Validate before dismissing so an invalid hex never closes the picker. - dialog.setOnShowListener(ignored -> { - if (dialog.getWindow() != null) { - dialog.getWindow().setBackgroundDrawable(ThemeEditorUi.shape(context, tokens.colorSurface(), 0, 0, 20)); - } - TextView dialogTitle = dialog.findViewById(androidx.appcompat.R.id.alertTitle); - if (dialogTitle != null) dialogTitle.setTextColor(tokens.colorOnSurface()); - ThemeEditorUi.buttonColors(dialog.getButton(AlertDialog.BUTTON_POSITIVE), tokens.colorPrimary(), tokens.colorOnPrimary(), tokens, false); - ThemeEditorUi.buttonColors(dialog.getButton(AlertDialog.BUTTON_NEGATIVE), tokens.colorSurfaceContainer(), tokens.colorOnSurface(), tokens, false); - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(view -> { - String normalized = ThemeProfileValidator.normalizeColor(hexInput.getText().toString()); - if (normalized == null) { - error.setText(R.string.theme_editor_hex_invalid); - return; - } - listener.onColorPicked(normalized); - dialog.dismiss(); - }); - }); - return dialog; - } - - /** - * A themed pill for the framework {@code Button}, which ships its own light background - * and would otherwise stay white-on-white once the text colour follows the theme. - */ - private static GradientDrawable outlinedPill(Context context, ThemeTokens tokens) { - GradientDrawable shape = new GradientDrawable(); - shape.setShape(GradientDrawable.RECTANGLE); - shape.setCornerRadius(dp(context, 18)); - shape.setColor(tokens.colorSurfaceContainer()); - shape.setStroke(dp(context, 1), tokens.colorOutline()); - return shape; - } - - private static SeekBar slider(Context context, LinearLayout root, int max, int initial) { - SeekBar bar = new SeekBar(context); - bar.setMax(max); - bar.setProgress(initial); - root.addView(bar, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - return bar; - } - - private static LinearLayout column(Context context) { - LinearLayout root = new LinearLayout(context); - root.setOrientation(LinearLayout.VERTICAL); - int pad = dp(context, 16); - root.setPadding(pad, pad, pad, pad); - return root; - } - - static String toHex(int color) { - return String.format(Locale.ROOT, "#%06X", color & 0xFFFFFF); - } - - private static int dp(Context context, int value) { - return Math.round(value * context.getResources().getDisplayMetrics().density); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorUtil.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorUtil.java deleted file mode 100644 index c52ce9af7b..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorUtil.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.fongmi.android.tv.theme; - -import java.util.Locale; - -/** Bounded CSS color conversion for the allowlisted TweakCN importer. */ -public final class ThemeColorUtil { - - private ThemeColorUtil() { - } - - public static String normalizeCss(String value) { - String hex = ThemeProfileValidator.normalizeColor(value); - if (hex != null) return hex; - if (value == null) return null; - String color = value.trim().toLowerCase(Locale.ROOT); - try { - if (color.startsWith("oklch(") && color.endsWith(")")) { - return format(parseOklch(color.substring(6, color.length() - 1))); - } - if ((color.startsWith("hsl(") || color.startsWith("hsla(")) && color.endsWith(")")) { - int offset = color.startsWith("hsla(") ? 5 : 4; - return format(parseHsl(color.substring(offset, color.length() - 1))); - } - if ((color.startsWith("rgb(") || color.startsWith("rgba(")) && color.endsWith(")")) { - int offset = color.startsWith("rgba(") ? 5 : 4; - return format(parseRgb(color.substring(offset, color.length() - 1))); - } - if (color.matches("[-+0-9.]+\\s+[-+0-9.]+%\\s+[-+0-9.]+%(\\s+[-+0-9.]+%?)?")) { - return format(parseHsl(color)); - } - } catch (RuntimeException ignored) { - // Unsupported CSS is reported as an import warning, never executed. - } - return null; - } - - private static int parseOklch(String body) { - String[] parts = components(body); - if (parts.length != 3 && parts.length != 4) throw new IllegalArgumentException("oklch components"); - if (parts.length == 4 && !isOpaqueAlpha(parts[3])) throw new IllegalArgumentException("transparent color"); - double lightness = number(parts[0], 1d, false); - double chroma = number(parts[1], 0.4d, true); - double hue = number(parts[2], 360d, false); - if (lightness < 0d || lightness > 1d || chroma < 0d || !finite(lightness, chroma, hue)) { - throw new IllegalArgumentException("oklch range"); - } - double radians = Math.toRadians(hue % 360d); - double a = chroma * Math.cos(radians); - double b = chroma * Math.sin(radians); - double l = lightness + 0.3963377774d * a + 0.2158037573d * b; - double m = lightness - 0.1055613458d * a - 0.0638541728d * b; - double s = lightness - 0.0894841775d * a - 1.2914855480d * b; - double l3 = l * l * l; - double m3 = m * m * m; - double s3 = s * s * s; - return rgb( - gamma(4.0767416621d * l3 - 3.3077115913d * m3 + 0.2309699292d * s3), - gamma(-1.2684380046d * l3 + 2.6097574011d * m3 - 0.3413193965d * s3), - gamma(-0.0041960863d * l3 - 0.7034186147d * m3 + 1.7076147010d * s3)); - } - - private static int parseHsl(String body) { - String[] parts = components(body); - if (parts.length != 3 && parts.length != 4) throw new IllegalArgumentException("hsl components"); - if (parts.length == 4 && !isOpaqueAlpha(parts[3])) throw new IllegalArgumentException("transparent color"); - double h = number(parts[0], 360d, false) % 360d; - double saturation = number(parts[1], 1d, true); - double lightness = number(parts[2], 1d, true); - if (!finite(h, saturation, lightness) || saturation < 0d || saturation > 1d || lightness < 0d || lightness > 1d) { - throw new IllegalArgumentException("hsl range"); - } - double c = (1d - Math.abs(2d * lightness - 1d)) * saturation; - double x = c * (1d - Math.abs((h / 60d) % 2d - 1d)); - double match = lightness - c / 2d; - double r = 0d, g = 0d, b = 0d; - if (h < 60d) { r = c; g = x; } else if (h < 120d) { r = x; g = c; } - else if (h < 180d) { g = c; b = x; } else if (h < 240d) { g = x; b = c; } - else if (h < 300d) { r = x; b = c; } else { r = c; b = x; } - return rgb(r + match, g + match, b + match); - } - - private static int parseRgb(String body) { - String[] parts = components(body); - if (parts.length != 3 && parts.length != 4) throw new IllegalArgumentException("rgb components"); - if (parts.length == 4 && !isOpaqueAlpha(parts[3])) throw new IllegalArgumentException("transparent color"); - return rgb(channel(parts[0]), channel(parts[1]), channel(parts[2])); - } - - private static String[] components(String body) { - return body.trim().replace(',', ' ').replace('/', ' ').trim().split("\\s+"); - } - - private static double number(String value, double percentScale, boolean percentageAllowed) { - String part = value.trim(); - if (part.endsWith("%")) { - if (!percentageAllowed) throw new IllegalArgumentException("unexpected percentage"); - return Double.parseDouble(part.substring(0, part.length() - 1)) / 100d * percentScale; - } - return Double.parseDouble(part); - } - - private static double channel(String value) { - String part = value.trim(); - double result = part.endsWith("%") - ? Double.parseDouble(part.substring(0, part.length() - 1)) / 100d - : Double.parseDouble(part) / 255d; - if (!finite(result) || result < 0d || result > 1d) throw new IllegalArgumentException("rgb range"); - return result; - } - - private static boolean isOpaqueAlpha(String value) { - double alpha = number(value, 1d, true); - return finite(alpha) && alpha >= 0.999d; - } - - private static double gamma(double value) { - if (!finite(value)) throw new IllegalArgumentException("color conversion"); - return value <= 0.0031308d ? 12.92d * value : 1.055d * Math.pow(value, 1d / 2.4d) - 0.055d; - } - - private static int rgb(double red, double green, double blue) { - if (!finite(red, green, blue)) throw new IllegalArgumentException("color conversion"); - int r = Math.round((float) (Math.max(0d, Math.min(1d, red)) * 255d)); - int g = Math.round((float) (Math.max(0d, Math.min(1d, green)) * 255d)); - int b = Math.round((float) (Math.max(0d, Math.min(1d, blue)) * 255d)); - return 0xFF000000 | r << 16 | g << 8 | b; - } - - private static String format(int color) { - return String.format(Locale.ROOT, "#%06X", color & 0xFFFFFF); - } - - private static boolean finite(double... values) { - for (double value : values) if (Double.isNaN(value) || Double.isInfinite(value)) return false; - return true; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeContrast.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeContrast.java deleted file mode 100644 index f86b661017..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeContrast.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.fongmi.android.tv.theme; - -final class ThemeContrast { - - private ThemeContrast() { - } - - static void require(ThemeTokens tokens) { - require(tokens.colorOnPrimary(), tokens.colorPrimary(), 4.5, "onPrimary/primary"); - require(tokens.colorOnPrimaryContainer(), tokens.colorPrimaryContainer(), 4.5, "onPrimaryContainer/primaryContainer"); - require(tokens.colorOnSecondary(), tokens.colorSecondary(), 4.5, "onSecondary/secondary"); - require(tokens.colorOnSecondaryContainer(), tokens.colorSecondaryContainer(), 4.5, "onSecondaryContainer/secondaryContainer"); - require(tokens.colorOnTertiary(), tokens.colorTertiary(), 4.5, "onTertiary/tertiary"); - require(tokens.colorOnError(), tokens.colorError(), 4.5, "onError/error"); - require(tokens.colorOnErrorContainer(), tokens.colorErrorContainer(), 4.5, "onErrorContainer/errorContainer"); - require(tokens.colorOnSuccess(), tokens.colorSuccess(), 4.5, "onSuccess/success"); - require(tokens.colorOnSuccessContainer(), tokens.colorSuccessContainer(), 4.5, "onSuccessContainer/successContainer"); - require(tokens.colorOnWarning(), tokens.colorWarning(), 4.5, "onWarning/warning"); - require(tokens.colorOnWarningContainer(), tokens.colorWarningContainer(), 4.5, "onWarningContainer/warningContainer"); - require(tokens.colorOnSurface(), tokens.colorSurface(), 4.5, "onSurface/surface"); - require(tokens.colorOnSurfaceVariant(), tokens.colorSurface(), 4.5, "onSurfaceVariant/surface"); - require(tokens.colorPrimary(), tokens.colorSurface(), 4.5, "primary/surface"); - require(tokens.colorPrimary(), tokens.colorSurfaceContainer(), 4.5, "primary/surfaceContainer"); - require(tokens.colorPrimary(), tokens.colorSurfaceContainerHigh(), 4.5, "primary/surfaceContainerHigh"); - require(tokens.colorPrimary(), tokens.colorSurfaceContainerHighest(), 4.5, "primary/surfaceContainerHighest"); - require(tokens.colorOnSurface(), tokens.colorSurfaceContainerHigh(), 4.5, "onSurface/surfaceContainerHigh"); - require(tokens.colorOnSurface(), tokens.colorSurfaceContainerHighest(), 4.5, "onSurface/surfaceContainerHighest"); - require(tokens.colorInverseOnSurface(), tokens.colorInverseSurface(), 4.5, "inverseOnSurface/inverseSurface"); - require(tokens.colorOutline(), tokens.colorSurface(), 3.0, "outline/surface"); - require(tokens.colorFocus(), tokens.colorSurface(), 3.0, "focus/surface"); - require(tokens.colorPlayerControlActive(), composite(tokens.colorPlayerScrim(), 0xFF000000), 4.5, "playerControlActive/playerScrim"); - } - - static double ratio(int foreground, int background) { - double light = relativeLuminance(foreground); - double dark = relativeLuminance(background); - return (Math.max(light, dark) + 0.05) / (Math.min(light, dark) + 0.05); - } - - static int composite(int foreground, int background) { - int alpha = (foreground >>> 24) & 0xFF; - int inverse = 255 - alpha; - int red = (((foreground >>> 16) & 0xFF) * alpha + ((background >>> 16) & 0xFF) * inverse + 127) / 255; - int green = (((foreground >>> 8) & 0xFF) * alpha + ((background >>> 8) & 0xFF) * inverse + 127) / 255; - int blue = ((foreground & 0xFF) * alpha + (background & 0xFF) * inverse + 127) / 255; - return 0xFF000000 | (red << 16) | (green << 8) | blue; - } - - private static void require(int foreground, int background, double minimum, String pair) { - double ratio = ratio(foreground, background); - if (ratio + 0.0001 < minimum) { - throw new IllegalArgumentException(pair + " contrast " + String.format(java.util.Locale.US, "%.2f", ratio) + " < " + minimum); - } - } - - private static double relativeLuminance(int color) { - double red = channel((color >>> 16) & 0xFF); - double green = channel((color >>> 8) & 0xFF); - double blue = channel(color & 0xFF); - return 0.2126 * red + 0.7152 * green + 0.0722 * blue; - } - - private static double channel(int value) { - double normalized = value / 255.0; - return normalized <= 0.04045 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeController.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeController.java deleted file mode 100644 index 4851278042..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeController.java +++ /dev/null @@ -1,470 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.graphics.drawable.Drawable; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.app.Dialog; -import android.os.Build; -import android.util.TypedValue; -import android.view.View; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.core.content.ContextCompat; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.utils.Util; - -/** Read-only application entry point for future theme adoption. */ -public final class ThemeController { - - private static volatile ThemeTokens current = ThemeTokens.light(); - private static volatile ThemeTokens baseline = ThemeTokens.light(); - private static volatile ThemeProfile profile = ThemeProfile.defaultProfile(); - - private ThemeController() { - } - - public static void apply(AppCompatActivity activity, ThemeTokens tokens) { - ThemeTokens safe = tokens == null ? ThemeTokens.light() : tokens; - current = safe; - int color = safe.colorSurface(); - activity.getWindow().setStatusBarColor(color); - activity.getWindow().setNavigationBarColor(color); - } - - /** - * Keeps AppCompat's DayNight configuration aligned with the persisted - * appearance mode so the whole activity tree (mobile and leanback) shares - * one resolution order: explicit mode > product default > system. - * - *

TV 的产品默认是**深色**(与上游一致,上游 TV 主题直接继承 - * {@code Theme.Material3.Dark})。理由与上游相同:电视多在暗环境观看, - * 浅色大面积亮底在夜间刺眼;而 TV 常见的深色底上,上游那套白色焦点环 - * (18.5:1)才能成立(白环对浅色表 surface 只有 1.05:1,等于看不见)。 - * - *

“跟随系统”对 TV 不再等于“跟随一个通常恒为浅色的系统默认”,否则电视上 - * 会默认落到浅色表。用户仍可在外观设置里显式选择浅色/深色。 - * mobile 保持既有的“跟随系统”语义不变。 - */ - public static void applyNightModeToApp() { - int mode = com.fongmi.android.tv.setting.Setting.getThemeMode(); - int delegate = switch (mode) { - case 0 -> AppCompatDelegate.MODE_NIGHT_NO; - case 1 -> AppCompatDelegate.MODE_NIGHT_YES; - default -> Util.isLeanback() - ? AppCompatDelegate.MODE_NIGHT_YES - : AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM; - }; - if (AppCompatDelegate.getDefaultNightMode() != delegate) { - AppCompatDelegate.setDefaultNightMode(delegate); - } - } - - public static ThemeTokens current() { - return current; - } - - /** The frozen Layer 1 palette the binder compares against. */ - public static ThemeTokens baseline() { - return baseline; - } - - /** - * Single source of truth for "the current app appearance is dark". - * - *

The Activity configuration already reflects a forced {@code theme_mode}, so - * ordinary UI must ask this method instead of reading the raw system night bit, - * which would contradict an explicit light/dark choice. - */ - public static boolean isNight(Context context) { - Configuration configuration = context == null ? Resources.getSystem().getConfiguration() - : context.getResources().getConfiguration(); - return (configuration.uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; - } - - /** - * The user's explicit scrim opacity, or {@code null} when they never set one. - * - *

Callers must keep this distinguished from "the set opacity happens to equal - * the shipped default". Modal scrims in this app do not use the token colour: - * the episode-detail and TMDB-person dialogs paint their own scrim (a translucent - * white in light mode, translucent black in dark) and that is the shipped design. - * Replacing those colours with {@code colorScrim()} was measured to flip light-mode - * scrims from lightening to darkening, so the wiring instead lets the user drive - * only the alpha of each dialog's own scrim colour, and stays a strict no-op while - * the slot is unset so the default rendering is byte-identical. - * - * @param light which slot set the caller resolved for its own colours; the scrim - * owner decides light/dark, so the same answer is reused here instead - * of re-deriving it from the uiMode bit - * @return the configured alpha in {@code 0..1}, or {@code null} when unset - */ - public static Float configuredScrimOpacity(boolean light) { - ThemeProfile active = profile; - if (active == null) return null; - ThemeProfile.SlotSet slots = light ? active.light : active.dark; - if (slots == null) return null; - Float value = slots.scrimOpacity; - if (value == null || !Float.isFinite(value)) return null; - return Math.max(0f, Math.min(1f, value)); - } - - /** - * The user's explicit playback-menu scrim strength for one mode, or {@code null}. - * - *

播放菜单遮盖层是"每个宿主自己决定明暗"的黑幕:它没有 ThemeTokens 分量, - * 由播放页在创建时通过本方法读取,未设置时由 {@link PlaybackMenuScrim} 回落到 - * 它自己的 DEFAULT_OPACITY,因此老 profile 的默认观感是确定的。 - */ - public static Float configuredPlayerScrimOpacity(boolean light) { - ThemeProfile active = profile; - if (active == null) return null; - ThemeProfile.SlotSet slots = light ? active.light : active.dark; - if (slots == null) return null; - Float value = slots.playerScrimOpacity; - if (value == null || !Float.isFinite(value)) return null; - return Math.max(0f, Math.min(1f, value)); - } - - /** - * The user's explicit live-list (channel drawer) scrim strength for one mode, or {@code null}. - * - *

直播页频道抽屉的遮盖层与播放菜单遮盖层同构:没有 ThemeTokens 分量,由宿主在创建时 - * 通过本方法读取;未设置时由 {@link LiveListScrim} 回落到它自己的 DEFAULT_OPACITY, - * 因此老 profile 的默认观感与 shape_live_list.xml 逐值一致。 - */ - public static Float configuredLiveListScrimOpacity(boolean light) { - ThemeProfile active = profile; - if (active == null) return null; - ThemeProfile.SlotSet slots = light ? active.light : active.dark; - if (slots == null) return null; - Float value = slots.liveListScrimOpacity; - if (value == null || !Float.isFinite(value)) return null; - return Math.max(0f, Math.min(1f, value)); - } - - /** - * Applies a scrim opacity to a surface's own scrim colour, replacing only the alpha. - * - *

{@code null} (the user never set the slot) returns {@code base} untouched, which - * is what keeps the shipped look byte-identical. Package-visible and taking the - * opacity explicitly so both branches can be asserted without an Android device. - */ - public static int applyScrimOpacity(int base, Float opacity) { - if (opacity == null) return base; - int alpha = Math.max(0, Math.min(255, Math.round(opacity * 255f))); - return (base & 0x00FFFFFF) | (alpha << 24); - } - - public static void refresh() { - boolean systemDark = (Resources.getSystem().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; - current = ThemeResolver.resolve(currentThemeMode(), ThemeSeed.NONE, 0, 0, systemDark); - } - - /** - * Resolves the active semantic snapshot from the persisted appearance - * preferences. {@code themeColor} follows the existing WebHTV contract: - * {@code -1} disables tinting, {@code 0} uses the wallpaper seed and any - * other value is an explicit ARGB seed. - */ - public static ThemeTokens resolveFromPreferences() { - ThemeProfile stored = null; - try { - stored = ThemeProfileStore.load(); - } catch (RuntimeException ignored) { - // Preference access is unavailable; Layer 1 defaults remain valid. - } - return resolveWith(stored, null); - } - - /** - * Resolves the active snapshot against the table the given context inflates with. - * - *

{@code context} must be the activity whose views are about to be inflated, or - * {@code null} where no activity exists yet (the read-only probe device tests use). - * It is what decides light/dark: see {@link #resolvedDark(Context)}. - */ - private static ThemeTokens resolveWith(ThemeProfile profile, Context context) { - // Both the base palette and the profile's light/dark slot selection follow the - // table that context resolves, so the active tokens stay comparable with inflated - // colours on every flavour (see resolvedDark(Context)). - boolean systemDark = resolvedDark(context); - ThemeMode themeMode = currentThemeMode(); - int themeColor = com.fongmi.android.tv.setting.Setting.getThemeColor(); - if (themeColor == -1) { - return ThemeResolver.resolve(themeMode, ThemeSeed.NONE, 0, 0, profile, null, systemDark); - } - ThemeSeed seed = themeColor == 0 ? ThemeSeed.WALLPAPER : ThemeSeed.EXPLICIT; - int explicit = themeColor == 0 ? 0 : themeColor; - int wallpaper = com.fongmi.android.tv.setting.Setting.getWallColor(); - return ThemeResolver.resolve(themeMode, seed, explicit, wallpaper, profile, null, systemDark); - } - - /** - * The frozen compiled palette the binder compares view colours against. - * - *

Every {@code ?attr/color*} attribute was resolved from the static - * {@code webhtv_color_*} resources when the view was inflated, so this baseline - * must ignore {@code theme_color}/{@code wall_color}: those preferences only - * reach the activity through {@link #resolveWith(ThemeProfile, Context)}. Deriving - * the baseline from the active seed made it describe colours no inflated view ever - * held, and {@link ThemeBinder} then had nothing left to match - the feature - * silently degraded to the legacy site dialog. - */ - private static ThemeTokens frozenPalette(Context context) { - return ThemeResolver.resolve(ThemeMode.SYSTEM, ThemeSeed.NONE, 0, 0, null, null, resolvedDark(context)); - } - - private static ThemeMode currentThemeMode() { - int mode = com.fongmi.android.tv.setting.Setting.getThemeMode(); - return mode < 0 ? ThemeMode.SYSTEM : (mode == 0 ? ThemeMode.LIGHT : ThemeMode.DARK); - } - - /** - * Whether the palette the running build actually ships is the dark table. - * - *

{@link #frozenPalette(Context)} must describe the colours inflation produced, - * and those come from the compiled {@code webhtv_color_*} resources. Resource - * selection is not purely a function of uiMode: a flavour may override - * {@code values/} with a fixed table and ship no table of its own for the other - * mode. When that happens a uiMode-derived baseline describes colours no inflated - * view ever held. Every binder rewrite compares against that baseline exactly, so - * the mismatch silently turned the whole TV theme channel into a no-op (measured: - * probe vs default = 0 changed pixels on TV while the identical probe changed - * ~49k pixels on mobile). - * - *

The compiled table is therefore read from resources whenever it can be - * identified, and only an unidentifiable palette falls back to the uiMode rule. - * - *

WebHTV no longer ships such an override: the TV flavour used to carry a - * permanent dark {@code values/webhtv_tokens.xml} and that file is gone, so both - * flavours now resolve the same day/night tables and this method normally agrees - * with the uiMode rule. The resource probe is kept because it is what keeps the - * baseline honest if a flavour ever pins a palette again. - * - *

The probe must run against the context that inflates the UI, never against - * the application context. A forced appearance mode is applied by - * {@code AppCompatDelegate.setDefaultNightMode}, which rewrites the resources of the - * activities it delegates for; on API < 31 it does not rewrite the application's - * own resources. The TV flavour forces {@code MODE_NIGHT_YES} by product default - * (see {@link #applyNightModeToApp()}), so on a day-mode TV box the application - * context still resolved the light table while every inflated {@code ?attr/color*} - * resolved the dark one. The snapshot therefore came out light under a dark UI, and - * the dialog shell painted from it ({@code LightDialog.shell}) ended up under dark - * foregrounds - measured 1.06-1.41:1 on the 新增点播 dialog. The activity's own - * configuration ({@link #isNight(Context)}) is the only source that already reflects - * the forced mode, so it is what {@code context} must be. - */ - static boolean resolvedDark(Context context) { - boolean systemDark = context == null - ? (Resources.getSystem().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - : isNight(context); - return darkPaletteFor(compiledDarkPalette(context), currentThemeMode(), systemDark); - } - - /** - * The dark/light decision shared by the baseline and the active palette. - * - *

When the compiled table is identifiable it is authoritative: it is literally - * what inflation resolved, so it must win even over an explicit appearance mode. - * A flavour that pins {@code values/} to one table needs that - a light-mode device - * (or a user who picked light) would otherwise be described by the wrong baseline. - * Only an unidentifiable palette falls back to the historical mode/uiMode rule. - */ - static boolean darkPaletteFor(Boolean compiledDark, ThemeMode mode, boolean systemDark) { - if (compiledDark != null) return compiledDark; - return mode == ThemeMode.DARK || (mode != ThemeMode.LIGHT && systemDark); - } - - /** - * Identifies which canonical table the given context resolves by comparing every role - * the binder may rewrite against its {@code webhtv_color_*} resource. Returns - * {@code null} when no context is available or neither table matches exactly, so - * an unmodelled future palette keeps the previous uiMode behaviour instead of - * silently binding against the wrong baseline. - * - *

The context must be the one that inflated the views (see - * {@link #resolvedDark(Context)}); it falls back to the application context only for - * callers that have no activity, such as the read-only device-test probe. - */ - static Boolean compiledDarkPalette(Context context) { - Context probe = context == null ? App.get() : context; - if (probe == null) return null; - ThemeTokens light = ThemeTokens.light(); - ThemeTokens dark = ThemeTokens.dark(); - boolean matchesLight = true; - boolean matchesDark = true; - for (ThemeRole role : ThemeRole.values()) { - int resource = colorResourceOf(role); - if (resource == 0) continue; - int compiled = ContextCompat.getColor(probe, resource); - matchesLight &= compiled == role.colorOf(light); - matchesDark &= compiled == role.colorOf(dark); - } - if (matchesLight == matchesDark) return null; - return matchesDark; - } - - /** The compiled default of a binder role, or 0 when the role has no resource. */ - private static int colorResourceOf(ThemeRole role) { - return switch (role) { - case PRIMARY -> R.color.webhtv_color_primary; - case PRIMARY_CONTAINER -> R.color.webhtv_color_primary_container; - case SECONDARY_CONTAINER -> R.color.webhtv_color_secondary_container; - case FOCUS -> R.color.webhtv_color_focus; - case SURFACE -> R.color.webhtv_color_surface; - case SURFACE_CONTAINER -> R.color.webhtv_color_surface_container; - case SURFACE_CONTAINER_HIGH -> R.color.webhtv_color_surface_container_high; - case ON_SURFACE -> R.color.webhtv_color_on_surface; - case ON_SURFACE_VARIANT -> R.color.webhtv_color_on_surface_variant; - case OUTLINE -> R.color.webhtv_color_outline; - case ERROR -> R.color.webhtv_color_error; - case SUCCESS -> R.color.webhtv_color_success; - case WARNING -> R.color.webhtv_color_warning; - case ON_PRIMARY -> R.color.webhtv_color_on_primary; - case ON_PRIMARY_CONTAINER -> R.color.webhtv_color_on_primary_container; - case ON_SECONDARY_CONTAINER -> R.color.webhtv_color_on_secondary_container; - case ON_ERROR -> R.color.webhtv_color_on_error; - case ON_SUCCESS -> R.color.webhtv_color_on_success; - case ON_WARNING -> R.color.webhtv_color_on_warning; - // 条目遮罩族没有编译期颜色资源(遮罩取 @color/black_40,文字取 onPrimary), - // 因此这里如实返回 0,与「角色没有资源」的既有语义一致。 - case ITEM_OVERLAY, ON_ITEM_OVERLAY -> 0; - }; - } - - /** - * Applies the persisted appearance snapshot to one activity. - * - *

Only the read-only token snapshot is refreshed here. System-bar colors - * are updated solely when the window still owns an opaque bar; edge-to-edge - * windows keep their transparent bars so this hook cannot regress the - * existing immersive layouts. - */ - public static void applyFromPreferences(AppCompatActivity activity) { - ThemeProfile stored = null; - try { - stored = ThemeProfileStore.load(); - } catch (RuntimeException ignored) { - // Preference access is unavailable; Layer 1 defaults remain valid. - } - profile = stored == null ? ThemeProfile.defaultProfile() : stored; - // `activity` is the context whose resources inflate every view of this screen, so - // it is also what decides which compiled table the snapshot must describe. - baseline = frozenPalette(activity); - current = resolveWith(profile, activity); - if (activity == null || activity.isFinishing()) return; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return; - if (activity.getWindow().getStatusBarColor() != android.graphics.Color.TRANSPARENT) { - activity.getWindow().setStatusBarColor(current.colorSurface()); - } - if (activity.getWindow().getNavigationBarColor() != android.graphics.Color.TRANSPARENT) { - activity.getWindow().setNavigationBarColor(current.colorSurface()); - } - } - - /** - * Applies the active profile to an already-created view tree. - * - *

When no profile override is active this returns immediately, so the - * default Layer 1 path is byte-identical and costs nothing. - */ - public static void bindTheme(View root) { - ThemeBinder.bind(root, baseline, current); - } - - /** Same controlled channel for a dialog or bottom-sheet window. */ - public static void bindDialog(Dialog dialog) { - if (dialog == null || dialog.getWindow() == null) return; - bindTheme(dialog.getWindow().getDecorView()); - } - - /** - * Applies the active profile to a window-level background drawable. - * - *

Alert dialog panels live on the window rather than on a view, so they need - * this second entry point next to {@link #bindTheme(View)}. It is a no-op while the - * active tokens equal the frozen baseline. - */ - public static void bindWindowBackground(Drawable background) { - if (background == null) return; - ThemeBinder.bindWindowBackground(background, baseline, current); - } - - /** True when the active palette differs from the frozen compiled baseline. */ - public static boolean hasProfileOverrides() { - return !baseline.equals(current); - } - - // ---------------------------------------------------------------- TV focus ring - - /** - * The one TV focus-ring colour source, for the code paths that must set a stroke - * imperatively (dynamic rows, adapters, drawables built at runtime). - * - *

TV 的焦点环只允许有一个取值源:{@code tv_item_focus_ring}(由 - * {@code ?attr/tvFocusRing} 提供给 XML 选择器,由本方法提供给代码路径)。此前详情页把 - * {@code #FFD166} 写死在 Java 里,于是 {@code tv_item_focus_ring} 接线到主题 FOCUS 槽 - * 之后,同一个 TV 应用里就出现了两种焦点环色:播放页/追更页跟随主题,详情页永远黄色。 - * - *

取值规则:活动快照的 FOCUS 槽一旦偏离冻结基线就跟随它,否则保持编译期静态值。 - * {@code ?attr/tvFocusRing} 是编译期静态资源,Android 没有公开 API 能在运行时改写已编译的 - * {@code ?attr/color*}(见 {@link ThemeBinder} 类注释)。只有代码路径能动态取值,因此: - *

    - *
  • FOCUS 槽与冻结基线同值时返回 {@code ?attr/tvFocusRing},与 XML 选择器字节一致—— - * 默认档两条路径永远同色,不会出现「代码路径跟随、选择器不跟随」的两种环色;
  • - *
  • FOCUS 槽被改过(编辑器 FOCUS 槽覆写,或 seed/壁纸重新生成色板导致 FOCUS 变化)时 - * 返回 {@code current().colorFocus()}。这与 {@link ThemeBinder} 的判据一致:binder 也是 - * 在 {@code baseline != current} 时把白名单 selector 的环改写为 FOCUS 角色,因此代码路径 - * 与白名单 selector 始终同色。这也是用户对「焦点色」这一项设置唯一能看到的反馈:卡片 - * 描边({@code TmdbCardFocusHelper}、{@code TmdbEpisodeAdapter}、详情页/追更页)由代码 - * 路径绘制,若这里仍返回静态值,编辑器的焦点色对这些视图永远无效。
  • - *
- * 已知并发面:XML 选择器与 {@code ?attr/tvPlayerRing} 玻璃族仍是静态环色,覆写后同一个 - * 屏幕里可能出现「卡片环跟随、选择器环不跟随」,属于 {@link ThemeBinder} 白名单边界, - * 需要另外扩展白名单才能统一。 - * - *

同时这也修正了浅色详情页的可读性缺陷:{@code #FFD166} 画在浅色底板 - * (cinema {@code #EBE3DA}、chip {@code #F5F8FB})上只有 1.14–1.35:1,环几乎看不见, - * 而主题 FOCUS 槽的浅色取值是 5.03–5.99:1(Resolver 另有 ≥3:1 的对比度门)。 - */ - public static int focusRingColor(Context context) { - int compiled = compiledFocusRingColor(context); - // Comparing the resolved FOCUS role against the frozen baseline avoids a second - // light/dark resolution here (this runs on every card focus change) and states the - // rule exactly: follow the profile only when it actually moved the focus colour. - int active = current.colorFocus(); - return active == baseline.colorFocus() ? compiled : active; - } - - /** The compiled {@code ?attr/tvFocusRing} value, shared with the XML selector path. */ - private static int compiledFocusRingColor(Context context) { - if (context != null) { - TypedValue value = new TypedValue(); - if (context.getTheme().resolveAttribute(R.attr.tvFocusRing, value, true)) { - if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { - return value.data; - } - if (value.resourceId != 0) return ContextCompat.getColor(context, value.resourceId); - } - } - return ContextCompat.getColor(App.get(), R.color.tv_item_focus_ring); - } - - /** - * The focus-ring colour with an explicit alpha, matching the historical - * {@code 0x55FFD166} / {@code 0x1AFFD166} fills now that the hue is theme-driven. - */ - public static int focusRingColor(Context context, float alpha) { - int color = focusRingColor(context); - int scaled = Math.round(((color >>> 24) & 0xFF) * Math.max(0f, Math.min(1f, alpha))); - return (color & 0x00FFFFFF) | (Math.max(0, Math.min(255, scaled)) << 24); - } - - public static ThemeProfile activeProfile() { - return profile; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditor.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditor.java deleted file mode 100644 index 0d05234b17..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditor.java +++ /dev/null @@ -1,257 +0,0 @@ -package com.fongmi.android.tv.theme; - -/** - * Editing session for one B-safe theme profile. - * - *

The draft is a deep copy. Every mutation is validated before it reaches the - * draft, so an invalid hex value or an out-of-range opacity can never be applied. - * {@link #apply()} is the only method that writes to disk; it is atomic and - * refreshes the legacy {@code theme_color} mirror through {@link ThemeProfileStore}. - * Cancelling simply drops the draft. - */ -public final class ThemeEditor { - - /** A user-editable slot of one mode. */ - public enum Slot { - PRIMARY, PRIMARY_CONTAINER, SECONDARY_CONTAINER, FOCUS, - /** - * 播放页「当前播放/当前生效」环色。独立于 FOCUS:播放页里两个状态会同时出现, - * 必须能区分;解析时会在播放页玻璃面板上做可读性夹取。 - */ - PLAYER_CURRENT, - SURFACE, SURFACE_CONTAINER, SURFACE_CONTAINER_HIGH, - ON_SURFACE, ON_SURFACE_VARIANT, OUTLINE, - ERROR, SUCCESS, WARNING, - /** - * 条目遮罩底色。遮罩就是列表/按钮聚焦时压在同一块表面上的那层半透明深色,底色、文字色、 - * 强度三者必须一起调才不会互相打架,所以三个槽相邻成组(见 ThemePreviewView 的分组)。 - */ - ITEM_OVERLAY, - /** 遮罩上的选中/聚焦文字色。 */ - ON_ITEM_OVERLAY, - SCRIM_OPACITY, DIALOG_OPACITY, OVERLAY_OPACITY, - /** - * 播放菜单遮盖层强度。只缩放黑幕 alpha、不改色相,所以仍走 opacity 分支。 - */ - PLAYER_SCRIM_OPACITY, - /** 条目遮罩强度:只缩放遮罩色的 alpha,不改色相。 */ - ITEM_OVERLAY_OPACITY, - /** - * 直播页频道抽屉遮盖层强度。只缩放黑幕 alpha、不改色相,所以仍走 opacity 分支。 - * - *

位置固定在末尾:beta 的 {@code ThemeEditorContractTest#theLiveListScrimSlotIsTheLastSlotAndUsesItsOwnRange} - * 以「这个槽是最后一个」作为「新槽不会被读成颜色槽」的守卫。合并时 {@code isColor()} 已改成 - * 显式列出不透明度槽(不再依赖 ordinal),但保留该位置约束可以让两边的既有断言都无需改写。 - */ - LIVE_LIST_SCRIM_OPACITY; - - /** - * 颜色槽 = 除不透明度槽以外的全部槽。这里刻意列出不透明度槽而不是用 - * {@code ordinal() <= WARNING.ordinal()} 的位置判断:条目遮罩组就是插在 WARNING 之后 - * 新增的颜色槽,位置一挪就会被误判成不透明度槽。 - */ - public boolean isColor() { - return switch (this) { - case SCRIM_OPACITY, DIALOG_OPACITY, OVERLAY_OPACITY, PLAYER_SCRIM_OPACITY, LIVE_LIST_SCRIM_OPACITY, - ITEM_OVERLAY_OPACITY -> false; - default -> true; - }; - } - } - - private final ThemeProfile original; - private ThemeProfile draft; - - public ThemeEditor(ThemeProfile source) { - this.original = source == null ? ThemeProfile.defaultProfile() : source.copy(); - this.draft = this.original.copy(); - } - - public static ThemeEditor load() { - return new ThemeEditor(ThemeProfileStore.load()); - } - - public ThemeProfile draft() { - return draft.copy(); - } - - public boolean isDirty() { - return !ThemeProfileCodec.encode(original).equals(ThemeProfileCodec.encode(draft)); - } - - /** Applies a color or opacity value to one slot of one mode. */ - public Result set(Slot slot, boolean dark, String color, float opacity) { - if (slot == null) return Result.failure("unknown slot"); - ThemeProfile.SlotSet slots = draft.slots(dark); - if (slot.isColor()) { - String normalized = ThemeProfileValidator.normalizeColor(color); - if (normalized == null) return Result.failure("invalid color"); - setColor(slots, slot, normalized); - } else { - Double range = clamp(slot, opacity); - if (range == null) return Result.failure("opacity out of range"); - setOpacity(slots, slot, opacity); - } - ThemeProfileValidator.Result validated = ThemeProfileValidator.validate(draft); - if (!validated.valid()) return Result.failure(validated.message()); - return Result.success(draft.copy()); - } - - /** Clears one slot so it inherits the built-in token or seed result again. */ - public Result clear(Slot slot, boolean dark) { - if (slot == null) return Result.failure("unknown slot"); - ThemeProfile.SlotSet slots = draft.slots(dark); - if (slot.isColor()) setColor(slots, slot, null); - else setOpacity(slots, slot, null); - return Result.success(draft.copy()); - } - - public Result setMode(String mode) { - draft.mode = ThemeProfile.normalizeMode(mode); - return Result.success(draft.copy()); - } - - public Result setPaletteStyle(String style) { - draft.paletteStyle = ThemeProfile.normalizePaletteStyle(style); - return Result.success(draft.copy()); - } - - public Result setSeed(String seedSource, String seedColor) { - String source = ThemeProfile.normalizeSeedSource(seedSource); - if (ThemeProfile.SEED_CUSTOM.equals(source)) { - String normalized = ThemeProfileValidator.normalizeColor(seedColor); - if (normalized == null) return Result.failure("invalid seed color"); - draft.seedSource = source; - draft.seedColor = normalized; - } else { - draft.seedSource = source; - if (ThemeProfile.SEED_NONE.equals(source)) draft.seedColor = null; - } - return Result.success(draft.copy()); - } - - /** Resolves the draft for previewing without touching persisted state. */ - public ThemeTokens preview(ThemeMode mode, ThemeSeed seed, int seedColor, int wallpaperColor, boolean systemDark) { - return ThemeResolver.resolve(mode, seed, seedColor, wallpaperColor, draft, null, systemDark); - } - - /** Atomically persists the draft. */ - public ThemeProfileStore.ApplyResult apply() { - ThemeProfileValidator.Result validated = ThemeProfileValidator.validate(draft); - if (!validated.valid()) return ThemeProfileStore.ApplyResult.failure(validated.message()); - return ThemeProfileStore.apply(validated.profile()); - } - - /** Resolves the actual editing mode, including the live wallpaper seed, without global state. */ - public ThemeTokens preview(boolean dark, int wallpaperColor) { - ThemeSeed seed = ThemeProfile.SEED_WALLPAPER.equals(draft.seedSource) ? ThemeSeed.WALLPAPER - : ThemeProfile.SEED_CUSTOM.equals(draft.seedSource) ? ThemeSeed.EXPLICIT : ThemeSeed.NONE; - return preview(dark ? ThemeMode.DARK : ThemeMode.LIGHT, seed, - ThemeProfileValidator.parseColor(draft.seedColor, 0), wallpaperColor, dark); - } - - /** Replaces only the draft; imports and presets retain the original dirty-state baseline. */ - public Result replace(ThemeProfile profile) { - ThemeProfileValidator.Result validated = ThemeProfileValidator.validate(profile == null ? null : profile.copy()); - if (!validated.valid()) return Result.failure(validated.message()); - draft = validated.profile(); - return Result.success(draft.copy()); - } - - /** Restores the frozen default in memory. Like every edit, this still needs Apply. */ - public Result reset() { - return replace(ThemeProfile.defaultProfile()); - } - - public String valueOf(Slot slot, boolean dark) { - if (slot == null) return null; - ThemeProfile.SlotSet slots = draft.slots(dark); - return switch (slot) { - case PRIMARY -> slots.primary; - case PRIMARY_CONTAINER -> slots.primaryContainer; - case SECONDARY_CONTAINER -> slots.secondaryContainer; - case FOCUS -> slots.focus; - case PLAYER_CURRENT -> slots.playerCurrent; - case SURFACE -> slots.surface; - case SURFACE_CONTAINER -> slots.surfaceContainer; - case SURFACE_CONTAINER_HIGH -> slots.surfaceContainerHigh; - case ON_SURFACE -> slots.onSurface; - case ON_SURFACE_VARIANT -> slots.onSurfaceVariant; - case OUTLINE -> slots.outline; - case ERROR -> slots.error; - case SUCCESS -> slots.success; - case WARNING -> slots.warning; - case ITEM_OVERLAY -> slots.itemOverlay; - case ON_ITEM_OVERLAY -> slots.onItemOverlay; - case SCRIM_OPACITY -> slots.scrimOpacity == null ? null : String.valueOf(slots.scrimOpacity); - case DIALOG_OPACITY -> slots.dialogOpacity == null ? null : String.valueOf(slots.dialogOpacity); - case OVERLAY_OPACITY -> slots.overlayOpacity == null ? null : String.valueOf(slots.overlayOpacity); - case PLAYER_SCRIM_OPACITY -> slots.playerScrimOpacity == null ? null : String.valueOf(slots.playerScrimOpacity); - case LIVE_LIST_SCRIM_OPACITY -> slots.liveListScrimOpacity == null ? null : String.valueOf(slots.liveListScrimOpacity); - case ITEM_OVERLAY_OPACITY -> slots.itemOverlayOpacity == null ? null : String.valueOf(slots.itemOverlayOpacity); - }; - } - - private static void setColor(ThemeProfile.SlotSet slots, Slot slot, String value) { - switch (slot) { - case PRIMARY -> slots.primary = value; - case PRIMARY_CONTAINER -> slots.primaryContainer = value; - case SECONDARY_CONTAINER -> slots.secondaryContainer = value; - case FOCUS -> slots.focus = value; - case PLAYER_CURRENT -> slots.playerCurrent = value; - case SURFACE -> slots.surface = value; - case SURFACE_CONTAINER -> slots.surfaceContainer = value; - case SURFACE_CONTAINER_HIGH -> slots.surfaceContainerHigh = value; - case ON_SURFACE -> slots.onSurface = value; - case ON_SURFACE_VARIANT -> slots.onSurfaceVariant = value; - case OUTLINE -> slots.outline = value; - case ERROR -> slots.error = value; - case SUCCESS -> slots.success = value; - case WARNING -> slots.warning = value; - case ITEM_OVERLAY -> slots.itemOverlay = value; - case ON_ITEM_OVERLAY -> slots.onItemOverlay = value; - default -> throw new IllegalArgumentException("not a color slot"); - } - } - - private static void setOpacity(ThemeProfile.SlotSet slots, Slot slot, Float value) { - switch (slot) { - case SCRIM_OPACITY -> slots.scrimOpacity = value; - case DIALOG_OPACITY -> slots.dialogOpacity = value; - case OVERLAY_OPACITY -> slots.overlayOpacity = value; - case PLAYER_SCRIM_OPACITY -> slots.playerScrimOpacity = value; - case LIVE_LIST_SCRIM_OPACITY -> slots.liveListScrimOpacity = value; - case ITEM_OVERLAY_OPACITY -> slots.itemOverlayOpacity = value; - default -> throw new IllegalArgumentException("not an opacity slot"); - } - } - - /** Returns the allowed range when the value is valid, otherwise null. */ - public static Double clamp(Slot slot, float value) { - if (!Float.isFinite(value)) return null; - return switch (slot) { - case SCRIM_OPACITY -> inRange(value, ThemeProfileValidator.MIN_SCRIM_OPACITY, ThemeProfileValidator.MAX_SCRIM_OPACITY); - case DIALOG_OPACITY -> inRange(value, ThemeProfileValidator.MIN_DIALOG_OPACITY, ThemeProfileValidator.MAX_DIALOG_OPACITY); - case OVERLAY_OPACITY -> inRange(value, ThemeProfileValidator.MIN_OVERLAY_OPACITY, ThemeProfileValidator.MAX_OVERLAY_OPACITY); - case PLAYER_SCRIM_OPACITY -> inRange(value, ThemeProfileValidator.MIN_PLAYER_SCRIM_OPACITY, ThemeProfileValidator.MAX_PLAYER_SCRIM_OPACITY); - case LIVE_LIST_SCRIM_OPACITY -> inRange(value, ThemeProfileValidator.MIN_LIVE_LIST_SCRIM_OPACITY, ThemeProfileValidator.MAX_LIVE_LIST_SCRIM_OPACITY); - case ITEM_OVERLAY_OPACITY -> inRange(value, ThemeProfileValidator.MIN_ITEM_OVERLAY_OPACITY, ThemeProfileValidator.MAX_ITEM_OVERLAY_OPACITY); - default -> null; - }; - } - - private static Double inRange(float value, float minimum, float maximum) { - return value < minimum || value > maximum ? null : (double) value; - } - - public record Result(boolean success, ThemeProfile profile, String error) { - - public static Result success(ThemeProfile profile) { - return new Result(true, profile, ""); - } - - public static Result failure(String error) { - return new Result(false, null, error == null ? "unknown error" : error); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditorUi.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditorUi.java deleted file mode 100644 index 7e48f884ea..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditorUi.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.graphics.Typeface; -import android.graphics.drawable.GradientDrawable; -import android.graphics.drawable.StateListDrawable; -import android.util.TypedValue; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.LinearLayout; -import android.widget.TextView; - -/** Small, dialog-local rendering helpers. Never changes the app's active palette. */ -public final class ThemeEditorUi { - - private ThemeEditorUi() { - } - - public static TextView text(Context context, CharSequence text, int size, int color) { - TextView view = new TextView(context); - view.setText(text); - view.setTextSize(TypedValue.COMPLEX_UNIT_SP, size); - view.setTextColor(color); - view.setIncludeFontPadding(false); - return view; - } - - public static Button button(Context context, int label) { - Button button = new Button(context); - button.setText(label); - button.setTextColor(0xFF1A1C1E); // Replaced with local tokens before the view is shown. - button.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); - button.setAllCaps(false); - button.setMinHeight(dp(context, 48)); - button.setMinimumHeight(dp(context, 48)); - button.setMinWidth(0); - button.setMinimumWidth(0); - button.setPadding(dp(context, 14), dp(context, 8), dp(context, 14), dp(context, 8)); - button.setStateListAnimator(null); - button.setGravity(Gravity.CENTER); - return button; - } - - public static void buttonColors(Button button, int fill, int foreground, ThemeTokens tokens, boolean selected) { - button.setBackgroundTintList(null); - int ink = readable(foreground, fill); - button.setTextColor(new ColorStateList(new int[][]{{-android.R.attr.state_enabled}, {}}, - new int[]{withAlpha(ink, 0.45f), ink})); - button.setBackground(interactive(button.getContext(), fill, tokens.colorOutline(), tokens.colorFocus(), selected)); - button.setSelected(selected); - } - - public static LinearLayout column(Context context) { - LinearLayout layout = new LinearLayout(context); - layout.setOrientation(LinearLayout.VERTICAL); - return layout; - } - - public static LinearLayout row(Context context) { - LinearLayout layout = new LinearLayout(context); - layout.setOrientation(LinearLayout.HORIZONTAL); - layout.setGravity(Gravity.CENTER_VERTICAL); - return layout; - } - - public static void heading(TextView text) { - text.setTypeface(text.getTypeface(), Typeface.BOLD); - } - - public static GradientDrawable shape(Context context, int fill, int stroke, int width, int radius) { - GradientDrawable shape = new GradientDrawable(); - shape.setColor(fill); - shape.setCornerRadius(dp(context, radius)); - if (width > 0) shape.setStroke(dp(context, width), stroke); - return shape; - } - - /** A visible focus ring independent of selection, also usable by a TV remote. */ - public static StateListDrawable interactive(Context context, int fill, int outline, int focus, boolean selected) { - int ring = ThemeContrast.ratio(focus, fill) >= 3 ? focus : readable(focus, fill); - StateListDrawable states = new StateListDrawable(); - states.addState(new int[]{android.R.attr.state_focused}, shape(context, fill, ring, 4, 14)); - states.addState(new int[]{android.R.attr.state_pressed}, shape(context, fill, ring, 4, 14)); - states.addState(new int[]{}, shape(context, fill, selected ? ring : outline, selected ? 2 : 1, 14)); - return states; - } - - public static int readable(int preferred, int background) { - if (ThemeContrast.ratio(preferred, background) >= 4.5) return preferred; - return ThemeContrast.ratio(0xFF000000, background) >= ThemeContrast.ratio(0xFFFFFFFF, background) - ? 0xFF000000 : 0xFFFFFFFF; - } - - public static int withAlpha(int color, float opacity) { - return (color & 0x00FFFFFF) | (Math.max(0, Math.min(255, Math.round(opacity * 255))) << 24); - } - - public static float alpha(int color) { - return (color >>> 24) / 255f; - } - - public static String hex(int color) { - return ThemeProfileValidator.formatColor(color); - } - - public static LinearLayout.LayoutParams fullWidth(Context context, int topMargin) { - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.topMargin = dp(context, topMargin); - return params; - } - - public static LinearLayout.LayoutParams weighted() { - return new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1); - } - - public static void padding(View view, int horizontal, int vertical) { - view.setPadding(dp(view.getContext(), horizontal), dp(view.getContext(), vertical), - dp(view.getContext(), horizontal), dp(view.getContext(), vertical)); - } - - public static int dp(Context context, int value) { - return Math.round(value * context.getResources().getDisplayMetrics().density); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeMode.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeMode.java deleted file mode 100644 index 0e3813f1c6..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeMode.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.fongmi.android.tv.theme; - -public enum ThemeMode { - SYSTEM, - LIGHT, - DARK -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemePaletteStyle.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemePaletteStyle.java deleted file mode 100644 index ebffadb51c..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemePaletteStyle.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.fongmi.android.tv.theme; - -import com.google.android.material.color.utilities.DynamicScheme; -import com.google.android.material.color.utilities.Hct; -import com.google.android.material.color.utilities.SchemeContent; -import com.google.android.material.color.utilities.SchemeExpressive; -import com.google.android.material.color.utilities.SchemeFidelity; -import com.google.android.material.color.utilities.SchemeFruitSalad; -import com.google.android.material.color.utilities.SchemeMonochrome; -import com.google.android.material.color.utilities.SchemeNeutral; -import com.google.android.material.color.utilities.SchemeRainbow; -import com.google.android.material.color.utilities.SchemeTonalSpot; -import com.google.android.material.color.utilities.SchemeVibrant; - -/** Material palette variants inspired by aShellYou's palette-style selector. */ -public enum ThemePaletteStyle { - TONAL_SPOT("tonalSpot"), - VIBRANT("vibrant"), - EXPRESSIVE("expressive"), - RAINBOW("rainbow"), - FRUIT_SALAD("fruitSalad"), - FIDELITY("fidelity"), - CONTENT("content"), - NEUTRAL("neutral"), - MONOCHROME("monochrome"); - - private final String id; - - ThemePaletteStyle(String id) { - this.id = id; - } - - public String id() { - return id; - } - - public static ThemePaletteStyle from(String value) { - if (value == null) return TONAL_SPOT; - for (ThemePaletteStyle style : values()) { - if (style.id.equalsIgnoreCase(value) || style.name().equalsIgnoreCase(value)) return style; - } - return TONAL_SPOT; - } - - public int defaultSeed() { - return 0xFF6750A4; - } - - public DynamicScheme create(int seedColor, boolean dark) { - Hct source = Hct.fromInt(seedColor); - return switch (this) { - case TONAL_SPOT -> new SchemeTonalSpot(source, dark, 0.0); - case VIBRANT -> new SchemeVibrant(source, dark, 0.0); - case EXPRESSIVE -> new SchemeExpressive(source, dark, 0.0); - case RAINBOW -> new SchemeRainbow(source, dark, 0.0); - case FRUIT_SALAD -> new SchemeFruitSalad(source, dark, 0.0); - case FIDELITY -> new SchemeFidelity(source, dark, 0.0); - case CONTENT -> new SchemeContent(source, dark, 0.0); - case NEUTRAL -> new SchemeNeutral(source, dark, 0.0); - case MONOCHROME -> new SchemeMonochrome(source, dark, 0.0); - }; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemePresets.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemePresets.java deleted file mode 100644 index 10e017d438..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemePresets.java +++ /dev/null @@ -1,170 +0,0 @@ -package com.fongmi.android.tv.theme; - -import com.fongmi.android.tv.R; -import com.google.android.material.color.utilities.Blend; -import com.google.android.material.color.utilities.DynamicScheme; -import com.google.android.material.color.utilities.Hct; - -import java.util.Objects; - -/** Complete, independently editable light/dark palettes. No preferences or UI state are written here. */ -public final class ThemePresets { - - public enum Preset { - DEFAULT("webhtv.local", "Default", R.string.theme_editor_preset_default, 0, 0, 0, 0, ThemePaletteStyle.TONAL_SPOT), - OCEAN("webhtv.preset.ocean", "Ocean", R.string.theme_editor_preset_blue, - 0xFF1565C0, 0xFF006B74, 0xFF25734A, 0xFF845B00, ThemePaletteStyle.VIBRANT), - LAGOON("webhtv.preset.lagoon", "Lagoon", R.string.theme_editor_preset_teal, - 0xFF007F7A, 0xFF535DA8, 0xFF23704F, 0xFF8B5900, ThemePaletteStyle.TONAL_SPOT), - FOREST("webhtv.preset.forest", "Forest", R.string.theme_editor_preset_green, - 0xFF386A20, 0xFF80561D, 0xFF3F6B24, 0xFF7D6200, ThemePaletteStyle.TONAL_SPOT), - AMBER("webhtv.preset.amber", "Amber", R.string.theme_editor_preset_orange, - 0xFFA65D13, 0xFF456A51, 0xFF526B27, 0xFF975200, ThemePaletteStyle.FIDELITY), - ROSE("webhtv.preset.rose", "Rose", R.string.theme_editor_preset_red, - 0xFFAE315B, 0xFF705289, 0xFF247456, 0xFF946000, ThemePaletteStyle.TONAL_SPOT), - IRIS("webhtv.preset.iris", "Iris", R.string.theme_editor_preset_purple, - 0xFF7043A6, 0xFF8B4A65, 0xFF317450, 0xFF8F6500, ThemePaletteStyle.VIBRANT), - GRAPHITE("webhtv.preset.graphite", "Graphite", R.string.theme_editor_preset_graphite, - 0xFF596168, 0xFF596168, 0xFF426957, 0xFF78613A, ThemePaletteStyle.NEUTRAL), - WALLPAPER("webhtv.wallpaper", "Wallpaper", R.string.theme_editor_preset_wallpaper, - 0, 0, 0, 0, ThemePaletteStyle.TONAL_SPOT); - - public final String id; - public final String name; - public final int label; - public final ThemePaletteStyle style; - private final int seed; - private final int secondary; - private final int success; - private final int warning; - - Preset(String id, String name, int label, int seed, int secondary, int success, int warning, - ThemePaletteStyle style) { - this.id = id; - this.name = name; - this.label = label; - this.seed = seed; - this.secondary = secondary; - this.success = success; - this.warning = warning; - this.style = style; - } - - public boolean isComplete() { - return this != DEFAULT && this != WALLPAPER; - } - - public ThemeProfile profile() { - return profile(style); - } - - public ThemeProfile profile(ThemePaletteStyle selectedStyle) { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.id = id; - profile.name = name; - profile.paletteStyle = selectedStyle.id(); - if (this == DEFAULT) return profile; - if (this == WALLPAPER) { - // Keep this genuinely dynamic: freezing slots here would stop following wallpaper changes. - profile.seedSource = ThemeProfile.SEED_WALLPAPER; - return profile; - } - profile.seedSource = ThemeProfile.SEED_CUSTOM; - profile.seedColor = ThemeProfileValidator.formatColor(seed); - profile.light = slots(false, selectedStyle); - profile.dark = slots(true, selectedStyle); - // Store the same safe values the runtime will display, not a misleading pre-correction swatch. - for (boolean dark : new boolean[]{false, true}) { - ThemeTokens resolved = new ThemeEditor(profile).preview(dark, 0); - if (ThemeResolver.lastDiagnostic().startsWith("fallback")) { - throw new IllegalStateException("Invalid built-in palette: " + id + "/" + selectedStyle); - } - ThemeProfile.SlotSet slots = profile.slots(dark); - slots.primary = hex(resolved.colorPrimary()); - slots.focus = slots.primary; // Preserve the binder's shared primary/focus baseline mapping. - // 遮罩上的文字沿用该预设自己的 onPrimary:这里必须落成具体值,否则该槽为空, - // 预设就不是「完整调色板」了。用户改过遮罩色后它仍会在复合底色上被夹到 ≥4.5:1。 - slots.onItemOverlay = hex(resolved.colorOnPrimary()); - } - return profile; - } - - private ThemeProfile.SlotSet slots(boolean dark, ThemePaletteStyle selectedStyle) { - DynamicScheme scheme = selectedStyle.create(seed, dark); - ThemeProfile.SlotSet slots = new ThemeProfile.SlotSet(); - boolean monochrome = selectedStyle == ThemePaletteStyle.MONOCHROME; - double neutralChroma = monochrome ? 0 : selectedStyle == ThemePaletteStyle.NEUTRAL ? 4 : 14; - double hue = Hct.fromInt(scheme.getSurface()).getHue(); - slots.primary = hex(scheme.getPrimary()); - slots.primaryContainer = hex(scheme.getPrimaryContainer()); - slots.secondaryContainer = hex(monochrome ? scheme.getSecondaryContainer() - : Hct.from(Hct.fromInt(secondary).getHue(), 24, dark ? 28 : 90).toInt()); - slots.focus = slots.primary; - slots.surface = hex(Hct.from(hue, neutralChroma, dark ? 7 : 98).toInt()); - slots.surfaceContainer = hex(Hct.from(hue, neutralChroma, dark ? 12 : 94).toInt()); - slots.surfaceContainerHigh = hex(Hct.from(hue, neutralChroma, dark ? 18 : 90).toInt()); - slots.onSurface = hex(Hct.from(hue, neutralChroma / 2, dark ? 94 : 12).toInt()); - slots.onSurfaceVariant = hex(Hct.from(hue, neutralChroma / 2, dark ? 80 : 32).toInt()); - slots.outline = hex(Hct.from(hue, neutralChroma, dark ? 62 : 48).toInt()); - slots.success = semantic(success, dark); - // 播放页「当前播放」环色:默认沿用成功色的语义绿,但保持独立槽位可单独编辑。 - slots.playerCurrent = slots.success; - slots.warning = semantic(warning, dark); - slots.error = semantic(0xFFBA1A1A, dark); - // 条目遮罩:40% 黑刚好等于编译期 @color/black_40(#66000000),所以写清这两个槽 - // 不会改变遮罩观感,只是让「完整预设」名下有全部颜色槽(ThemePresetsTest 的契约)。 - slots.itemOverlay = hex(0xFF000000); - slots.itemOverlayOpacity = 0.40f; - return slots; - } - - private String semantic(int color, boolean dark) { - // Material harmonization shifts hue by at most 15 degrees; green/amber/red remain semantic. - Hct harmonized = Hct.fromInt(Blend.harmonize(color, seed)); - return hex(Hct.from(harmonized.getHue(), harmonized.getChroma(), dark ? 80 : 36).toInt()); - } - } - - private ThemePresets() { - } - - public static Preset find(ThemeProfile profile) { - if (profile == null) return null; - for (Preset preset : Preset.values()) { - if (!preset.id.equals(profile.id)) continue; - if (preset == Preset.DEFAULT && !ThemeProfile.SEED_NONE.equals(profile.seedSource)) return null; - return preset; - } - return null; - } - - /** A style change updates template-generated slots, but never discards individual user edits. */ - public static ThemeProfile withPaletteStyle(ThemeProfile source, ThemePaletteStyle style) { - Preset preset = find(source); - if (preset == null || !preset.isComplete()) { - ThemeProfile copy = source.copy(); - copy.paletteStyle = style.id(); - return copy; - } - ThemeEditor before = new ThemeEditor(preset.profile(ThemePaletteStyle.from(source.paletteStyle))); - ThemeEditor current = new ThemeEditor(source); - ThemeEditor after = new ThemeEditor(preset.profile(style)); - for (boolean dark : new boolean[]{false, true}) { - for (ThemeEditor.Slot slot : ThemeEditor.Slot.values()) { - String value = current.valueOf(slot, dark); - if (Objects.equals(value, before.valueOf(slot, dark))) continue; - if (value == null) after.clear(slot, dark); - else if (slot.isColor()) after.set(slot, dark, value, 0); - else after.set(slot, dark, null, Float.parseFloat(value)); - } - } - ThemeProfile result = after.draft(); - result.name = source.name; - result.mode = source.mode; - return result; - } - - private static String hex(int color) { - return ThemeProfileValidator.formatColor(color); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemePreviewView.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemePreviewView.java deleted file mode 100644 index b4b04f976f..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemePreviewView.java +++ /dev/null @@ -1,362 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.graphics.Typeface; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.LinearLayout; -import android.widget.SeekBar; -import android.widget.TextView; - -import com.fongmi.android.tv.R; - -import java.util.ArrayList; -import java.util.EnumMap; -import java.util.List; - -/** The settings themselves are the preview: stable, locally themed controls, with no sample panel. */ -public final class ThemePreviewView extends LinearLayout { - - public interface Callbacks { - void onColorSlotClicked(ThemeEditor.Slot slot, boolean dark); - void onDraftChanged(); - } - - private final Callbacks callbacks; - private final EnumMap colors = new EnumMap<>(ThemeEditor.Slot.class); - private final EnumMap opacities = new EnumMap<>(ThemeEditor.Slot.class); - private final List groups = new ArrayList<>(); - private ThemeEditor editor; - private boolean dark; - private boolean binding; - - private ThemePreviewView(Context context, Callbacks callbacks) { - super(context); - this.callbacks = callbacks; - setOrientation(VERTICAL); - boolean wide = context.getResources().getConfiguration().screenWidthDp >= 700; - addGroups(wide, - colorGroup(R.string.theme_editor_group_accent, ThemeEditor.Slot.PRIMARY, - ThemeEditor.Slot.PRIMARY_CONTAINER, ThemeEditor.Slot.SECONDARY_CONTAINER, ThemeEditor.Slot.FOCUS), - colorGroup(R.string.theme_editor_group_surfaces, ThemeEditor.Slot.SURFACE, - ThemeEditor.Slot.SURFACE_CONTAINER, ThemeEditor.Slot.SURFACE_CONTAINER_HIGH)); - // 「当前播放环色」单独一组:它不是页面角色,而是播放页叠在视频上的状态色, - // 解析时会在固定玻璃面板上做可读性夹取。 - addView(colorGroup(R.string.theme_editor_group_player, ThemeEditor.Slot.PLAYER_CURRENT).view, - ThemeEditorUi.fullWidth(context, 12)); - addGroups(wide, - colorGroup(R.string.theme_editor_group_text, ThemeEditor.Slot.ON_SURFACE, - ThemeEditor.Slot.ON_SURFACE_VARIANT, ThemeEditor.Slot.OUTLINE), - colorGroup(R.string.theme_editor_group_states, ThemeEditor.Slot.SUCCESS, - ThemeEditor.Slot.WARNING, ThemeEditor.Slot.ERROR)); - Group layers = group(R.string.theme_editor_group_layers); - for (ThemeEditor.Slot slot : new ThemeEditor.Slot[]{ThemeEditor.Slot.SCRIM_OPACITY, - ThemeEditor.Slot.DIALOG_OPACITY, ThemeEditor.Slot.OVERLAY_OPACITY, - ThemeEditor.Slot.PLAYER_SCRIM_OPACITY, - ThemeEditor.Slot.LIVE_LIST_SCRIM_OPACITY}) { - OpacityRow row = new OpacityRow(slot); - opacities.put(slot, row); - layers.view.addView(row.view, ThemeEditorUi.fullWidth(context, 8)); - } - addView(layers.view, ThemeEditorUi.fullWidth(context, 12)); - // 条目遮罩就是列表/按钮聚焦时的同一个视觉机制:遮罩底色、遮罩上的文字色、遮罩强度 - // 三者必须一起调才不会互相打架,所以单独成组;强度滑杆也跟着留在这里,不拆去 layers 组, - // 否则用户调这一处机制要跨两个分组来回找。 - Group itemOverlay = colorGroup(R.string.theme_editor_group_item_overlay, - ThemeEditor.Slot.ITEM_OVERLAY, ThemeEditor.Slot.ON_ITEM_OVERLAY); - OpacityRow itemOverlayOpacity = new OpacityRow(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY); - opacities.put(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY, itemOverlayOpacity); - itemOverlay.view.addView(itemOverlayOpacity.view, ThemeEditorUi.fullWidth(context, 8)); - addView(itemOverlay.view, ThemeEditorUi.fullWidth(context, 12)); - } - - public static ThemePreviewView createPanel(Context context, ThemeEditor editor, boolean dark, - ThemeTokens tokens, Callbacks callbacks) { - ThemePreviewView panel = new ThemePreviewView(context, callbacks); - panel.render(editor, dark, tokens); - return panel; - } - - /** Rebinds in place so dragging, accessibility focus and the scroll position cannot be lost. */ - public void render(ThemeEditor editor, boolean dark, ThemeTokens tokens) { - this.editor = editor; - this.dark = dark; - binding = true; - for (Group group : groups) { - group.view.setBackground(ThemeEditorUi.shape(getContext(), tokens.colorSurfaceContainer(), 0, 0, 18)); - group.title.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurfaceVariant(), tokens.colorSurfaceContainer())); - } - for (ColorRow row : colors.values()) row.render(tokens); - for (OpacityRow row : opacities.values()) row.render(tokens); - binding = false; - } - - private Group colorGroup(int label, ThemeEditor.Slot... slots) { - Group group = group(label); - for (ThemeEditor.Slot slot : slots) { - ColorRow row = new ColorRow(slot); - colors.put(slot, row); - group.view.addView(row.view, ThemeEditorUi.fullWidth(getContext(), 6)); - } - return group; - } - - private Group group(int label) { - LinearLayout view = ThemeEditorUi.column(getContext()); - ThemeEditorUi.padding(view, 12, 12); - TextView title = ThemeEditorUi.text(getContext(), getContext().getString(label), 13, 0xFF44474F); - ThemeEditorUi.heading(title); - view.addView(title, ThemeEditorUi.fullWidth(getContext(), 0)); - Group group = new Group(view, title); - groups.add(group); - return group; - } - - private void addGroups(boolean wide, Group first, Group second) { - if (!wide) { - addView(first.view, ThemeEditorUi.fullWidth(getContext(), 12)); - addView(second.view, ThemeEditorUi.fullWidth(getContext(), 12)); - return; - } - LinearLayout row = ThemeEditorUi.row(getContext()); - row.setGravity(Gravity.TOP); - LinearLayout.LayoutParams left = ThemeEditorUi.weighted(); - left.setMarginEnd(ThemeEditorUi.dp(getContext(), 12)); - row.addView(first.view, left); - row.addView(second.view, ThemeEditorUi.weighted()); - addView(row, ThemeEditorUi.fullWidth(getContext(), 12)); - } - - private final class ColorRow { - final ThemeEditor.Slot slot; - final LinearLayout view; - final TextView swatch; - final TextView label; - final TextView value; - final Button clear; - - ColorRow(ThemeEditor.Slot slot) { - this.slot = slot; - Context context = getContext(); - view = ThemeEditorUi.row(context); - view.setTag("theme-slot:" + slot.name()); - ThemeEditorUi.padding(view, 8, 8); - view.setMinimumHeight(ThemeEditorUi.dp(context, 64)); - view.setFocusable(true); - view.setOnClickListener(ignored -> callbacks.onColorSlotClicked(slot, dark)); - - swatch = ThemeEditorUi.text(context, symbol(slot), 14, 0xFF000000); - swatch.setGravity(Gravity.CENTER); - swatch.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - int size = ThemeEditorUi.dp(context, 32); - LinearLayout.LayoutParams swatchParams = new LinearLayout.LayoutParams(size, size); - swatchParams.setMarginEnd(ThemeEditorUi.dp(context, 10)); - view.addView(swatch, swatchParams); - - LinearLayout text = ThemeEditorUi.column(context); - label = ThemeEditorUi.text(context, context.getString(labelOf(slot)), 14, 0xFF1A1C1E); - value = ThemeEditorUi.text(context, "", 11, 0xFF44474F); - value.setTypeface(Typeface.MONOSPACE); - text.addView(label); - text.addView(value, ThemeEditorUi.fullWidth(context, 5)); - view.addView(text, ThemeEditorUi.weighted()); - - clear = ThemeEditorUi.button(context, R.string.theme_editor_clear_short); - clear.setContentDescription(context.getString(R.string.theme_editor_clear) + " · " + label.getText()); - clear.setOnClickListener(ignored -> { - editor.clear(slot, dark); - callbacks.onDraftChanged(); - }); - view.addView(clear, new LinearLayout.LayoutParams(ThemeEditorUi.dp(context, 48), ViewGroup.LayoutParams.WRAP_CONTENT)); - } - - void render(ThemeTokens tokens) { - int color = colorOf(tokens, slot); - int fill = slot == ThemeEditor.Slot.SURFACE_CONTAINER_HIGH - ? tokens.colorSurfaceContainerHigh() : tokens.colorSurfaceContainer(); - String raw = editor.valueOf(slot, dark); - String actual = ThemeEditorUi.hex(color); - value.setText(raw == null ? getContext().getString(R.string.theme_editor_value_auto, actual) - : raw.equalsIgnoreCase(actual) ? actual - : getContext().getString(R.string.theme_editor_value_adjusted, raw, actual)); - label.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurface(), fill)); - value.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurfaceVariant(), fill)); - swatch.setBackground(ThemeEditorUi.shape(getContext(), color, tokens.colorOutline(), 1, 9)); - swatch.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurface(), color)); - view.setBackground(ThemeEditorUi.interactive(getContext(), fill, tokens.colorOutlineVariant(), tokens.colorFocus(), false)); - clear.setEnabled(raw != null); - ThemeEditorUi.buttonColors(clear, fill, tokens.colorOnSurfaceVariant(), tokens, false); - } - } - - private final class OpacityRow { - final ThemeEditor.Slot slot; - final LinearLayout view; - final TextView label; - final TextView value; - final SeekBar bar; - final Button clear; - - OpacityRow(ThemeEditor.Slot slot) { - this.slot = slot; - Context context = getContext(); - view = ThemeEditorUi.column(context); - LinearLayout header = ThemeEditorUi.row(context); - label = ThemeEditorUi.text(context, context.getString(labelOf(slot)), 14, 0xFF1A1C1E); - header.addView(label, ThemeEditorUi.weighted()); - value = ThemeEditorUi.text(context, "", 12, 0xFF44474F); - header.addView(value); - view.addView(header); - - LinearLayout controls = ThemeEditorUi.row(context); - bar = new SeekBar(context); - bar.setTag("theme-opacity:" + slot.name()); - bar.setMax(100); - bar.setMinimumHeight(ThemeEditorUi.dp(context, 48)); - controls.addView(bar, ThemeEditorUi.weighted()); - clear = ThemeEditorUi.button(context, R.string.theme_editor_clear_short); - clear.setContentDescription(context.getString(R.string.theme_editor_clear) + " · " + label.getText()); - clear.setOnClickListener(ignored -> { - editor.clear(slot, dark); - callbacks.onDraftChanged(); - }); - controls.addView(clear, new LinearLayout.LayoutParams(ThemeEditorUi.dp(context, 48), ViewGroup.LayoutParams.WRAP_CONTENT)); - view.addView(controls); - bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - if (!fromUser || binding) return; - float opacity = minimumOf(slot) + (maximumOf(slot) - minimumOf(slot)) * progress / 100f; - if (editor.set(slot, dark, null, opacity).success()) callbacks.onDraftChanged(); - } - - @Override - public void onStartTrackingTouch(SeekBar seekBar) { - seekBar.getParent().requestDisallowInterceptTouchEvent(true); - } - - @Override - public void onStopTrackingTouch(SeekBar seekBar) { - seekBar.getParent().requestDisallowInterceptTouchEvent(false); - } - }); - } - - void render(ThemeTokens tokens) { - String raw = editor.valueOf(slot, dark); - float opacity = switch (slot) { - case SCRIM_OPACITY -> ThemeEditorUi.alpha(tokens.colorScrim()); - case DIALOG_OPACITY -> tokens.dialogOpacity(); - case PLAYER_SCRIM_OPACITY -> PlaybackMenuScrim.DEFAULT_OPACITY; - case LIVE_LIST_SCRIM_OPACITY -> LiveListScrim.DEFAULT_OPACITY; - // 遮罩强度就是遮罩色的 alpha,所以「自动」时的取值直接读解析后的 alpha, - // 不另立一个默认常量,避免两处默认值漂移。 - case ITEM_OVERLAY_OPACITY -> ThemeEditorUi.alpha(tokens.colorItemOverlay()); - default -> ThemeEditorUi.alpha(tokens.colorOverlayLight()); - }; - if (raw != null) opacity = Float.parseFloat(raw); - String percentage = Math.round(opacity * 100) + "%"; - value.setText(raw == null ? getContext().getString(R.string.theme_editor_value_auto, percentage) : percentage); - int fill = tokens.colorSurfaceContainer(); - label.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurface(), fill)); - value.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurfaceVariant(), fill)); - bar.setProgress(Math.round((opacity - minimumOf(slot)) / (maximumOf(slot) - minimumOf(slot)) * 100)); - bar.setContentDescription(label.getText() + " · " + percentage); - bar.setProgressTintList(ColorStateList.valueOf(tokens.colorPrimary())); - bar.setProgressBackgroundTintList(ColorStateList.valueOf(tokens.colorOutline())); - bar.setThumbTintList(ColorStateList.valueOf(tokens.colorPrimary())); - bar.setBackground(ThemeEditorUi.interactive(getContext(), fill, fill, tokens.colorFocus(), false)); - clear.setEnabled(raw != null); - ThemeEditorUi.buttonColors(clear, fill, tokens.colorOnSurfaceVariant(), tokens, false); - } - } - - private record Group(LinearLayout view, TextView title) { } - - private static String symbol(ThemeEditor.Slot slot) { - return switch (slot) { - case ON_SURFACE, ON_SURFACE_VARIANT, ON_ITEM_OVERLAY -> "Aa"; - case SUCCESS -> "✓"; - case WARNING -> "!"; - case ERROR -> "×"; - case PLAYER_CURRENT -> "▶"; - default -> ""; - }; - } - - public static int colorOf(ThemeTokens tokens, ThemeEditor.Slot slot) { - return switch (slot) { - case PRIMARY -> tokens.colorPrimary(); - case PRIMARY_CONTAINER -> tokens.colorPrimaryContainer(); - case SECONDARY_CONTAINER -> tokens.colorSecondaryContainer(); - case FOCUS -> tokens.colorFocus(); - case PLAYER_CURRENT -> tokens.colorPlayerCurrentRing(); - case ITEM_OVERLAY -> tokens.colorItemOverlay(); - case ON_ITEM_OVERLAY -> tokens.colorOnItemOverlay(); - case SURFACE -> tokens.colorSurface(); - case SURFACE_CONTAINER -> tokens.colorSurfaceContainer(); - case SURFACE_CONTAINER_HIGH -> tokens.colorSurfaceContainerHigh(); - case ON_SURFACE -> tokens.colorOnSurface(); - case ON_SURFACE_VARIANT -> tokens.colorOnSurfaceVariant(); - case OUTLINE -> tokens.colorOutline(); - case ERROR -> tokens.colorError(); - case SUCCESS -> tokens.colorSuccess(); - case WARNING -> tokens.colorWarning(); - default -> throw new IllegalArgumentException("Not a colour slot: " + slot); - }; - } - - private static float minimumOf(ThemeEditor.Slot slot) { - return switch (slot) { - case SCRIM_OPACITY -> ThemeProfileValidator.MIN_SCRIM_OPACITY; - case DIALOG_OPACITY -> ThemeProfileValidator.MIN_DIALOG_OPACITY; - case PLAYER_SCRIM_OPACITY -> ThemeProfileValidator.MIN_PLAYER_SCRIM_OPACITY; - case LIVE_LIST_SCRIM_OPACITY -> ThemeProfileValidator.MIN_LIVE_LIST_SCRIM_OPACITY; - // 条目遮罩的有效区间是 5%..100%(见 ThemeProfileValidator),与 OVERLAY_OPACITY 的 - // 5%..60% 不同;不显式列出就会落到 default 分支,滑杆永远推不到 100%。 - case ITEM_OVERLAY_OPACITY -> ThemeProfileValidator.MIN_ITEM_OVERLAY_OPACITY; - default -> ThemeProfileValidator.MIN_OVERLAY_OPACITY; - }; - } - - private static float maximumOf(ThemeEditor.Slot slot) { - return switch (slot) { - case SCRIM_OPACITY -> ThemeProfileValidator.MAX_SCRIM_OPACITY; - case DIALOG_OPACITY -> ThemeProfileValidator.MAX_DIALOG_OPACITY; - case PLAYER_SCRIM_OPACITY -> ThemeProfileValidator.MAX_PLAYER_SCRIM_OPACITY; - case LIVE_LIST_SCRIM_OPACITY -> ThemeProfileValidator.MAX_LIVE_LIST_SCRIM_OPACITY; - case ITEM_OVERLAY_OPACITY -> ThemeProfileValidator.MAX_ITEM_OVERLAY_OPACITY; - default -> ThemeProfileValidator.MAX_OVERLAY_OPACITY; - }; - } - - public static int labelOf(ThemeEditor.Slot slot) { - return switch (slot) { - case PRIMARY -> R.string.theme_editor_slot_primary; - case PRIMARY_CONTAINER -> R.string.theme_editor_slot_primary_container; - case SECONDARY_CONTAINER -> R.string.theme_editor_slot_secondary_container; - case FOCUS -> R.string.theme_editor_slot_focus; - case PLAYER_CURRENT -> R.string.theme_editor_slot_player_current; - case SURFACE -> R.string.theme_editor_slot_surface; - case SURFACE_CONTAINER -> R.string.theme_editor_slot_surface_container; - case SURFACE_CONTAINER_HIGH -> R.string.theme_editor_slot_surface_container_high; - case ON_SURFACE -> R.string.theme_editor_slot_on_surface; - case ON_SURFACE_VARIANT -> R.string.theme_editor_slot_on_surface_variant; - case OUTLINE -> R.string.theme_editor_slot_outline; - case ERROR -> R.string.theme_editor_slot_error; - case SUCCESS -> R.string.theme_editor_slot_success; - case WARNING -> R.string.theme_editor_slot_warning; - case SCRIM_OPACITY -> R.string.theme_editor_slot_scrim_opacity; - case DIALOG_OPACITY -> R.string.theme_editor_slot_dialog_opacity; - case OVERLAY_OPACITY -> R.string.theme_editor_slot_overlay_opacity; - case PLAYER_SCRIM_OPACITY -> R.string.theme_editor_slot_player_scrim_opacity; - case LIVE_LIST_SCRIM_OPACITY -> R.string.theme_editor_slot_live_list_scrim_opacity; - case ITEM_OVERLAY -> R.string.theme_editor_slot_item_overlay; - case ON_ITEM_OVERLAY -> R.string.theme_editor_slot_on_item_overlay; - case ITEM_OVERLAY_OPACITY -> R.string.theme_editor_slot_item_overlay_opacity; - }; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfile.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfile.java deleted file mode 100644 index c8dec31c7b..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfile.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.fongmi.android.tv.theme; - -import java.util.Locale; - -/** Persisted B-safe theme profile limited to the 22 user-editable semantic slots. */ -public final class ThemeProfile { - - public static final int SCHEMA_VERSION = 2; - public static final String FORMAT = "webhtv-theme"; - public static final String MODE_SYSTEM = "system"; - public static final String MODE_LIGHT = "light"; - public static final String MODE_DARK = "dark"; - public static final String SEED_NONE = "none"; - public static final String SEED_WALLPAPER = "wallpaper"; - public static final String SEED_CUSTOM = "custom"; - - public int schemaVersion = SCHEMA_VERSION; - public String format = FORMAT; - public String id = "webhtv.local"; - public String name = "Default"; - public String mode = MODE_SYSTEM; - public String paletteStyle = ThemePaletteStyle.TONAL_SPOT.id(); - public String seedSource = SEED_NONE; - public String seedColor; - public SlotSet light = new SlotSet(); - public SlotSet dark = new SlotSet(); - - public ThemeProfile() { - } - - public static ThemeProfile defaultProfile() { - return new ThemeProfile(); - } - - public ThemeProfile copy() { - ThemeProfile copy = new ThemeProfile(); - copy.schemaVersion = schemaVersion; - copy.format = format; - copy.id = id; - copy.name = name; - copy.mode = mode; - copy.paletteStyle = paletteStyle; - copy.seedSource = seedSource; - copy.seedColor = seedColor; - copy.light = light == null ? null : light.copy(); - copy.dark = dark == null ? null : dark.copy(); - return copy; - } - - public SlotSet slots(boolean dark) { - if (dark) { - if (this.dark == null) this.dark = new SlotSet(); - return this.dark; - } - if (light == null) light = new SlotSet(); - return light; - } - - public String displayName() { - return name == null || name.isBlank() ? "Default" : name; - } - - public static String normalizeMode(String value) { - if (MODE_LIGHT.equals(value)) return MODE_LIGHT; - if (MODE_DARK.equals(value)) return MODE_DARK; - return MODE_SYSTEM; - } - - public static String normalizePaletteStyle(String value) { - return ThemePaletteStyle.from(value).id(); - } - - public static String normalizeSeedSource(String value) { - if (SEED_CUSTOM.equals(value)) return SEED_CUSTOM; - if (SEED_WALLPAPER.equals(value)) return SEED_WALLPAPER; - return SEED_NONE; - } - - /** The 16 color slots plus 6 opacity slots a user may edit. */ - public static final class SlotSet { - public String primary; - public String primaryContainer; - public String secondaryContainer; - public String focus; - /** - * 播放页「当前播放/当前生效」环色。与 focus 分开,因为这两个状态在播放页同时存在、 - * 必须可区分;取值会在播放页固定玻璃面板上做可读性夹取(ThemeResolver)。 - */ - public String playerCurrent; - public String surface; - public String surfaceContainer; - public String surfaceContainerHigh; - public String onSurface; - public String onSurfaceVariant; - public String outline; - public String error; - public String success; - public String warning; - /** - * 条目遮罩底色。列表/按钮聚焦时压在封面与标题上的那层半透明深色就是它。 - * null 表示沿用编译期 {@code @color/black_40}({@code #66000000})的取值,因此空 - * profile 的解析结果与改动前字节一致;强度单独由 {@link #itemOverlayOpacity} 控制, - * 颜色槽本身仍只接受不透明 #RRGGBB(与其它颜色槽同一套 B-safe 校验)。 - */ - public String itemOverlay; - /** - * 遮罩上的选中/聚焦文字色。null 表示沿用 {@code colorOnPrimary}:遮罩上的文字本来就是 - * onPrimary 一族、跟随明暗表;只有用户显式改过,才在「遮罩复合底色」上夹取 ≥4.5:1。 - */ - public String onItemOverlay; - public Float scrimOpacity; - public Float dialogOpacity; - public Float overlayOpacity; - /** - * 条目遮罩强度:只缩放 {@link #itemOverlay} 的 alpha,不改色相。null 表示不缩放, - * 于是默认遮罩仍是编译期的 40% 黑({@code #66000000})。 - */ - public Float itemOverlayOpacity; - /** - * 播放菜单遮盖层(播放页底部控制条黑幕)的强度,只缩放 alpha,色相固定取 - * colorPlayerScrim;null 表示回落到 {@code PlaybackMenuScrim.DEFAULT_OPACITY}。 - */ - public Float playerScrimOpacity; - /** - * 直播页频道抽屉(列表)遮盖层的强度,只缩放 alpha,色相固定为纯黑;null 表示回落到 - * {@code LiveListScrim.DEFAULT_OPACITY},此时渲染结果与 shape_live_list.xml 逐值一致。 - */ - public Float liveListScrimOpacity; - - public SlotSet copy() { - SlotSet copy = new SlotSet(); - copy.primary = primary; - copy.primaryContainer = primaryContainer; - copy.secondaryContainer = secondaryContainer; - copy.focus = focus; - copy.playerCurrent = playerCurrent; - copy.surface = surface; - copy.surfaceContainer = surfaceContainer; - copy.surfaceContainerHigh = surfaceContainerHigh; - copy.onSurface = onSurface; - copy.onSurfaceVariant = onSurfaceVariant; - copy.outline = outline; - copy.error = error; - copy.success = success; - copy.warning = warning; - copy.itemOverlay = itemOverlay; - copy.onItemOverlay = onItemOverlay; - copy.scrimOpacity = scrimOpacity; - copy.dialogOpacity = dialogOpacity; - copy.overlayOpacity = overlayOpacity; - copy.itemOverlayOpacity = itemOverlayOpacity; - copy.playerScrimOpacity = playerScrimOpacity; - copy.liveListScrimOpacity = liveListScrimOpacity; - return copy; - } - } - - @Override - public String toString() { - return String.format(Locale.ROOT, "%s (%s)", displayName(), mode); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileCodec.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileCodec.java deleted file mode 100644 index 6ca1626c82..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileCodec.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.fongmi.android.tv.theme; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.nio.charset.StandardCharsets; -import java.util.Map; - -/** JSON codec for the bounded B-safe profile; rejects executable or oversized payloads. */ -public final class ThemeProfileCodec { - - private static final Gson GSON = new GsonBuilder().serializeNulls().create(); - - private ThemeProfileCodec() { - } - - public static ThemeProfile parse(String json) { - if (json == null || json.isBlank()) throw new IllegalArgumentException("theme JSON is empty"); - validateJsonBounds(json); - JsonElement root; - try { - root = JsonParser.parseString(json); - } catch (RuntimeException error) { - throw new IllegalArgumentException("theme JSON is invalid", error); - } - if (!root.isJsonObject()) throw new IllegalArgumentException("theme JSON must be an object"); - inspect(root, 0); - ThemeProfile parsed; - try { - parsed = GSON.fromJson(root, ThemeProfile.class); - } catch (RuntimeException error) { - throw new IllegalArgumentException("theme JSON has an invalid field type", error); - } - if (parsed == null) throw new IllegalArgumentException("theme JSON must be an object"); - applyMissingDefaults(parsed); - ThemeProfileValidator.Result result = ThemeProfileValidator.validate(parsed); - if (!result.valid()) throw new IllegalArgumentException(result.message()); - return result.profile(); - } - - /** - * Gson allocates without running field initializers, so absent keys arrive as - * null/0. Bounded defaults keep an old or partial payload loadable. - */ - static void applyMissingDefaults(ThemeProfile profile) { - if (profile.format == null) profile.format = ThemeProfile.FORMAT; - if (profile.schemaVersion == 0) profile.schemaVersion = ThemeProfile.SCHEMA_VERSION; - if (profile.light == null) profile.light = new ThemeProfile.SlotSet(); - if (profile.dark == null) profile.dark = new ThemeProfile.SlotSet(); - } - - public static String encode(ThemeProfile profile) { - ThemeProfileValidator.Result result = ThemeProfileValidator.validate(profile); - if (!result.valid()) throw new IllegalArgumentException(result.message()); - return GSON.toJson(result.profile()); - } - - static void validateJsonBounds(String json) { - if (json.getBytes(StandardCharsets.UTF_8).length > ThemeProfileValidator.MAX_JSON_BYTES) { - throw new IllegalArgumentException("theme JSON is too large"); - } - int depth = 0; - boolean quoted = false; - boolean escaped = false; - for (int index = 0; index < json.length(); index++) { - char current = json.charAt(index); - if (quoted) { - if (escaped) escaped = false; - else if (current == '\\') escaped = true; - else if (current == '"') quoted = false; - continue; - } - if (current == '"') quoted = true; - else if (current == '{' || current == '[') { - if (++depth > ThemeProfileValidator.MAX_NESTING_DEPTH) { - throw new IllegalArgumentException("theme JSON is too deeply nested"); - } - } else if (current == '}' || current == ']') { - if (--depth < 0) throw new IllegalArgumentException("theme JSON is invalid"); - } - } - if (quoted || depth != 0) throw new IllegalArgumentException("theme JSON is invalid"); - } - - private static void inspect(JsonElement element, int depth) { - if (depth > ThemeProfileValidator.MAX_NESTING_DEPTH) throw new IllegalArgumentException("theme JSON is too deeply nested"); - if (element == null || element.isJsonNull() || element.isJsonPrimitive()) return; - if (element.isJsonArray()) { - for (JsonElement child : element.getAsJsonArray()) inspect(child, depth + 1); - return; - } - JsonObject object = element.getAsJsonObject(); - for (Map.Entry entry : object.entrySet()) { - if (!ThemeProfileValidator.isSafeJsonKey(entry.getKey())) { - throw new IllegalArgumentException("unsupported executable theme field: " + entry.getKey()); - } - inspect(entry.getValue(), depth + 1); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileStore.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileStore.java deleted file mode 100644 index ea37089c8b..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileStore.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.fongmi.android.tv.theme; - -import com.fongmi.android.tv.setting.Setting; -import com.github.catvod.utils.Prefers; - -/** SharedPreferences-backed store for the v2 profile with last-good recovery. */ -public final class ThemeProfileStore { - - public static final String KEY_PROFILE = "theme_profile_v2_json"; - public static final String KEY_LAST_GOOD = "theme_profile_v2_last_good"; - public static final String KEY_SCHEMA = "theme_profile_v2_schema"; - public static final String LEGACY_KEY_PROFILE = "theme_profile_json"; - - private ThemeProfileStore() { - } - - /** Reads the active profile; corrupted or missing data never blocks the caller. */ - public static ThemeProfile load() { - ThemeProfile current = parse(Prefers.getString(KEY_PROFILE)); - if (current != null) return current; - ThemeProfile lastGood = parse(Prefers.getString(KEY_LAST_GOOD)); - return lastGood != null ? lastGood : migrateLegacy(Setting.getThemeColor(), Prefers.getString(LEGACY_KEY_PROFILE)); - } - - /** Returns the last successfully persisted profile, or null when none is usable. */ - public static ThemeProfile loadLastGood() { - return parse(Prefers.getString(KEY_LAST_GOOD)); - } - - /** Persists the one-time legacy migration so later loads do not re-derive it. */ - public static ApplyResult ensureMigrated() { - ThemeProfile current = parse(Prefers.getString(KEY_PROFILE)); - if (current != null) return ApplyResult.success(current); - ThemeProfile migrated = load(); - return apply(migrated); - } - - public static ApplyResult apply(ThemeProfile draft) { - ThemeProfileValidator.Result result = ThemeProfileValidator.validate(draft); - if (!result.valid()) return ApplyResult.failure(result.message()); - String json; - try { - json = ThemeProfileCodec.encode(result.profile()); - } catch (RuntimeException error) { - return ApplyResult.failure(error.getMessage() == null ? "theme serialization failed" : error.getMessage()); - } - if (!writeJson(result.profile(), json)) return ApplyResult.failure("theme preferences could not be saved"); - return ApplyResult.success(result.profile()); - } - - public static ApplyResult reset() { - return apply(ThemeProfile.defaultProfile()); - } - - /** Maps the legacy theme_color mirror and, when present, bounded v1 profile fields. */ - public static ThemeProfile migrateLegacy(int legacyThemeColor, String legacyProfileJson) { - ThemeProfile migrated = fromLegacyJson(legacyProfileJson); - applyLegacyThemeColor(migrated, legacyThemeColor); - return migrated; - } - - public static ThemeProfile migrateLegacy(int legacyThemeColor) { - return migrateLegacy(legacyThemeColor, null); - } - - /** Mirrors seed source/color back into the legacy theme_color preference. */ - public static int legacyThemeColor(ThemeProfile profile) { - if (profile == null || ThemeProfile.SEED_NONE.equals(profile.seedSource)) return -1; - if (ThemeProfile.SEED_WALLPAPER.equals(profile.seedSource)) return 0; - return ThemeProfileValidator.parseColor(profile.seedColor, -1); - } - - private static ThemeProfile fromLegacyJson(String json) { - ThemeProfile profile = ThemeProfile.defaultProfile(); - if (json == null || json.isBlank()) return profile; - try { - com.google.gson.JsonElement parsed = com.google.gson.JsonParser.parseString(json); - if (!parsed.isJsonObject()) return profile; - com.google.gson.JsonObject root = parsed.getAsJsonObject(); - profile.mode = ThemeProfile.normalizeMode(string(root, "mode")); - profile.seedSource = ThemeProfile.normalizeSeedSource(string(root, "seedSource")); - String seed = nullableString(root, "seedColor"); - if (seed != null) profile.seedColor = ThemeProfileValidator.normalizeColor(seed); - com.google.gson.JsonObject colors = object(root, "colors"); - if (colors != null) { - copyLegacySlots(object(colors, "light"), profile.slots(false)); - copyLegacySlots(object(colors, "dark"), profile.slots(true)); - } - } catch (RuntimeException ignored) { - // A malformed legacy profile must not block startup; theme_color remains the fallback. - } - return profile; - } - - private static void copyLegacySlots(com.google.gson.JsonObject source, ThemeProfile.SlotSet target) { - if (source == null) return; - target.primary = ThemeProfileValidator.normalizeColor(nullableString(source, "primary")); - target.surface = ThemeProfileValidator.normalizeColor(nullableString(source, "surface")); - target.onSurface = ThemeProfileValidator.normalizeColor(nullableString(source, "onSurface")); - target.outline = ThemeProfileValidator.normalizeColor(nullableString(source, "outline")); - target.error = ThemeProfileValidator.normalizeColor(nullableString(source, "error")); - } - - private static void applyLegacyThemeColor(ThemeProfile profile, int legacyThemeColor) { - if (legacyThemeColor == -1) { - if (ThemeProfile.SEED_NONE.equals(profile.seedSource)) profile.seedColor = null; - return; - } - profile.seedSource = legacyThemeColor == 0 ? ThemeProfile.SEED_WALLPAPER : ThemeProfile.SEED_CUSTOM; - profile.seedColor = legacyThemeColor == 0 ? null : ThemeProfileValidator.formatColor(legacyThemeColor); - if (legacyThemeColor != 0) { - profile.id = "webhtv.legacy-custom"; - profile.name = "Custom"; - } - } - - private static ThemeProfile parse(String raw) { - if (raw == null || raw.isBlank()) return null; - try { - return ThemeProfileCodec.parse(raw); - } catch (RuntimeException error) { - return null; - } - } - - private static boolean writeJson(ThemeProfile profile, String json) { - try { - android.content.SharedPreferences.Editor editor = Prefers.getPrefers().edit() - .putString(KEY_PROFILE, json) - .putString(KEY_LAST_GOOD, json) - .putInt(KEY_SCHEMA, ThemeProfile.SCHEMA_VERSION) - .putInt("theme_color", legacyThemeColor(profile)); - return editor.commit(); - } catch (RuntimeException error) { - return false; - } - } - - private static String string(com.google.gson.JsonObject object, String key) { - if (object == null || !object.has(key) || object.get(key).isJsonNull()) return null; - try { - return object.get(key).getAsString(); - } catch (RuntimeException error) { - return null; - } - } - - private static String nullableString(com.google.gson.JsonObject object, String key) { - return string(object, key); - } - - private static com.google.gson.JsonObject object(com.google.gson.JsonObject parent, String key) { - if (parent == null || !parent.has(key) || !parent.get(key).isJsonObject()) return null; - return parent.getAsJsonObject(key); - } - - public record ApplyResult(boolean success, ThemeProfile profile, String error) { - - public static ApplyResult success(ThemeProfile profile) { - return new ApplyResult(true, profile, ""); - } - - public static ApplyResult failure(String error) { - return new ApplyResult(false, null, error == null ? "unknown error" : error); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileValidator.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileValidator.java deleted file mode 100644 index 9855286f3f..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileValidator.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.fongmi.android.tv.theme; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Set; - -/** Validates and normalizes a B-safe theme profile before it is persisted or resolved. */ -public final class ThemeProfileValidator { - - public static final int MAX_JSON_BYTES = 128 * 1024; - public static final int MAX_NESTING_DEPTH = 8; - public static final float MIN_SCRIM_OPACITY = 0f; - public static final float MAX_SCRIM_OPACITY = 0.85f; - public static final float MIN_DIALOG_OPACITY = 0.70f; - public static final float MAX_DIALOG_OPACITY = 1f; - public static final float MIN_OVERLAY_OPACITY = 0.05f; - public static final float MAX_OVERLAY_OPACITY = 0.60f; - public static final float MIN_PLAYER_SCRIM_OPACITY = 0.20f; - public static final float MAX_PLAYER_SCRIM_OPACITY = 1f; - public static final float MIN_LIVE_LIST_SCRIM_OPACITY = 0.20f; - public static final float MAX_LIVE_LIST_SCRIM_OPACITY = 1f; - /** - * 条目遮罩强度的取值范围。上限是 1(而不是环境遮罩槽的 0.60):遮罩色由用户自选,选浅色 - * 时往往需要压满才能盖住封面上的杂色,而强度只是缩放 alpha,压满等于「遮罩色说了算」; - * 下限沿用 0.05,低于它在电视观看距离上等于没有遮罩,滑到那里只会让用户以为功能失效。 - */ - public static final float MIN_ITEM_OVERLAY_OPACITY = 0.05f; - public static final float MAX_ITEM_OVERLAY_OPACITY = 1f; - - private static final Set DANGEROUS_KEYS = Set.of( - "script", "javascript", "resource", "resourcepath", "intent", "classname", "class", "css", - "url", "uri", "path", "file", "assets", "src", "srcset", "href", "onload", "onerror"); - - private ThemeProfileValidator() { - } - - public static Result validate(ThemeProfile profile) { - List errors = new ArrayList<>(); - if (profile == null) return Result.invalid("theme profile is missing"); - if (profile.schemaVersion != ThemeProfile.SCHEMA_VERSION) { - errors.add("schemaVersion must be " + ThemeProfile.SCHEMA_VERSION); - } - if (!ThemeProfile.FORMAT.equals(profile.format)) errors.add("format must be " + ThemeProfile.FORMAT); - profile.id = text(profile.id, "webhtv.local"); - profile.name = text(profile.name, "Default"); - if (profile.id.length() > 64) errors.add("id is too long"); - if (profile.name.length() > 64) errors.add("name is too long"); - profile.mode = ThemeProfile.normalizeMode(profile.mode); - profile.paletteStyle = ThemeProfile.normalizePaletteStyle(profile.paletteStyle); - profile.seedSource = ThemeProfile.normalizeSeedSource(profile.seedSource); - profile.seedColor = color(profile.seedColor, errors, "seedColor"); - if (ThemeProfile.SEED_CUSTOM.equals(profile.seedSource) && profile.seedColor == null) { - errors.add("seedColor is required for a custom seed"); - } - if (ThemeProfile.SEED_NONE.equals(profile.seedSource)) profile.seedColor = null; - if (profile.light == null) profile.light = new ThemeProfile.SlotSet(); - if (profile.dark == null) profile.dark = new ThemeProfile.SlotSet(); - validateSlots(profile.light, errors, "light"); - validateSlots(profile.dark, errors, "dark"); - return new Result(errors.isEmpty() ? profile : null, errors); - } - - private static void validateSlots(ThemeProfile.SlotSet slots, List errors, String path) { - slots.primary = color(slots.primary, errors, path + ".primary"); - slots.primaryContainer = color(slots.primaryContainer, errors, path + ".primaryContainer"); - slots.secondaryContainer = color(slots.secondaryContainer, errors, path + ".secondaryContainer"); - slots.focus = color(slots.focus, errors, path + ".focus"); - slots.playerCurrent = color(slots.playerCurrent, errors, path + ".playerCurrent"); - slots.surface = color(slots.surface, errors, path + ".surface"); - slots.surfaceContainer = color(slots.surfaceContainer, errors, path + ".surfaceContainer"); - slots.surfaceContainerHigh = color(slots.surfaceContainerHigh, errors, path + ".surfaceContainerHigh"); - slots.onSurface = color(slots.onSurface, errors, path + ".onSurface"); - slots.onSurfaceVariant = color(slots.onSurfaceVariant, errors, path + ".onSurfaceVariant"); - slots.outline = color(slots.outline, errors, path + ".outline"); - slots.error = color(slots.error, errors, path + ".error"); - slots.success = color(slots.success, errors, path + ".success"); - slots.warning = color(slots.warning, errors, path + ".warning"); - slots.itemOverlay = color(slots.itemOverlay, errors, path + ".itemOverlay"); - slots.onItemOverlay = color(slots.onItemOverlay, errors, path + ".onItemOverlay"); - slots.scrimOpacity = opacity(slots.scrimOpacity, MIN_SCRIM_OPACITY, MAX_SCRIM_OPACITY, errors, path + ".scrimOpacity"); - slots.dialogOpacity = opacity(slots.dialogOpacity, MIN_DIALOG_OPACITY, MAX_DIALOG_OPACITY, errors, path + ".dialogOpacity"); - slots.overlayOpacity = opacity(slots.overlayOpacity, MIN_OVERLAY_OPACITY, MAX_OVERLAY_OPACITY, errors, path + ".overlayOpacity"); - slots.playerScrimOpacity = opacity(slots.playerScrimOpacity, MIN_PLAYER_SCRIM_OPACITY, MAX_PLAYER_SCRIM_OPACITY, errors, path + ".playerScrimOpacity"); - slots.liveListScrimOpacity = opacity(slots.liveListScrimOpacity, MIN_LIVE_LIST_SCRIM_OPACITY, MAX_LIVE_LIST_SCRIM_OPACITY, errors, path + ".liveListScrimOpacity"); - slots.itemOverlayOpacity = opacity(slots.itemOverlayOpacity, MIN_ITEM_OVERLAY_OPACITY, MAX_ITEM_OVERLAY_OPACITY, errors, path + ".itemOverlayOpacity"); - } - - private static String color(String value, List errors, String path) { - if (value == null || value.isBlank()) return null; - String normalized = normalizeColor(value); - if (normalized == null) errors.add(path + " must be an opaque #RRGGBB color"); - return normalized; - } - - /** Only opaque #RRGGBB colors are accepted; transparent values must use an opacity slot. */ - static String normalizeColor(String value) { - if (value == null) return null; - String normalized = value.trim().toUpperCase(Locale.ROOT); - if (normalized.length() == 4 && normalized.charAt(0) == '#') { - normalized = "#" + normalized.charAt(1) + normalized.charAt(1) - + normalized.charAt(2) + normalized.charAt(2) - + normalized.charAt(3) + normalized.charAt(3); - } - if (normalized.length() != 7 || normalized.charAt(0) != '#') return null; - for (int index = 1; index < normalized.length(); index++) { - char current = normalized.charAt(index); - boolean hex = (current >= '0' && current <= '9') || (current >= 'A' && current <= 'F'); - if (!hex) return null; - } - return normalized; - } - - static int parseColor(String value, int fallback) { - String normalized = normalizeColor(value); - if (normalized == null) return fallback; - return (int) (0xFF000000L | Long.parseLong(normalized.substring(1), 16)); - } - - static String formatColor(int value) { - return String.format(Locale.ROOT, "#%06X", value & 0xFFFFFF); - } - - private static Float opacity(Float value, float minimum, float maximum, List errors, String path) { - if (value == null) return null; - if (!Float.isFinite(value) || value < minimum || value > maximum) { - errors.add(path + " must be between " + minimum + " and " + maximum); - return value; - } - return value; - } - - private static String text(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value.trim(); - } - - public static boolean isSafeJsonKey(String key) { - if (key == null) return false; - String normalized = key.replace("_", "").replace("-", "").toLowerCase(Locale.ROOT); - return !DANGEROUS_KEYS.contains(normalized); - } - - public record Result(ThemeProfile profile, List errors) { - - public Result { - errors = errors == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(errors)); - } - - public boolean valid() { - return errors.isEmpty(); - } - - public String message() { - return valid() ? "" : String.join("; ", errors); - } - - public static Result invalid(String error) { - return new Result(null, List.of(error)); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeResolver.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeResolver.java deleted file mode 100644 index e7ef97f738..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeResolver.java +++ /dev/null @@ -1,439 +0,0 @@ -package com.fongmi.android.tv.theme; - -import com.google.android.material.color.utilities.DynamicScheme; -import com.google.android.material.color.utilities.Hct; - -/** Resolves semantic tokens without exposing seed colors directly to UI surfaces. */ -public final class ThemeResolver { - - private static final ThreadLocal LAST_DIAGNOSTIC = ThreadLocal.withInitial(() -> "default"); - - /** Material's own baseline scheme keeps primary at or above this on every surface role. */ - private static final double MIN_ACCENT_CONTRAST = 4.5; - - /** - * 播放页(视频层)环色必须可读的背景。 - * - *

视频层环色的宿主是**固定深色玻璃**:控制面板 - * ({@code shape_dialog_control_glass_panel})、快捷搜索/选集面板 - * ({@code shape_quick_search_dialog})、播放器底部抽屉,三档渐变都是 - * {@code #E62F315E → #D6282955 → #CC303463},叠在任何画面上都保持深色。 - * 浅色表的 FOCUS 深蓝 #0B57D0 在它们上面只有 1.83:1(dev1 实机实测), - * 所以浅色表必须夹取到 ≥3:1(派生结果 #447BF5)——这正是本方法存在的理由。 - * - *

纯白视频(雪/白墙)刻意不在列表里。用户报告(2026-10-11) - * 「播放页存在多个不同颜色的高亮效果」:把纯白视频也作为约束,会把深色表的 - * 近白环压成灰蓝 #7695C5,于是同一页出现「应用表面近白 + 视频层灰蓝」两种高亮。 - * 深色表两类宿主的实际底色都是深色,近白环对它们全部可读,所以深色表统一取近白 - * (与应用表面焦点环同值),只有浅色表保留夹取。 - * 已知取舍:近白环在纯白视频上对比度不足,仅在「亮场景视频 + 焦点落在画面框/播控条」 - * 时出现,而绝大多数播放页宿主的局部底色是深玻璃。 - */ - private static final int[] PLAYER_RING_BACKDROPS = { - 0xFF2F315E, 0xFF282955, 0xFF303463, // 控制面板固定玻璃渐变的三档 - }; - - /** - * 播放页环色的对比度门槛。取 WCAG 2.2 SC 1.4.11「非文本对比度」的 3:1(与 - * {@code outline}/{@code focus} 的 {@code ensureContrast(..., 3.0)} 同一口径); - * 4.5:1 是正文文本的档位,用在环上会把浅色表的蓝压得过暗。 - */ - private static final double MIN_PLAYER_RING_CONTRAST = 3.0; - - private ThemeResolver() { - } - - public static ThemeTokens resolve(ThemeMode mode, ThemeSeed seed, int seedColor, - int wallpaperColor, boolean systemDark) { - return resolve(mode, seed, seedColor, wallpaperColor, null, systemDark); - } - - /** - * Resolves the frozen palette, an optional seed, and the B-safe profile in one - * deterministic order. A profile may only narrow the 16 user slots; it cannot - * reach player colors or the derived on* pairs. - */ - public static ThemeTokens resolve(ThemeMode mode, ThemeSeed seed, int seedColor, - int wallpaperColor, ThemeProfile profile, boolean systemDark) { - ThemeProfile lastGood = null; - try { - lastGood = ThemeProfileStore.loadLastGood(); - } catch (RuntimeException ignored) { - // Android preference access is unavailable (for example in a JVM test); defaults remain valid. - } - return resolve(mode, seed, seedColor, wallpaperColor, profile, lastGood, systemDark); - } - - /** Testable overload with an explicit last-good fallback. */ - public static ThemeTokens resolve(ThemeMode mode, ThemeSeed seed, int seedColor, int wallpaperColor, - ThemeProfile profile, ThemeProfile lastGood, boolean systemDark) { - boolean dark = mode == ThemeMode.DARK || (mode != ThemeMode.LIGHT && systemDark); - ThemeTokens fallback = dark ? ThemeTokens.dark() : ThemeTokens.light(); - ThemeTokens base = fallback; - String path = "default"; - ThemePaletteStyle paletteStyle = profile == null - ? ThemePaletteStyle.TONAL_SPOT - : ThemePaletteStyle.from(profile.paletteStyle); - try { - if (seed != null && seed != ThemeSeed.NONE) { - int source = seed == ThemeSeed.WALLPAPER ? wallpaperColor : seedColor; - if (!isOpaque(source)) { - LAST_DIAGNOSTIC.set("fallback:invalid-seed:" + seed.name().toLowerCase(java.util.Locale.US)); - return fallback; - } - base = derive(source, dark, fallback, paletteStyle); - path = "seed:" + seed.name().toLowerCase(java.util.Locale.US); - } else if (profile != null && paletteStyle != ThemePaletteStyle.TONAL_SPOT) { - base = derive(paletteStyle.defaultSeed(), dark, fallback, paletteStyle); - path = "palette:" + paletteStyle.id(); - } - ThemeTokens candidate = profile == null ? base : applyProfile(base, profile, dark); - candidate.requireContrast(); - LAST_DIAGNOSTIC.set(profile == null ? path : path + "+profile:" + profile.displayName()); - return candidate; - } catch (RuntimeException error) { - String cause = error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage(); - if (lastGood != null && lastGood != profile) { - try { - ThemeTokens recovered = applyProfile(base, lastGood, dark); - recovered.requireContrast(); - LAST_DIAGNOSTIC.set("last-good:" + cause); - return recovered; - } catch (RuntimeException ignored) { - // Fall through to the frozen default palette. - } - } - LAST_DIAGNOSTIC.set("fallback:profile-resolve:" + cause); - return fallback; - } - } - - /** - * Applies only the slots the user actually overrode. Empty/null slots keep the - * resolved base value, and on* pairs are recomputed from black/white contrast so - * the profile cannot create unreadable foreground/background pairs. - */ - static ThemeTokens applyProfile(ThemeTokens base, ThemeProfile profile, boolean dark) { - ThemeProfileValidator.Result validated = ThemeProfileValidator.validate(profile); - if (!validated.valid()) throw new IllegalArgumentException(validated.message()); - ThemeProfile.SlotSet slots = dark ? validated.profile().dark : validated.profile().light; - - boolean hasPrimary = slots.primary != null; - boolean hasPrimaryContainer = slots.primaryContainer != null; - boolean hasSecondaryContainer = slots.secondaryContainer != null; - boolean hasFocus = slots.focus != null; - boolean hasSurface = slots.surface != null; - boolean hasContainer = slots.surfaceContainer != null; - boolean hasContainerHigh = slots.surfaceContainerHigh != null; - boolean hasOnSurface = slots.onSurface != null; - boolean hasOnSurfaceVariant = slots.onSurfaceVariant != null; - boolean hasOutline = slots.outline != null; - boolean hasError = slots.error != null; - boolean hasSuccess = slots.success != null; - boolean hasWarning = slots.warning != null; - - int primary = color(slots.primary, base.colorPrimary()); - int primaryContainer = color(slots.primaryContainer, base.colorPrimaryContainer()); - int secondaryContainer = color(slots.secondaryContainer, base.colorSecondaryContainer()); - int surface = color(slots.surface, base.colorSurface()); - int surfaceContainer = color(slots.surfaceContainer, base.colorSurfaceContainer()); - int surfaceContainerHigh = color(slots.surfaceContainerHigh, base.colorSurfaceContainerHigh()); - if (hasContainer && surfaceContainer == surface) { - surfaceContainer = mix(surface, dark ? 0xFFFFFFFF : 0xFF000000, 0.06); - } - if (hasContainerHigh && surfaceContainerHigh == surfaceContainer) { - surfaceContainerHigh = mix(surfaceContainer, dark ? 0xFFFFFFFF : 0xFF000000, 0.06); - } - int surfaceContainerHighest = hasContainer || hasContainerHigh - ? mix(surfaceContainerHigh, surfaceContainer, 0.35) - : base.colorSurfaceContainerHighest(); - // Material 3 derives primary and the surface roles from one tonal palette, so - // primary is legible on every surface role by construction (the shipped baseline - // scheme measures 4.97-10.91:1). The editor exposes both slots independently, - // which can break that guarantee - primary is also the dialog action-button text - // colour, so a broken pair renders actions invisible. Restoring the guarantee is - // what the documented B-safe contract promises: a profile must never be able to - // create an unreadable pair. The move keeps hue and chroma and only shifts tone. - int readablePrimary = readableAccent( - primary, surface, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest); - boolean primaryAdjusted = readablePrimary != primary; - primary = readablePrimary; - int error = color(slots.error, base.colorError()); - int success = color(slots.success, base.colorSuccess()); - int warning = color(slots.warning, base.colorWarning()); - - // Derived pairs are only recomputed when the user actually touched a slot that - // feeds them; an empty profile must stay byte-identical to the frozen palette. - int onPrimary = (hasPrimary || primaryAdjusted) - ? readableOn(primary, base.colorOnPrimary()) : base.colorOnPrimary(); - int onPrimaryContainer = hasPrimaryContainer - ? readableOn(primaryContainer, base.colorOnPrimaryContainer()) : base.colorOnPrimaryContainer(); - int onSecondaryContainer = hasSecondaryContainer - ? readableOn(secondaryContainer, base.colorOnSecondaryContainer()) : base.colorOnSecondaryContainer(); - int onError = hasError ? readableOn(error, base.colorOnError()) : base.colorOnError(); - int onSuccess = hasSuccess ? readableOn(success, base.colorOnSuccess()) : base.colorOnSuccess(); - int onWarning = hasWarning ? readableOn(warning, base.colorOnWarning()) : base.colorOnWarning(); - - boolean surfaceChanged = hasSurface || hasContainer || hasContainerHigh; - int onSurface = surfaceChanged || hasOnSurface - ? enforceBase(color(slots.onSurface, base.colorOnSurface()), surface, - hasContainer ? surfaceContainer : -1, - hasContainerHigh ? surfaceContainerHigh : -1) - : base.colorOnSurface(); - int onSurfaceVariant = surfaceChanged || hasOnSurfaceVariant - ? enforceBase(color(slots.onSurfaceVariant, base.colorOnSurfaceVariant()), surface, - hasContainer ? surfaceContainer : -1, -1) - : base.colorOnSurfaceVariant(); - int outline = hasOutline || hasSurface - ? ensureContrast(color(slots.outline, base.colorOutline()), surface, 3.0) - : base.colorOutline(); - // The frozen palette deliberately ships focus == primary. Keeping that relationship - // matters beyond aesthetics: the binder resolves a view colour by its semantic role, - // and a baseline colour shared by two roles is only rewritten when both agree. If - // focus drifted away from primary, primary-as-text views (dialog actions) would keep - // their compiled colour and stay unreadable. `primary` already clears 4.5:1 on the - // surface, so it also clears the 3.0:1 focus requirement. - // A user-chosen focus colour is clamped exactly like primary: hue and chroma are kept - // and only tone moves. Snapping to black/white instead turned a legible deep red such - // as #8C0000 (1.86:1 on the dark surface) into pure #FFFFFF, which is why the editor - // showed "输入 #8C0000 / 显示 #FFFFFF" and every dark-surface ring came out white. - int focus = hasFocus || hasSurface - ? readableAccent(color(slots.focus, primary), 3.0, surface) - : base.colorFocus(); - - int surfaceDim = hasSurface ? mix(surface, 0xFF000000, dark ? 0.06 : 0.10) : base.colorSurfaceDim(); - int surfaceBright = hasSurface ? mix(surface, 0xFFFFFFFF, dark ? 0.14 : 0.08) : base.colorSurfaceBright(); - - // ---------------------------------------------------------------- 播放页(视频层)环色 - // 视频层环色的宿主是固定深色玻璃与深色视频画面,与应用表面无关,所以不能直接用 - // focus:浅色表的 focus #0B57D0 在固定深靛玻璃上只有 1.83:1(实机 dev1 复核), - // 焦点不可见。因此用仓库已有的 readableAccent:保持用户 FOCUS 槽的色相与彩度、 - // 只走明度,直到在固定玻璃上达到 MIN_PLAYER_RING_CONTRAST(3.0)。 - // 深色表的 focus 本身就是近白 #F5F7FF,对深玻璃与深色 video 都远远达标,夹取是 - // 恒等变换——这正是用户要的「播放页只有一种高亮色」:深色表下视频层环 = 应用表面环。 - int playerFocusRing = hasFocus - ? readableAccent(focus, MIN_PLAYER_RING_CONTRAST, PLAYER_RING_BACKDROPS) - : base.colorPlayerFocusRing(); - // 「当前播放」有独立的用户槽位(默认绿);同样做固定玻璃上的可读性夹取。 - boolean hasPlayerCurrent = slots.playerCurrent != null; - int playerCurrentRing = hasPlayerCurrent - ? readableAccent(color(slots.playerCurrent, base.colorPlayerCurrentRing()), - MIN_PLAYER_RING_CONTRAST, PLAYER_RING_BACKDROPS) - : base.colorPlayerCurrentRing(); - - float scrimOpacity = slots.scrimOpacity == null ? (base.colorScrim() >>> 24) / 255f : slots.scrimOpacity; - float dialogOpacity = slots.dialogOpacity == null ? base.dialogOpacity() : slots.dialogOpacity; - float overlayOpacity = slots.overlayOpacity == null ? (base.colorOverlayLight() >>> 24) / 255f : slots.overlayOpacity; - - // ---------------------------------------------------------------- 条目遮罩 - // 遮罩底色默认取编译期 @color/black_40(#66000000):强度槽为空时**完全不动 alpha**, - // 空 profile 解析出的 token 才与改动前的静态资源字节一致。用户改过颜色槽之后才用强度槽 - // 重写 alpha(空则视作 1.0),因为颜色槽只接受不透明 #RRGGBB,本身没有 alpha 可继承。 - boolean hasItemOverlay = slots.itemOverlay != null; - boolean hasItemOverlayOpacity = slots.itemOverlayOpacity != null; - int itemOverlayColor = hasItemOverlay - ? ThemeProfileValidator.parseColor(slots.itemOverlay, base.colorItemOverlay()) - : base.colorItemOverlay(); - int itemOverlay = hasItemOverlayOpacity - ? withAlpha(itemOverlayColor, slots.itemOverlayOpacity) : itemOverlayColor; - // 遮罩上的文字:用户显式改过才夹取可读性(保持色相、只走明度,与 FOCUS 槽同一口径)。 - // 夹取的宿主是「遮罩压在 surface 各层上」的复合底色——遮罩自带 alpha,直接拿 surface 比 - // 会高估对比度。用户没改文字色但遮罩变了(底色或强度)时,仍用既有的 readableOn 在这几个 - // 复合底色上重挑黑/白:否则默认的选中文字压在用户选的不透明浅色遮罩上会跌到 4.5:1 以下。 - int onItemOverlay; - if (slots.onItemOverlay != null) { - onItemOverlay = readableAccent(color(slots.onItemOverlay, base.colorOnItemOverlay()), - MIN_ACCENT_CONTRAST, - overlayBackdrops(itemOverlay, surface, surfaceContainer, surfaceContainerHigh)); - } else if (hasItemOverlay || hasItemOverlayOpacity) { - onItemOverlay = readableOverlayText(base.colorOnItemOverlay(), - overlayBackdrops(itemOverlay, surface, surfaceContainer, surfaceContainerHigh)); - } else { - onItemOverlay = base.colorOnItemOverlay(); - } - - return new ThemeTokens( - primary, onPrimary, primaryContainer, onPrimaryContainer, - base.colorSecondary(), base.colorOnSecondary(), - secondaryContainer, onSecondaryContainer, - base.colorTertiary(), base.colorOnTertiary(), - error, onError, base.colorErrorContainer(), base.colorOnErrorContainer(), - success, onSuccess, base.colorSuccessContainer(), base.colorOnSuccessContainer(), - warning, onWarning, base.colorWarningContainer(), base.colorOnWarningContainer(), - surface, surfaceDim, surfaceBright, base.colorSurfaceContainerLowest(), - base.colorSurfaceContainerLow(), surfaceContainer, surfaceContainerHigh, surfaceContainerHighest, - onSurface, onSurfaceVariant, outline, base.colorOutlineVariant(), - base.colorInverseSurface(), base.colorInverseOnSurface(), base.colorInversePrimary(), - withAlpha(base.colorScrim(), scrimOpacity), base.colorShadow(), focus, base.focusScale(), - playerFocusRing, playerCurrentRing, dialogOpacity, - base.colorPlayerControl(), base.colorPlayerControlMuted(), base.colorPlayerControlActive(), base.colorPlayerScrim(), - base.colorHealthGood(), base.colorHealthWarn(), base.colorHealthBad(), - withAlpha(base.colorOverlayLight(), overlayOpacity), base.colorOverlayDark(), - itemOverlay, onItemOverlay - ); - } - - /** - * 条目遮罩真正压在什么底色上:遮罩自身的 alpha 与 surface 各层复合后的结果。 - * - *

遮罩是半透明的,用户又可能只改强度,因此对比度必须按复合后的实色算; - * 直接拿 surface 当宿主会把默认白字算成 4.5:1 以上,实机却是 3.2:1。 - */ - private static int[] overlayBackdrops(int itemOverlay, int... surfaces) { - int[] backdrops = new int[surfaces.length]; - for (int index = 0; index < surfaces.length; index++) { - backdrops[index] = ThemeContrast.composite(itemOverlay, surfaces[index]); - } - return backdrops; - } - - /** - * 用户只改了遮罩(底色或强度)时,让既有的 onPrimary 一族在这几个复合底色上重新可读。 - * - *

为什么不逐个底色跑 {@link #readableOn}:那个方法只对**一个**底色保证 4.5:1, - * 逐个套用会在不同底色间来回改判(遮罩压在 surface 与 surfaceContainerHigh 上亮度不同), - * 最终可能连第一个底色都不再达标。这里一次性比较黑/白在**全部**底色上的最差比值, - * 既有的 default 值只要本来全部达标就原样保留——空 profile 之外的常见路径也不会漂移。 - */ - private static int readableOverlayText(int preferred, int... backdrops) { - if (clearsContrast(preferred, MIN_ACCENT_CONTRAST, backdrops)) return preferred; - return worstRatio(0xFF000000, backdrops) >= worstRatio(0xFFFFFFFF, backdrops) ? 0xFF000000 : 0xFFFFFFFF; - } - - /** 前景色在全部底色上最差的那个对比度。 */ - private static double worstRatio(int foreground, int... backdrops) { - double worst = Double.MAX_VALUE; - for (int backdrop : backdrops) worst = Math.min(worst, ThemeContrast.ratio(foreground, backdrop)); - return worst; - } - private static int enforceBase(int foreground, int surface, int container, int containerHigh) { - int result = ensureContrast(foreground, surface, 4.5); - if (container >= 0) result = ensureContrast(result, container, 4.5); - if (containerHigh >= 0) result = ensureContrast(result, containerHigh, 4.5); - return result; - } - - private static int color(String value, int fallback) { - return value == null ? fallback : ThemeProfileValidator.parseColor(value, fallback); - } - - private static int readableOn(int background, int preferred) { - double black = ThemeContrast.ratio(0xFF000000, background); - double white = ThemeContrast.ratio(0xFFFFFFFF, background); - boolean useBlack = black >= white; - if ((preferred == 0xFF000000) == useBlack && ThemeContrast.ratio(preferred, background) >= 4.5) return preferred; - return useBlack ? 0xFF000000 : 0xFFFFFFFF; - } - - /** - * Keeps {@code color} when it clears {@link #MIN_ACCENT_CONTRAST} on every backdrop, - * otherwise returns the nearest tone on the same hue/chroma that does. - * - *

Walking tone rather than snapping to black/white preserves the user's brand - * hue - the same thing Material's tonal palette does when it assigns primary. - */ - private static int readableAccent(int color, int... backdrops) { - return readableAccent(color, MIN_ACCENT_CONTRAST, backdrops); - } - - /** - * Same walk, with an explicit threshold. The player ring uses - * {@link #MIN_PLAYER_RING_CONTRAST} (3:1, the WCAG non-text state bar) because - * 4.5:1 would drag the light-table blue far too dark for a ring that sits on - * fixed dark glass. - */ - private static int readableAccent(int color, double minimum, int... backdrops) { - if (clearsContrast(color, minimum, backdrops)) return color; - Hct source = Hct.fromInt(color); - double hue = source.getHue(); - double chroma = source.getChroma(); - double original = source.getTone(); - int best = color; - double bestDistance = Double.MAX_VALUE; - for (int tone = 0; tone <= 100; tone++) { - int candidate = Hct.from(hue, chroma, tone).toInt(); - if (!clearsContrast(candidate, minimum, backdrops)) continue; - double distance = Math.abs(tone - original); - if (distance < bestDistance) { - bestDistance = distance; - best = candidate; - } - } - if (bestDistance != Double.MAX_VALUE) return best; - // Unreachable for real surfaces (tone 0 / 100 always bracket), but never return - // an unreadable colour if a future call site passes something degenerate. - int fallback = ensureContrast(color, backdrops[0], minimum); - for (int backdrop : backdrops) fallback = ensureContrast(fallback, backdrop, minimum); - return fallback; - } - - private static boolean clearsContrast(int color, int... backdrops) { - return clearsContrast(color, MIN_ACCENT_CONTRAST, backdrops); - } - - private static boolean clearsContrast(int color, double minimum, int... backdrops) { - for (int backdrop : backdrops) { - if (ThemeContrast.ratio(color, backdrop) + 0.0001 < minimum) return false; - } - return true; - } - - private static int ensureContrast(int foreground, int background, double minimum) { - if (ThemeContrast.ratio(foreground, background) + 0.0001 >= minimum) return foreground; - double black = ThemeContrast.ratio(0xFF000000, background); - double white = ThemeContrast.ratio(0xFFFFFFFF, background); - return black >= white ? 0xFF000000 : 0xFFFFFFFF; - } - - private static int mix(int first, int second, double amount) { - double inverse = 1.0 - amount; - int red = (int) Math.round(((first >>> 16) & 0xFF) * inverse + ((second >>> 16) & 0xFF) * amount); - int green = (int) Math.round(((first >>> 8) & 0xFF) * inverse + ((second >>> 8) & 0xFF) * amount); - int blue = (int) Math.round((first & 0xFF) * inverse + (second & 0xFF) * amount); - return 0xFF000000 | (red << 16) | (green << 8) | blue; - } - - private static int withAlpha(int color, float opacity) { - int alpha = Math.max(0, Math.min(255, Math.round(opacity * 255f))); - return (color & 0x00FFFFFF) | (alpha << 24); - } - - public static String lastDiagnostic() { - return LAST_DIAGNOSTIC.get(); - } - - static ThemeTokens requireOrFallback(ThemeTokens candidate, ThemeTokens fallback) { - try { - return candidate.requireContrast(); - } catch (IllegalArgumentException error) { - LAST_DIAGNOSTIC.set("fallback:seed-contrast:" + error.getMessage()); - return fallback; - } - } - - private static ThemeTokens derive(int seedColor, boolean dark, ThemeTokens fallback, - ThemePaletteStyle paletteStyle) { - DynamicScheme scheme = paletteStyle.create(seedColor, dark); - return new ThemeTokens( - scheme.getPrimary(), scheme.getOnPrimary(), scheme.getPrimaryContainer(), scheme.getOnPrimaryContainer(), - scheme.getSecondary(), scheme.getOnSecondary(), scheme.getSecondaryContainer(), scheme.getOnSecondaryContainer(), - scheme.getTertiary(), scheme.getOnTertiary(), scheme.getError(), scheme.getOnError(), - scheme.getErrorContainer(), scheme.getOnErrorContainer(), - fallback.colorSuccess(), fallback.colorOnSuccess(), fallback.colorSuccessContainer(), fallback.colorOnSuccessContainer(), - fallback.colorWarning(), fallback.colorOnWarning(), fallback.colorWarningContainer(), fallback.colorOnWarningContainer(), - scheme.getSurface(), scheme.getSurfaceDim(), scheme.getSurfaceBright(), scheme.getSurfaceContainerLowest(), - scheme.getSurfaceContainerLow(), scheme.getSurfaceContainer(), scheme.getSurfaceContainerHigh(), scheme.getSurfaceContainerHighest(), - scheme.getOnSurface(), scheme.getOnSurfaceVariant(), scheme.getOutline(), scheme.getOutlineVariant(), - scheme.getInverseSurface(), scheme.getInverseOnSurface(), scheme.getInversePrimary(), scheme.getScrim(), - scheme.getShadow(), scheme.getPrimary(), fallback.focusScale(), - fallback.colorPlayerFocusRing(), fallback.colorPlayerCurrentRing(), fallback.dialogOpacity(), fallback.colorPlayerControl(), - fallback.colorPlayerControlMuted(), fallback.colorPlayerControlActive(), fallback.colorPlayerScrim(), - fallback.colorHealthGood(), fallback.colorHealthWarn(), fallback.colorHealthBad(), fallback.colorOverlayLight(), fallback.colorOverlayDark(), - fallback.colorItemOverlay(), fallback.colorOnItemOverlay() - ); - } - - private static boolean isOpaque(int color) { - return ((color >>> 24) & 0xFF) == 0xFF; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeRole.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeRole.java deleted file mode 100644 index fce8a5ea3e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeRole.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.fongmi.android.tv.theme; - -import java.util.Locale; - -/** - * The bounded set of semantic roles the controlled binder may rewrite. - * - *

Only the 13 user-editable color slots and the six foreground roles the - * resolver derives from them are listed. Player, media, logo, rating, health and - * brand colors are intentionally absent so a profile can never reach them. - * - *

{@link #ITEM_OVERLAY} / {@link #ON_ITEM_OVERLAY} 是条目遮罩族的两个角色:它们有独立的 - * 用户槽位,但不参与基线色改写(理由见 {@link #isUserSlot()})。 - */ -public enum ThemeRole { - PRIMARY, - PRIMARY_CONTAINER, - SECONDARY_CONTAINER, - FOCUS, - SURFACE, - SURFACE_CONTAINER, - SURFACE_CONTAINER_HIGH, - ON_SURFACE, - ON_SURFACE_VARIANT, - OUTLINE, - ERROR, - SUCCESS, - WARNING, - ON_PRIMARY, - ON_PRIMARY_CONTAINER, - ON_SECONDARY_CONTAINER, - ON_ERROR, - ON_SUCCESS, - ON_WARNING, - /** - * 条目遮罩底色(默认 {@code #66000000} = {@code @color/black_40})。 - */ - ITEM_OVERLAY, - /** 遮罩上的选中/聚焦文字色(默认等于 onPrimary)。 */ - ON_ITEM_OVERLAY; - - public static final String TAG_PREFIX = "webhtv:"; - - /** Resolves an explicit {@code android:tag="webhtv:primary"} marker. */ - public static ThemeRole fromTag(Object tag) { - if (!(tag instanceof String value)) return null; - String normalized = value.trim().toLowerCase(Locale.ROOT); - if (!normalized.startsWith(TAG_PREFIX)) return null; - String name = normalized.substring(TAG_PREFIX.length()).trim(); - if (name.isEmpty()) return null; - for (ThemeRole role : values()) { - if (role.name().toLowerCase(Locale.ROOT).equals(name)) return role; - } - return null; - } - - /** The active value of this role, or 0 when the role is not bound. */ - public int colorOf(ThemeTokens tokens) { - if (tokens == null) return 0; - return switch (this) { - case PRIMARY -> tokens.colorPrimary(); - case PRIMARY_CONTAINER -> tokens.colorPrimaryContainer(); - case SECONDARY_CONTAINER -> tokens.colorSecondaryContainer(); - case FOCUS -> tokens.colorFocus(); - case SURFACE -> tokens.colorSurface(); - case SURFACE_CONTAINER -> tokens.colorSurfaceContainer(); - case SURFACE_CONTAINER_HIGH -> tokens.colorSurfaceContainerHigh(); - case ON_SURFACE -> tokens.colorOnSurface(); - case ON_SURFACE_VARIANT -> tokens.colorOnSurfaceVariant(); - case OUTLINE -> tokens.colorOutline(); - case ERROR -> tokens.colorError(); - case SUCCESS -> tokens.colorSuccess(); - case WARNING -> tokens.colorWarning(); - case ON_PRIMARY -> tokens.colorOnPrimary(); - case ON_PRIMARY_CONTAINER -> tokens.colorOnPrimaryContainer(); - case ON_SECONDARY_CONTAINER -> tokens.colorOnSecondaryContainer(); - case ON_ERROR -> tokens.colorOnError(); - case ON_SUCCESS -> tokens.colorOnSuccess(); - case ON_WARNING -> tokens.colorOnWarning(); - case ITEM_OVERLAY -> tokens.colorItemOverlay(); - case ON_ITEM_OVERLAY -> tokens.colorOnItemOverlay(); - }; - } - - /** - * 绑定器可以按基线色改写、且用户档案可以直接覆盖的 13 个颜色槽。 - * - *

条目遮罩族的两个角色同样是用户可编辑槽,却刻意不计入这里:它们的默认值(遮罩 - * {@code #66000000} = {@code @color/black_40}、文字色 = onPrimary 的白/深蓝)在仓库里 - * 被大量**固定**资源共用——键盘键底、搜索框、输入框、玻璃面板都填 {@code black_40}。 - * 一旦入表,用户改一次遮罩色就会把那些刻意固定的深玻璃一并改掉,而这正是 ThemeBinder - * 白名单机制要阻止的事。遮罩目前只走 token / 预览链路,因此这里保持原有的 13 个槽。 - */ - public boolean isUserSlot() { - return ordinal() <= WARNING.ordinal(); - } - - /** 是否参与「按基线色反查角色」的颜色改写;条目遮罩族不参与(理由见 {@link #isUserSlot()})。 */ - public boolean rewritesBaseline() { - return this != ITEM_OVERLAY && this != ON_ITEM_OVERLAY; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeSeed.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeSeed.java deleted file mode 100644 index ce2a24157d..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeSeed.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.fongmi.android.tv.theme; - -public enum ThemeSeed { - NONE, - WALLPAPER, - EXPLICIT, - CONTENT -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeTokens.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeTokens.java deleted file mode 100644 index cad27e5dcd..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeTokens.java +++ /dev/null @@ -1,211 +0,0 @@ -package com.fongmi.android.tv.theme; - -/** Immutable Material 3 semantic color and player-control contract. */ -public record ThemeTokens( - int colorPrimary, - int colorOnPrimary, - int colorPrimaryContainer, - int colorOnPrimaryContainer, - int colorSecondary, - int colorOnSecondary, - int colorSecondaryContainer, - int colorOnSecondaryContainer, - int colorTertiary, - int colorOnTertiary, - int colorError, - int colorOnError, - int colorErrorContainer, - int colorOnErrorContainer, - int colorSuccess, - int colorOnSuccess, - int colorSuccessContainer, - int colorOnSuccessContainer, - int colorWarning, - int colorOnWarning, - int colorWarningContainer, - int colorOnWarningContainer, - int colorSurface, - int colorSurfaceDim, - int colorSurfaceBright, - int colorSurfaceContainerLowest, - int colorSurfaceContainerLow, - int colorSurfaceContainer, - int colorSurfaceContainerHigh, - int colorSurfaceContainerHighest, - int colorOnSurface, - int colorOnSurfaceVariant, - int colorOutline, - int colorOutlineVariant, - int colorInverseSurface, - int colorInverseOnSurface, - int colorInversePrimary, - int colorScrim, - int colorShadow, - int colorFocus, - float focusScale, - /** - * 播放页(视频层)焦点环色。由 FOCUS 槽派生并在固定玻璃面板上做了 ≥4.5:1 的可读性夹取, - * 因为播放页控制条是叠在视频上的固定深靛玻璃:浅色表的 FOCUS 深蓝在它上面只有 1.83:1。 - */ - int colorPlayerFocusRing, - /** 播放页「当前播放/当前生效」环色。有独立的用户可编辑槽位(profile 的 playerCurrent)。 */ - int colorPlayerCurrentRing, - float dialogOpacity, - int colorPlayerControl, - int colorPlayerControlMuted, - int colorPlayerControlActive, - int colorPlayerScrim, - int colorHealthGood, - int colorHealthWarn, - int colorHealthBad, - int colorOverlayLight, - int colorOverlayDark, - /** - * 条目遮罩底色(已含 alpha):默认 {@code #66000000},即编译期 {@code @color/black_40} - * 的取值,透明度由 profile 的 itemOverlayOpacity 槽缩放。 - * - *

前缀 color 不是命名洁癖:{@code ThemeContractTest} 用组件名推断同族资源名 - * ({@code "webhtv_color_" + snakeCase(name.substring(5))}),组件名必须带 color 前缀 - * 才能推断出 {@code webhtv_color_item_overlay}。 - */ - int colorItemOverlay, - /** 遮罩上的选中/聚焦文字色。默认等于本表的 {@link #colorOnPrimary}。 */ - int colorOnItemOverlay -) { - - public static ThemeTokens light() { - return new ThemeTokens( - 0xFF0B57D0, - 0xFFFFFFFF, - 0xFFD3E3FD, - 0xFF041E49, - 0xFF5B5F63, - 0xFFFFFFFF, - 0xFFDFE2E6, - 0xFF191C1E, - 0xFF00639B, - 0xFFFFFFFF, - 0xFFB3261E, - 0xFFFFFFFF, - 0xFFF9DEDC, - 0xFF410E0B, - 0xFF146C2E, - 0xFFFFFFFF, - 0xFFC4EED0, - 0xFF00210B, - 0xFF7A5900, - 0xFFFFFFFF, - 0xFFFFDF9A, - 0xFF261A00, - 0xFFF8FAFD, - 0xFFD8DAE0, - 0xFFF8FAFD, - 0xFFFFFFFF, - 0xFFF2F3F9, - 0xFFECEEF4, - 0xFFE7E8EF, - 0xFFE1E2E9, - 0xFF1A1C1E, - 0xFF44474F, - 0xFF74777F, - 0xFFC4C6CF, - 0xFF2F3033, - 0xFFF1F0F4, - 0xFFA8C7FA, - 0x52000000, - 0xFF000000, - 0xFF0B57D0, - // 焦点缩放倍率与明暗模式无关:它是**动效**属性,不是配色属性。此前浅色表为 - // 1.0、深色表为 1.1,而各适配器一律写死 1.1,于是浅色主题的电视一旦改走 token - // 就会丢掉缩放反馈。这里统一为 1.1(= 既有实机行为,也是 Android TV 按钮 - // 指南与设计文档 §5.2 记录的取值)。 - 1.1f, - // 浅色表视频层环:FOCUS 深蓝在固定深靛玻璃上仅 1.83:1, - // 取 ThemeResolver 夹取到 ≥3:1 的派生值(与 values/webhtv_tokens.xml 同步)。 - 0xFF447BF5, - 0xFF00A95A, - 1.0f, - 0xFFFFFFFF, - 0xCCFFFFFF, - 0xFFFFD35C, - 0x7A000000, - 0xFF146C2E, - 0xFF7A5900, - 0xFFB3261E, - 0x14FFFFFF, - 0x52000000, - // 同上:遮罩 = @color/black_40,遮罩文字 = 本表 colorOnPrimary(浅色表为白)。 - 0x66000000, - 0xFFFFFFFF - ); - } - - public static ThemeTokens dark() { - return new ThemeTokens( - 0xFFA8C7FA, - 0xFF062E6F, - 0xFF0842A0, - 0xFFD3E3FD, - 0xFFC6C6C9, - 0xFF2F3133, - 0xFF44474A, - 0xFFE2E2E6, - 0xFF8ECDFF, - 0xFF003353, - 0xFFFFB4AB, - 0xFF690005, - 0xFF93000A, - 0xFFFFDAD6, - 0xFF8EDB9F, - 0xFF003918, - 0xFF00522A, - 0xFFC4EED0, - 0xFFF2C66D, - 0xFF402D00, - 0xFF5C4200, - 0xFFFFDF9A, - 0xFF101418, - 0xFF0B0E11, - 0xFF363A3F, - 0xFF0B0E11, - 0xFF181C20, - 0xFF1F2428, - 0xFF2A2F34, - 0xFF34393E, - 0xFFE2E2E9, - 0xFFC4C6D0, - 0xFF8E9099, - 0xFF44474F, - 0xFFE2E2E9, - 0xFF2F3033, - 0xFF0B57D0, - 0x8A000000, - 0xFF000000, - 0xFFF5F7FF, - 1.1f, - // 深色表视频层环 = 应用表面焦点环(近白):深玻璃/深色画面都可读, - // 因此播放页只有一种高亮色。浅色表仍为固定玻璃上的派生夹取值。 - 0xFFF5F7FF, - 0xFF00A95A, - 1.0f, - 0xFFFFFFFF, - 0xCCFFFFFF, - 0xFFFFD35C, - 0x8F000000, - 0xFF8EDB9F, - 0xFFF2C66D, - 0xFFFFB4AB, - 0x14FFFFFF, - // 条目遮罩沿用编译期 @color/black_40(#66000000);遮罩上的文字沿用本表的 - // colorOnPrimary,因此空 profile 的解析结果与改动前字节一致。 - 0x66000000, - 0x66000000, - 0xFF062E6F - ); - } - - public ThemeTokens requireContrast() { - ThemeContrast.require(this); - return this; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeTransfer.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeTransfer.java deleted file mode 100644 index 4bc4a9ed03..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeTransfer.java +++ /dev/null @@ -1,158 +0,0 @@ -package com.fongmi.android.tv.theme; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.InetAddress; -import java.net.Proxy; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.TimeUnit; - -import okhttp3.Authenticator; -import okhttp3.CookieJar; -import okhttp3.Dns; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; - -/** Bounded, no-cookie transport used by the theme draft importer. */ -public final class ThemeTransfer { - - public static final int MAX_BYTES = ThemeProfileValidator.MAX_JSON_BYTES; - private static final OkHttpClient CLIENT = new OkHttpClient.Builder() - .followRedirects(false) - .followSslRedirects(false) - .cookieJar(CookieJar.NO_COOKIES) - .proxy(Proxy.NO_PROXY) - .authenticator(Authenticator.NONE) - .proxyAuthenticator(Authenticator.NONE) - .connectTimeout(8, TimeUnit.SECONDS) - .readTimeout(8, TimeUnit.SECONDS) - .callTimeout(12, TimeUnit.SECONDS) - .build(); - - private ThemeTransfer() { - } - - public static boolean isHttps(String value) { - try { - URI uri = new URI(value == null ? "" : value.trim()); - return "https".equalsIgnoreCase(uri.getScheme()) - && uri.getHost() != null - && uri.getUserInfo() == null - && uri.getPort() != 0 - && uri.getPort() <= 65535 - && !isBlockedHost(uri.getHost()); - } catch (URISyntaxException | RuntimeException error) { - return false; - } - } - - public static String host(String value) { - try { - URI uri = new URI(value); - return uri.getHost() == null ? "" : uri.getHost(); - } catch (URISyntaxException | RuntimeException error) { - return ""; - } - } - - public static String read(InputStream input) throws IOException { - if (input == null) throw new IOException("theme source is unavailable"); - ByteArrayOutputStream output = new ByteArrayOutputStream(Math.min(MAX_BYTES, 16 * 1024)); - byte[] buffer = new byte[8192]; - int count; - int total = 0; - while ((count = input.read(buffer)) != -1) { - if (count > MAX_BYTES - total) throw new IOException("theme JSON is too large"); - output.write(buffer, 0, count); - total += count; - } - return new String(output.toByteArray(), StandardCharsets.UTF_8); - } - - public static String fetch(String value) throws IOException { - if (!isHttps(value)) throw new IOException("theme URL must be HTTPS and public"); - String requestedHost = host(value); - List addresses = lookupPublic(requestedHost); - OkHttpClient client = CLIENT.newBuilder().dns(host -> { - if (requestedHost.equalsIgnoreCase(host)) return addresses; - return Dns.SYSTEM.lookup(host); - }).build(); - Request request = new Request.Builder().url(value).get().build(); - try (Response response = client.newCall(request).execute()) { - if (response.code() >= 300 && response.code() < 400) { - throw new IOException("theme URL redirects are not allowed"); - } - if (!response.isSuccessful()) throw new IOException("theme URL returned HTTP " + response.code()); - ResponseBody body = response.body(); - if (body == null) throw new IOException("theme URL returned an empty response"); - if (body.contentLength() > MAX_BYTES) throw new IOException("theme JSON is too large"); - return read(body.byteStream()); - } - } - - private static List lookupPublic(String hostname) throws IOException { - if (hostname == null || hostname.isBlank()) throw new IOException("theme URL host is missing"); - List addresses; - try { - addresses = Dns.SYSTEM.lookup(hostname); - } catch (RuntimeException error) { - throw new IOException("theme URL host cannot be resolved", error); - } - if (addresses.isEmpty()) throw new IOException("theme URL host cannot be resolved"); - for (InetAddress address : addresses) { - if (isBlockedAddress(address)) throw new IOException("private or special theme host is not allowed"); - } - return addresses; - } - - private static boolean isBlockedHost(String host) { - String normalized = host == null ? "" : host.toLowerCase(Locale.ROOT); - if (normalized.isBlank() || "localhost".equals(normalized) - || normalized.endsWith(".localhost") || normalized.endsWith(".local")) return true; - if (!isIpLiteral(normalized)) return false; - try { - return isBlockedAddress(InetAddress.getByName(normalized)); - } catch (java.net.UnknownHostException | RuntimeException error) { - return true; - } - } - - private static boolean isIpLiteral(String host) { - if (host.indexOf(':') >= 0) return true; - String[] parts = host.split("\\.", -1); - if (parts.length != 4) return false; - for (String part : parts) { - if (part.isEmpty() || part.length() > 3) return false; - int value = 0; - for (int index = 0; index < part.length(); index++) { - char current = part.charAt(index); - if (current < '0' || current > '9') return false; - value = value * 10 + current - '0'; - } - if (value > 255) return false; - } - return true; - } - - private static boolean isBlockedAddress(InetAddress address) { - if (address == null || address.isAnyLocalAddress() || address.isLoopbackAddress() - || address.isLinkLocalAddress() || address.isSiteLocalAddress() || address.isMulticastAddress()) return true; - byte[] bytes = address.getAddress(); - if (bytes.length == 16 && (bytes[0] & 0xfe) == 0xfc) return true; - if (bytes.length != 4) return false; - int first = bytes[0] & 0xff; - int second = bytes[1] & 0xff; - return first == 0 || (first == 100 && second >= 64 && second <= 127) - || (first == 192 && second == 0) || (first == 192 && second == 2) - || (first == 198 && (second == 18 || second == 19)) - || (first == 198 && second == 51) || (first == 203 && second == 0) - || first >= 224; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapter.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapter.java deleted file mode 100644 index 11e6edc856..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapter.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.fongmi.android.tv.theme; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -/** Offline allowlist adapter for TweakCN/shadcn theme JSON. */ -public final class ThemeTweakCnAdapter { - - private static final int MAX_WARNINGS = 32; - - private ThemeTweakCnAdapter() { - } - - public static Result parse(String json) { - if (json == null || json.isBlank()) throw new IllegalArgumentException("theme JSON is empty"); - ThemeProfileCodec.validateJsonBounds(json); - JsonElement root; - try { - root = JsonParser.parseString(json); - } catch (RuntimeException error) { - throw new IllegalArgumentException("theme JSON is invalid", error); - } - if (!root.isJsonObject()) throw new IllegalArgumentException("theme JSON must be an object"); - inspectSafeKeys(root, 0); - JsonObject object = root.getAsJsonObject(); - if (object.has("format") || object.has("schemaVersion")) { - return new Result(ThemeProfileCodec.parse(json), List.of()); - } - - ThemeProfile profile = ThemeProfile.defaultProfile(); - List warnings = new ArrayList<>(); - JsonObject cssVars = object.has("cssVars") && object.get("cssVars").isJsonObject() - ? object.getAsJsonObject("cssVars") : object; - int recognized = 0; - if (cssVars.has("light") || cssVars.has("dark")) { - if (cssVars.has("light") && cssVars.get("light").isJsonObject()) { - recognized += applyObject(cssVars.getAsJsonObject("light"), profile.light, warnings, "light"); - } - if (cssVars.has("dark") && cssVars.get("dark").isJsonObject()) { - recognized += applyObject(cssVars.getAsJsonObject("dark"), profile.dark, warnings, "dark"); - } - warnUnsupported(cssVars, warnings, "cssVars"); - copyMissing(profile.light, profile.dark); - } else { - recognized = applyObject(cssVars, profile.light, warnings, "theme"); - copyMissing(profile.light, profile.dark); - } - if (recognized == 0) throw new IllegalArgumentException("no supported TweakCN color tokens"); - - String name = object.has("name") && object.get("name").isJsonPrimitive() - ? object.get("name").getAsString() : "Imported TweakCN theme"; - profile.id = "webhtv.tweakcn.imported"; - profile.name = name == null || name.isBlank() ? "Imported TweakCN theme" - : name.trim().substring(0, Math.min(64, name.trim().length())); - String seed = profile.light.primary != null ? profile.light.primary : profile.dark.primary; - if (seed != null) { - profile.seedSource = ThemeProfile.SEED_CUSTOM; - profile.seedColor = seed; - } - ThemeProfileValidator.Result checked = ThemeProfileValidator.validate(profile); - if (!checked.valid()) throw new IllegalArgumentException(checked.message()); - return new Result(checked.profile(), warnings); - } - - private static void inspectSafeKeys(JsonElement element, int depth) { - if (depth > ThemeProfileValidator.MAX_NESTING_DEPTH) throw new IllegalArgumentException("theme JSON is too deeply nested"); - if (element == null || element.isJsonNull() || element.isJsonPrimitive()) return; - if (element.isJsonArray()) { - for (JsonElement child : element.getAsJsonArray()) inspectSafeKeys(child, depth + 1); - return; - } - for (Map.Entry entry : element.getAsJsonObject().entrySet()) { - if (!ThemeProfileValidator.isSafeJsonKey(entry.getKey())) { - throw new IllegalArgumentException("unsupported executable theme field: " + entry.getKey()); - } - inspectSafeKeys(entry.getValue(), depth + 1); - } - } - - private static int applyObject(JsonObject object, ThemeProfile.SlotSet target, - List warnings, String path) { - int recognized = 0; - for (Map.Entry entry : object.entrySet()) { - String key = normalizeKey(entry.getKey()); - String value = color(entry.getValue()); - if (value == null) { - addWarning(warnings, path + "." + entry.getKey() + " ignored: unsupported field or color format"); - continue; - } - if (apply(key, value, target)) recognized++; - else addWarning(warnings, path + "." + entry.getKey() + " ignored: unsupported token"); - } - return recognized; - } - - private static void warnUnsupported(JsonObject object, List warnings, String path) { - for (Map.Entry entry : object.entrySet()) { - String key = normalizeKey(entry.getKey()); - if (!key.equals("--light") && !key.equals("--dark") && !isColorToken(key) && !"--theme".equals(key)) { - addWarning(warnings, path + "." + entry.getKey() + " ignored: unsupported field"); - } - } - } - - private static void copyMissing(ThemeProfile.SlotSet from, ThemeProfile.SlotSet to) { - if (to.primary == null) to.primary = from.primary; - if (to.primaryContainer == null) to.primaryContainer = from.primaryContainer; - if (to.surface == null) to.surface = from.surface; - if (to.surfaceContainer == null) to.surfaceContainer = from.surfaceContainer; - if (to.surfaceContainerHigh == null) to.surfaceContainerHigh = from.surfaceContainerHigh; - if (to.onSurface == null) to.onSurface = from.onSurface; - if (to.onSurfaceVariant == null) to.onSurfaceVariant = from.onSurfaceVariant; - if (to.outline == null) to.outline = from.outline; - if (to.focus == null) to.focus = from.focus; - if (to.error == null) to.error = from.error; - if (to.success == null) to.success = from.success; - if (to.warning == null) to.warning = from.warning; - } - - private static boolean apply(String key, String value, ThemeProfile.SlotSet target) { - switch (key) { - case "--primary" -> target.primary = value; - case "--primary-container", "--accent" -> target.primaryContainer = value; - case "--background", "--foreground" -> { - if ("--background".equals(key)) target.surface = value; - else target.onSurface = value; - } - case "--card", "--secondary", "--muted" -> target.surfaceContainer = value; - case "--popover", "--accent-foreground" -> target.surfaceContainerHigh = value; - case "--card-foreground", "--popover-foreground", "--secondary-foreground" -> target.onSurface = value; - case "--muted-foreground" -> target.onSurfaceVariant = value; - case "--border", "--input" -> target.outline = value; - case "--ring" -> target.focus = value; - case "--destructive" -> target.error = value; - default -> { return false; } - } - return true; - } - - private static boolean isColorToken(String key) { - return key.contains("primary") || key.contains("background") || key.contains("foreground") - || key.contains("card") || key.contains("popover") || key.contains("muted") - || key.contains("border") || key.contains("input") || key.contains("ring") - || key.contains("destructive") || key.contains("secondary") || key.contains("accent"); - } - - private static String normalizeKey(String value) { - String key = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); - return key.startsWith("--") ? key : "--" + key; - } - - private static String color(JsonElement value) { - if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) return null; - return ThemeColorUtil.normalizeCss(value.getAsString()); - } - - private static void addWarning(List warnings, String warning) { - if (warnings.size() < MAX_WARNINGS) warnings.add(warning); - } - - public record Result(ThemeProfile profile, List warnings) { - public Result { - warnings = warnings == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(warnings)); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeWebBridge.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeWebBridge.java deleted file mode 100644 index a38aab2c3f..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/ThemeWebBridge.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.fongmi.android.tv.theme; - -/** Produces a bounded, read-only token snapshot for trusted WebTheme pages. */ -public final class ThemeWebBridge { - - private ThemeWebBridge() { - } - - public static String snapshotJson(ThemeTokens tokens) { - ThemeTokens safe = tokens == null ? ThemeTokens.light() : tokens; - return "{\"primary\":\"" + hex(safe.colorPrimary()) + "\"" - + ",\"onPrimary\":\"" + hex(safe.colorOnPrimary()) + "\"" - + ",\"surface\":\"" + hex(safe.colorSurface()) + "\"" - + ",\"surfaceContainer\":\"" + hex(safe.colorSurfaceContainer()) + "\"" - + ",\"surfaceContainerHigh\":\"" + hex(safe.colorSurfaceContainerHigh()) + "\"" - + ",\"onSurface\":\"" + hex(safe.colorOnSurface()) + "\"" - + ",\"onSurfaceVariant\":\"" + hex(safe.colorOnSurfaceVariant()) + "\"" - + ",\"outline\":\"" + hex(safe.colorOutline()) + "\"" - + ",\"outlineVariant\":\"" + hex(safe.colorOutlineVariant()) + "\"" - + ",\"error\":\"" + hex(safe.colorError()) + "\"" - + ",\"success\":\"" + hex(safe.colorSuccess()) + "\"" - + ",\"warning\":\"" + hex(safe.colorWarning()) + "\"" - + ",\"focus\":\"" + hex(safe.colorFocus()) + "\"" - + ",\"focusScale\":" + decimal(safe.focusScale()) - + ",\"scrimOpacity\":" + decimal(alpha(safe.colorScrim())) - + ",\"dialogOpacity\":" + decimal(safe.dialogOpacity()) - + ",\"overlayOpacity\":" + decimal(alpha(safe.colorOverlayLight())) - // 条目遮罩是本轮新增的用户可编辑机制,页面只拿 overlayOpacity 画不出原生列表的 - // 聚焦遮罩,所以补三个只读派生字段(一共 20 个字段,字段集合仍由本类固定,页面只读)。 - + ",\"itemOverlay\":\"" + hex(safe.colorItemOverlay()) + "\"" - + ",\"onItemOverlay\":\"" + hex(safe.colorOnItemOverlay()) + "\"" - + ",\"itemOverlayOpacity\":" + decimal(alpha(safe.colorItemOverlay())) + "}"; - } - - private static String hex(int color) { - return String.format(java.util.Locale.US, "#%08X", color); - } - - /** Alpha channel of a colour as a stable 2-decimal fraction. */ - private static float alpha(int color) { - return ((color >>> 24) & 0xFF) / 255f; - } - - private static String decimal(float value) { - return String.format(java.util.Locale.US, "%.2f", value); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/UpdateVersionRowTheme.java b/app/src/main/java/com/fongmi/android/tv/theme/UpdateVersionRowTheme.java deleted file mode 100644 index fa0e1cfb1a..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/UpdateVersionRowTheme.java +++ /dev/null @@ -1,279 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.GradientDrawable; -import android.graphics.drawable.StateListDrawable; -import android.view.View; -import android.widget.TextView; - -import com.fongmi.android.tv.R; - -/** - * Row rendering for the version cards inside the “版本更新” dialog. - * - *

Why this exists (measured defect, not a style preference): both - * {@code UpdateDialog} flavours painted each version card with the static - * {@code selector_update_version_item} drawable (resting fill - * {@code ?attr/colorSurfaceContainerHighest}, selected fill - * {@code ?attr/colorPrimaryContainer}) while the card text came from - * {@code @color/update_version_item_title} / {@code _meta} / {@code _accent}, i.e. from - * {@code ?attr/colorOnSurface} / {@code ?attr/colorOnSurfaceVariant} / - * {@code ?attr/colorPrimary}. Those are two independent resource graphs: the fill is a - * {@code StateListDrawable} that {@link ThemeBinder} never rewrites - * ({@code bindDrawable} only handles {@code GradientDrawable} and - * {@code MaterialShapeDrawable}), while the text colours are {@code ColorStateList}s that - * it does rewrite. A user on the default theme sees the compiled pair and everything - * matches; a user on “壁纸取色” or a custom colour gets text re-derived from the dynamic - * palette on top of a fill that did not move - and the card goes unreadable. - * - *

Reported case (user screenshot, 1080x2340, dark wallpaper, “主题色彩 = 壁纸取色”): the - * beta card rendered as a solid {@code #0F1D26} block carrying {@code #49454F} text, i.e. - * 1.5:1, while the stable card next to it stayed legible - the two cards resolve - * different {@code ?attr} entries once the profile overlay is involved, so the mismatch - * only shows up on some cards for some users. - * - *

The card therefore has to take its fill and every text colour from one - * {@link ThemeController#current()} snapshot. {@code ?attr/color*} cannot do that: it is - * a compile-time resource, Android exposes no public API to rewrite it at runtime, and - * the whole point of the profile feature is that a user override must reach this dialog. - * - *

The card keeps the three states the static selector defined, with the same role swap - * between them: resting ({@code colorSurfaceContainerHighest} fill), the expanded/selected - * channel ({@code colorPrimaryContainer} fill) and focus ({@code colorPrimary} fill). The - * selector deliberately inverted fill and ring between "focused" and "selected" so that - * "where the focus is" and "which channel is open" stay two independent clues, and it used - * a closed ring for both. Collapsing the focus fill into {@code colorPrimaryContainer} - * would leave the TV dialog - which always opens with {@code stableItem.requestFocus()} - - * showing a focused card that looks selected; the ring width therefore comes from the - * single {@code @dimen/webhtv_focus_ring_width} token, not from a literal. - * - *

Every foreground is additionally re-checked with - * {@link ThemeEditorUi#readable(int, int)} against the fills it can actually land on, so - * an arbitrary user palette cannot produce a low-contrast card even when the two roles - * happen to collide. The card subtree is tagged {@link #IGNORE_TAG} because this class - * now owns it; otherwise {@link ThemeBinder} would re-colour the text a second time from - * a different role mapping and undo that guarantee. - */ -public final class UpdateVersionRowTheme { - - /** Same marker {@link ThemeBinder} honours for views that paint themselves. */ - public static final String IGNORE_TAG = "webhtv:ignore"; - - /** WCAG AA for body text; the card carries both a title and a version line. */ - private static final double AA = 4.5; - - /** WCAG 1.4.11 non-text contrast, used for the selected/focused ring. */ - private static final double RING = 3.0; - - private static final int CORNER_DP = 8; - - private static final int PILL_RADIUS_DP = 12; - - private UpdateVersionRowTheme() { - } - - /** - * The card fill: {@code colorSurfaceContainerHighest} at rest (the same role the - * static selector used, and the Material 3 "inset list" pattern already used by - * {@link AppearanceRowTheme}) and {@code colorPrimaryContainer} for the expanded - * channel, which is what the {@code state_selected} entry used to paint. - */ - public static int fill(ThemeTokens tokens, boolean expanded) { - ThemeTokens safe = safe(tokens); - return expanded ? safe.colorPrimaryContainer() : safe.colorSurfaceContainerHighest(); - } - - /** The card outline: {@code colorPrimary} when expanded, else the quiet hairline. */ - public static int ring(ThemeTokens tokens, boolean expanded) { - ThemeTokens safe = safe(tokens); - return expanded ? safe.colorPrimary() : safe.colorOutlineVariant(); - } - - /** Channel name (“稳定版” / “测试版”). */ - public static int nameColor(ThemeTokens tokens, boolean expanded) { - return readableOn(safe(tokens).colorOnSurface(), fill(tokens, expanded)); - } - - /** Version line (“5.6.0-beta-…”), the role that was unreadable in the report. */ - public static int versionColor(ThemeTokens tokens, boolean expanded) { - return readableOn(safe(tokens).colorOnSurfaceVariant(), fill(tokens, expanded)); - } - - /** Release notes body. */ - public static int descColor(ThemeTokens tokens, boolean expanded) { - return readableOn(safe(tokens).colorOnSurface(), fill(tokens, expanded)); - } - - /** The “展开” affordance. */ - public static int expandColor(ThemeTokens tokens, boolean expanded) { - return readableOn(safe(tokens).colorPrimary(), fill(tokens, expanded)); - } - - /** The “已最新” pill sits on {@code colorSurface}, so it stays visible on either fill. */ - public static int pillFill(ThemeTokens tokens) { - return safe(tokens).colorSurface(); - } - - /** The pill label; {@code colorPrimary} is already contracted against {@code colorSurface}. */ - public static int pillTextColor(ThemeTokens tokens) { - return readableOn(safe(tokens).colorPrimary(), pillFill(tokens)); - } - - /** - * The fill the card paints while it holds focus: {@code colorPrimary}, i.e. the role the - * static selector used for {@code state_focused} and deliberately swapped with the - * selected fill so the two cues stay independent. - */ - public static int focusFill(ThemeTokens tokens) { - return safe(tokens).colorPrimary(); - } - - /** - * The ink for the focused card. {@code colorOnPrimary} is already contracted against - * {@code colorPrimary}, so on a shipped palette this is the exact colour the old - * {@code update_version_item_*} state lists used; the fallback only fires for a user - * palette that breaks that pair. - */ - public static int focusInk(ThemeTokens tokens) { - ThemeTokens safe = safe(tokens); - return readableOn(safe.colorOnPrimary(), safe.colorPrimary()); - } - - /** - * One text role as a state list: focused and pressed take the on-focus ink, everything - * else the colour clamped against the resting and expanded fills. - * - *

The card's text views declare {@code android:duplicateParentState="true"} in both - * layouts; that is what lets the row's focus state reach them, and without it the - * focused branch would never be selected. - */ - static ColorStateList textColors(int resting, int focused) { - return new ColorStateList( - new int[][]{new int[]{android.R.attr.state_focused}, new int[]{android.R.attr.state_pressed}, new int[]{}}, - new int[]{focused, focused, resting}); - } - - /** - * Applies the palette to one version card. - * - *

The text colours are checked against both the resting and the expanded fill - * because the card keeps them while the user toggles it open: a colour that only - * clears AA on one of the two fills would go unreadable on the other. - * - * @param expanded whether this channel is the expanded one - */ - public static void apply(View row, TextView name, TextView version, TextView status, TextView expand, TextView desc, boolean expanded, ThemeTokens tokens) { - ThemeTokens safe = safe(tokens); - int resting = safe.colorSurfaceContainerHighest(); - int expandedFill = safe.colorPrimaryContainer(); - if (row != null) { - row.setTag(IGNORE_TAG); - row.setBackground(background(row.getContext(), safe)); - } - int focusedInk = focusInk(safe); - if (name != null) name.setTextColor(textColors(readableOn(safe.colorOnSurface(), resting, expandedFill), focusedInk)); - if (version != null) version.setTextColor(textColors(readableOn(safe.colorOnSurfaceVariant(), resting, expandedFill), focusedInk)); - if (desc != null) desc.setTextColor(textColors(readableOn(safe.colorOnSurface(), resting, expandedFill), focusedInk)); - if (expand != null) expand.setTextColor(textColors(readableOn(safe.colorPrimary(), resting, expandedFill), focusedInk)); - if (status != null) { - status.setTextColor(pillTextColor(safe)); - status.setBackground(pill(status.getContext(), pillFill(safe))); - } - } - - /** - * The card background for the current palette. - * - *

This is the runtime twin of {@code selector_update_version_item}: focus paints the - * {@code colorPrimary} fill with an on-primary ring, the selected channel paints - * {@code colorPrimaryContainer} with a primary ring, and the resting card keeps the - * quiet hairline. Focus is the only state whose text flips (see - * {@link #textColors(int, int)}), which is what makes a primary focus fill readable - * again. - */ - public static Drawable background(Context context, ThemeTokens tokens) { - ThemeTokens safe = safe(tokens); - StateListDrawable states = new StateListDrawable(); - states.addState(new int[]{android.R.attr.state_focused}, - faced(context, safe.colorPrimary(), ringColor(safe.colorOnPrimary(), safe.colorPrimary()))); - states.addState(new int[]{android.R.attr.state_pressed}, - faced(context, safe.colorPrimary(), ringColor(safe.colorOnPrimary(), safe.colorPrimary()))); - states.addState(new int[]{android.R.attr.state_selected}, - faced(context, safe.colorPrimaryContainer(), ringColor(safe.colorPrimary(), safe.colorPrimaryContainer()))); - states.addState(new int[]{}, - ThemeEditorUi.shape(context, safe.colorSurfaceContainerHighest(), safe.colorOutlineVariant(), 1, CORNER_DP)); - return states; - } - - /** - * A card face with the shared focus-ring width. {@link ThemeEditorUi#shape} skips its own - * stroke when the width is 0, so the width is set here from the single repository token - * instead of a per-widget literal. - */ - private static GradientDrawable faced(Context context, int fill, int stroke) { - GradientDrawable shape = ThemeEditorUi.shape(context, fill, stroke, 0, CORNER_DP); - shape.setStroke(focusRingWidth(context), stroke); - return shape; - } - - /** The single focus-ring width source, {@code app/src/main/res/values/webhtv_dimens.xml}. */ - private static int focusRingWidth(Context context) { - return Math.max(1, Math.round(context.getResources().getDimension(R.dimen.webhtv_focus_ring_width))); - } - - /** The ring role when it clears the non-text floor against its own fill, else a readable fallback. */ - private static int ringColor(int preferred, int fill) { - return ThemeContrast.ratio(preferred, fill) >= RING ? preferred : readableOn(preferred, fill); - } - - private static GradientDrawable pill(Context context, int fill) { - return ThemeEditorUi.shape(context, fill, 0, 0, PILL_RADIUS_DP); - } - - /** - * {@code preferred} when it clears AA on every fill it can land on, otherwise the - * better of black and white against all of them. - * - *

{@link ThemeEditorUi#readable(int, int)} only takes one background; a card whose - * text survives a fill change needs the conservative variant. One of black/white - * always clears AA against a given fill, and taking the minimum over the fills keeps - * that property for the pair. - */ - static int readableOn(int preferred, int... fills) { - if (clears(preferred, fills)) return preferred; - int best = 0xFF000000; - double bestScore = -1; - for (int candidate : new int[]{0xFF000000, 0xFFFFFFFF}) { - double score = Double.MAX_VALUE; - for (int fill : fills) score = Math.min(score, ThemeContrast.ratio(candidate, fill)); - if (score > bestScore) { - bestScore = score; - best = candidate; - } - } - return best; - } - - private static boolean clears(int color, int... fills) { - for (int fill : fills) if (ThemeContrast.ratio(color, fill) < AA) return false; - return true; - } - - /** - * The expanded card draws a real indicator ring, so it must clear the non-text - * contrast floor against its own fill. The resting card draws a decorative hairline - * only - the same {@code colorOutlineVariant} hairline the static selector used - so it - * is checked for distinguishability instead of the 3:1 indicator floor. - */ - static boolean ringIsVisible(ThemeTokens tokens, boolean expanded) { - ThemeTokens safe = safe(tokens); - if (expanded) return ThemeContrast.ratio(safe.colorPrimary(), safe.colorPrimaryContainer()) >= RING; - return safe.colorOutlineVariant() != safe.colorSurfaceContainerHighest(); - } - - private static ThemeTokens safe(ThemeTokens tokens) { - return tokens == null ? ThemeTokens.dark() : tokens; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/WebHtvAlertDialogBuilder.java b/app/src/main/java/com/fongmi/android/tv/theme/WebHtvAlertDialogBuilder.java deleted file mode 100644 index e76d71f390..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/theme/WebHtvAlertDialogBuilder.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; - -import androidx.appcompat.app.AlertDialog; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -/** - * The single construction point for Material alert dialogs in this app. - * - *

{@code MaterialAlertDialogBuilder.show()} is implemented as - * {@code create().show()} and {@code create()} is virtual, so overriding it here - * binds the dialog window to the active theme tokens without the caller having to do - * anything. Dialogs built with the raw Material builder were never passed to - * {@link ThemeController#bindDialog(android.app.Dialog)}, so their panels and text - * stayed on the compiled {@code webhtv_color_*} palette no matter which theme the - * user picked - the reason "theme colour" only appeared to affect the site dialog. - * - *

Binding is a no-op whenever the active tokens equal the compiled baseline, so - * the default appearance stays byte-identical to the previous behaviour and there is - * no cost for users who never change the theme. - */ -public class WebHtvAlertDialogBuilder extends MaterialAlertDialogBuilder { - - public WebHtvAlertDialogBuilder(Context context) { - super(context); - } - - public WebHtvAlertDialogBuilder(Context context, int themeResId) { - super(context, themeResId); - } - - @Override - public AlertDialog create() { - AlertDialog dialog = super.create(); - // The panel itself is this builder's window background, not a view background, - // so it is recoloured separately from the view tree. - ThemeController.bindWindowBackground(getBackground()); - ThemeController.bindDialog(dialog); - return dialog; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCache.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCache.java index afed9e04af..4b1dfbb125 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCache.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCache.java @@ -50,38 +50,18 @@ public final class MediaTitleCache { AiConfig safe = config == null ? new AiConfig().sanitize() : config.sanitize(); String rawTitle = request == null ? "" : request.getRawTitle(); if (rawTitle.trim().isEmpty()) return ""; - MediaTitleRequest safeRequest = request == null ? MediaTitleRequest.builder().build() : request; - StringBuilder value = new StringBuilder("v3|"); - appendPart(value, safeRequest.getSiteKey()); - appendPart(value, safeRequest.getVodId()); - appendPart(value, rawTitle); - appendPart(value, safeRequest.getRawRemarks()); - appendPart(value, safeRequest.getSearchKeyword()); - appendPart(value, safeRequest.getVodYear()); - appendPart(value, safeRequest.getEpisodeName()); - appendPart(value, safeRequest.getFolderName()); - for (String title : safeRequest.getContextTitles()) appendPart(value, title); - appendPart(value, safe.getProtocol()); - appendPart(value, safe.getEndpoint()); - appendPart(value, safe.getModel()); - appendPart(value, safe.getTitleExtractionPrompt()); - for (MediaTitleLearningExample example : safeRequest.getLearningExamples()) { - if (example == null) continue; - appendPart(value, example.getRawTitle()); - appendPart(value, example.getRuleTitle()); - appendPart(value, example.getExpectedTitle()); - appendPart(value, example.getMediaType()); - appendPart(value, Integer.toString(example.getYear())); - appendPart(value, Integer.toString(example.getSeasonNumber())); - appendPart(value, example.getSource()); - } - appendPart(value, Integer.toString(PROMPT_VERSION)); - return md5(value.toString()); - } - - private static void appendPart(StringBuilder value, String part) { - String text = Objects.toString(part, ""); - value.append(text.length()).append(':').append(text).append('|'); + String value = "v1|" + + Objects.toString(request.getSiteKey(), "") + "|" + + Objects.toString(request.getVodId(), "") + "|" + + Objects.toString(rawTitle, "") + "|" + + Objects.toString(request.getSearchKeyword(), "") + "|" + + Objects.toString(request.getEpisodeName(), "") + "|" + + Objects.toString(safe.getProtocol(), "") + "|" + + Objects.toString(safe.getEndpoint(), "") + "|" + + Objects.toString(safe.getModel(), "") + "|" + + Objects.toString(safe.getTitleExtractionPrompt(), "") + "|" + + PROMPT_VERSION; + return md5(value); } private static String md5(String text) { diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCandidate.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCandidate.java index 4bf0eeb09e..ffc1286053 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCandidate.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleCandidate.java @@ -10,7 +10,6 @@ public final class MediaTitleCandidate { public static final String SOURCE_AI = "ai"; public static final String SOURCE_RULE = "rule"; public static final String SOURCE_ALIAS = "alias"; - public static final String SOURCE_CONTEXT = "context"; public static final String SOURCE_RAW = "raw"; private String title; diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleParser.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleParser.java index ec9e15353c..9a977f2ad7 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleParser.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleParser.java @@ -4,7 +4,6 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Locale; -import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -16,44 +15,23 @@ public final class MediaTitleParser { private static final Pattern RELEASE_DATE_PATTERN = Pattern.compile("(? CATEGORY_LABELS = Set.of( - "电影", "電影", "电视剧", "電視劇", "剧集", "劇集", "动漫", "動漫", "动画", "動畫", "综艺", "綜藝", - "纪录片", "紀錄片", "纪录", "紀錄", "短片", "微电影", "微電影", "国漫", "國漫", "日漫", "美漫", - "美剧", "美劇", "韩剧", "韓劇", "日剧", "日劇", "港剧", "港劇", "台剧", "台劇", "泰剧", "泰劇", - "短剧", "短劇", "连续剧", "連續劇", "戏曲", "戲曲", "音乐", "音樂", "体育", "體育", "儿童", "兒童", - "大陆", "大陸", "内地", "內地", "中国", "中國", "国产", "國產", "港台", "香港", "台湾", "台灣", - "澳门", "澳門", "日本", "日韩", "日韓", "韩国", "韓國", "美国", "美國", "欧美", "歐美", "英国", "英國", - "法国", "法國", "德国", "德國", "泰国", "泰國", "印度", "新加坡", "加拿大", "澳大利亚", "澳大利亞", - "澳洲", "俄罗斯", "俄羅斯", "西班牙", "意大利", "土耳其", "巴西", "墨西哥", - "动作", "動作", "喜剧", "喜劇", "爱情", "愛情", "科幻", "恐怖", "惊悚", "驚悚", "悬疑", "懸疑", - "犯罪", "剧情", "劇情", "冒险", "冒險", "奇幻", "战争", "戰爭", "历史", "歷史", "传记", "傳記", - "歌舞", "家庭", "运动", "運動", "武侠", "武俠", "古装", "古裝", "灾难", "災難", "西部", "青春", - "都市", "谍战", "諜戰", "真人秀", "脱口秀", "脫口秀"); private static final Pattern SUSPICIOUS_MARKERS = Pattern.compile("[##]"); private static final Pattern BOOK_TITLE_PATTERN = Pattern.compile("《([^》]{1,80})》"); private static final Pattern URL_PATTERN = Pattern.compile("(?i)https?://\\S+"); private static final Pattern INLINE_FROM_PATTERN = Pattern.compile("\\bfrom\\b", Pattern.CASE_INSENSITIVE); private static final Pattern DASH_SEPARATOR_PATTERN = Pattern.compile("\\s+[--—~~]\\s+"); private static final Pattern FRAME_RATE_PATTERN = Pattern.compile("(?i)(? contextCandidates = contextTitles(safe); - String contextTitle = contextConsensusTitle(contextCandidates); - float contextConfidence = contextConfidence(contextCandidates, ruleTitle, contextTitle); MediaTitleLearningExample learning = bestLearningExample(ruleTitle, combined, safe.getLearningExamples()); MediaTitleResolution resolution = new MediaTitleResolution(); resolution.setRawTitle(raw); resolution.setRuleTitle(ruleTitle); - resolution.setRuleSource(ruleSource(raw, ruleTitle)); - resolution.setContextConfidence(contextConfidence); - resolution.setContextGroupSize(contextCandidates.isEmpty() ? 0 : contextCandidates.size() + 1); resolution.setYear(firstPositive(firstYear(safe.getVodYear()), firstYear(combined))); resolution.setSeasonNumber(firstPositive(seasonNumber(combined), inferTrailingSeason(raw))); resolution.setEpisodeNumber(episodeNumber(combined)); @@ -70,24 +48,15 @@ public final class MediaTitleParser { resolution.addCandidate(MediaTitleCandidate.of(learning.getExpectedTitle(), MediaTitleCandidate.SOURCE_MANUAL, 0.98f)); resolution.addAlias(learning.getRuleTitle()); } else { - boolean useContext = !contextTitle.isEmpty() && contextConfidence >= 0.85f - && (ruleTitle.isEmpty() || ruleConfidence < 0.75f); - resolution.setCanonicalTitle(useContext ? contextTitle : ruleTitle); - resolution.setConfidence(useContext - ? Math.max(ruleConfidence, Math.min(0.88f, 0.55f + contextConfidence * 0.35f)) - : ruleConfidence); + resolution.setCanonicalTitle(ruleTitle); + resolution.setConfidence(ruleConfidence(raw, ruleTitle)); resolution.setSource(MediaTitleResolution.SOURCE_RULE); - if (useContext) resolution.addCandidate(MediaTitleCandidate.of(contextTitle, MediaTitleCandidate.SOURCE_CONTEXT, contextConfidence)); } resolution.addCandidate(MediaTitleCandidate.of(resolution.getCanonicalTitle(), learning != null ? MediaTitleCandidate.SOURCE_MANUAL : MediaTitleCandidate.SOURCE_RULE, resolution.getConfidence())); resolution.addCandidate(MediaTitleCandidate.of(ruleTitle, MediaTitleCandidate.SOURCE_RULE, Math.min(0.8f, resolution.getConfidence()))); - if (learning == null && !contextTitle.isEmpty() && contextConfidence >= 0.85f) { - resolution.addCandidate(MediaTitleCandidate.of(contextTitle, MediaTitleCandidate.SOURCE_CONTEXT, contextConfidence)); - } if (!raw.equals(ruleTitle)) resolution.addCandidate(MediaTitleCandidate.of(raw, MediaTitleCandidate.SOURCE_RAW, 0.25f)); resolution.addAlias(ruleTitle); - resolution.setConfidenceBreakdown(String.format(Locale.ROOT, "rule=%.2f,context=%.2f,final=%.2f", ruleConfidence, contextConfidence, resolution.getConfidence())); return resolution; } @@ -104,7 +73,7 @@ public final class MediaTitleParser { if (isBlank(text)) return ""; String raw = text.trim(); String clean = raw; - clean = EXTENSION_PATTERN.matcher(clean).replaceAll(" "); + clean = clean.replaceAll("(?i)\\.(mkv|mp4|avi|mov|wmv|flv|rmvb|ts|m2ts)$", " "); clean = removeNoiseBrackets(clean); clean = EPISODE_PATTERN.matcher(clean).replaceAll(" "); clean = SEASON_PATTERN.matcher(clean).replaceAll(" "); @@ -112,11 +81,9 @@ public final class MediaTitleParser { clean = NOISE_WORDS.matcher(clean).replaceAll(" "); clean = FRAME_RATE_PATTERN.matcher(clean).replaceAll(" "); clean = clean.replaceAll("(更新至|更至|连载至|全|共)\\s*[0-9零〇一二三四五六七八九十百]+\\s*[集话話回期章节節]", " "); - clean = clean.replaceAll("(更新至|更至|连载至|連載至)\\s*$", " "); - // 站源标题在繁体界面下会被改成繁体,噪声词表必须同时列出简体与繁体写法, - // 否则「慶餘年(高碼)」这类标题清洗不掉尾部标记,会连标记一起拿去搜 TMDB。 - clean = clean.replaceAll("(国语版|国配版|普通话版|普通話版|粤语版|粵語版|台语版|台語版|闽南语版|閩南語版|原声版|原聲版|配音版|中字版|字幕版|台版|台湾版|台灣版|港版|大陆版|大陸版|内地版|內地版|中国版|中國版|泰版|韩版|韓版|日版|美版|英版)", " "); - clean = clean.replaceAll("(真彩|臻彩|高码|高码率|高碼|高碼率|无水印|無水印|无台标|無台標|国语|國語|国配|國配|国粤|國粵|粤语|粵語|中字|字幕|内封|內封|简繁|簡繁|双语|雙語|官中|杜比|合集|全集|完结|完結|未删减|未刪減|加长版|加長版|修复版|修復版|防和谐版|防和諧版|防和谐|防和諧)", " "); + clean = clean.replaceAll("(更新至|更至|连载至)\\s*$", " "); + clean = clean.replaceAll("(国语版|国配版|普通话版|粤语版|台语版|闽南语版|原声版|配音版|中字版|字幕版|台版|台湾版|港版|大陆版|内地版|中国版|泰版|韩版|日版|美版|英版)", " "); + clean = clean.replaceAll("(真彩|臻彩|高码|高码率|无水印|无台标|国语|国配|国粤|粤语|中字|字幕|内封|简繁|双语|官中|杜比|合集|全集|完结|未删减|加长版|修复版|防和谐版|防和谐)", " "); clean = clean.replaceAll("[##]+", ""); clean = clean.replaceAll("(?i)(^|\\s)(动漫|动画|电视剧|剧集|电影|综艺)(\\s|$)", " "); clean = clean.replaceAll("[._\\-+]+", " "); @@ -159,62 +126,12 @@ public final class MediaTitleParser { return cleanTitle(text).replaceAll("[\\s·•::\\-_/\\\\|()()\\[\\]【】]+", "").toLowerCase(Locale.ROOT); } - private List contextTitles(MediaTitleRequest request) { - List result = new ArrayList<>(); - for (String raw : request.getContextTitles()) { - List candidates = cleanSearchTitles(MediaTitleRequest.builder().rawTitle(raw).build()); - if (!candidates.isEmpty()) result.add(candidates.get(0)); - } - return result; - } - - private String contextConsensusTitle(List titles) { - if (titles.isEmpty()) return ""; - String best = ""; - int bestCount = 0; - for (String title : titles) { - int count = 0; - String normalized = normalizeSearchText(title); - for (String candidate : titles) if (normalized.equals(normalizeSearchText(candidate))) count++; - if (count > bestCount) { - best = title; - bestCount = count; - } - } - return best; - } - - private float contextConfidence(List titles, String ruleTitle, String consensusTitle) { - if (titles.isEmpty() || consensusTitle.isEmpty()) return 0f; - int matches = 0; - String consensus = normalizeSearchText(consensusTitle); - for (String title : titles) if (consensus.equals(normalizeSearchText(title))) matches++; - float consensusScore = matches / (float) titles.size(); - double ruleScore = similarity(normalizeSearchText(ruleTitle), consensus); - if (titles.size() == 1 && ruleScore < 0.9d) return 0.65f; - return (float) Math.min(1d, Math.max(consensusScore, ruleScore)); - } - - private String ruleSource(String raw, String ruleTitle) { - if (isBlank(ruleTitle)) return "empty"; - List rules = new ArrayList<>(); - if (EPISODE_PATTERN.matcher(raw == null ? "" : raw).find()) rules.add("episode"); - if (SEASON_PATTERN.matcher(raw == null ? "" : raw).find()) rules.add("season"); - if (YEAR_PATTERN.matcher(raw == null ? "" : raw).find()) rules.add("year"); - if (NOISE_WORDS.matcher(raw == null ? "" : raw).find() || FRAME_RATE_PATTERN.matcher(raw == null ? "" : raw).find()) rules.add("media-noise"); - if (BRACKET_PATTERN.matcher(raw == null ? "" : raw).find()) rules.add("bracket"); - if (SUSPICIOUS_MARKERS.matcher(raw == null ? "" : raw).find()) rules.add("obfuscation"); - return rules.isEmpty() ? "plain" : String.join("+", rules); - } - private void addCleanSearchTitles(List result, String text) { if (isBlank(text)) return; String source = normalizePlaybackName(URL_PATTERN.matcher(text.trim()).replaceAll(" ")); Matcher book = BOOK_TITLE_PATTERN.matcher(source); while (book.find()) addCleanSearchTitle(result, book.group(1)); - addCleanSearchTitle(result, mainTitleWithoutCategoryLabels(source)); - boolean bracketTitle = addCleanSearchTitle(result, bestBracketTitle(source)); if (bracketTitle && looksLikeBracketReleaseName(source)) return; @@ -363,7 +280,6 @@ public final class MediaTitleParser { String normalized = normalize(value); if (normalized.length() < 2) return false; if (normalized.matches("(?i)(?:更新至|更至|连载至|全集|合集|完结|电影|电视剧|剧集|动漫|动画|综艺|tv)")) return false; - if (value.matches("(?i)^(?:更新至|更至|连载至|全|共)?\\s*[0-9零〇一二三四五六七八九十两百]+\\s*[集话話回期章节節](?:完结|更新中)?$")) return false; return !value.matches("^第\\s*[0-9零〇一二三四五六七八九十两百]+\\s*[集话話回期章节節].*"); } @@ -421,81 +337,7 @@ public final class MediaTitleParser { String value = normalize(text); if (value.isEmpty()) return true; return NOISE_WORDS.matcher(text).find() - || value.matches(".*(真彩|臻彩|高码|高碼|无水印|無水印|无台标|無台標|国语|國語|国配|國配|国粤|國粵|粤语|粵語|中字|字幕|内封|內封|简繁|簡繁|双语|雙語|官中|杜比|合集|全集|完结|完結|未删减|未刪減|加长版|加長版|修复版|修復版|完整版|未剪辑|未剪輯|双字|雙字|国英|國英|中英|票房).*"); - } - - /** - * 括号里是「电影」「美国 恐怖 奇幻」这类分类标签时,真实片名在括号外。 - * 弹幕服务按整串匹配,带标签的串一律搜不到,所以这里优先把所有括号剥掉只留主干。 - * 但全剥会误伤片名本身写在括号里的标题,因此按三档依次回退: - * 第一档|全剥:删掉所有括号内容,仅在至少一个括号是分类标签时才参与比较, - * 对应「[电影] 小丑回魂:德里故事 (2026) [...] [美国 恐怖 奇幻]」这类片名在括号外的标题; - * 第二档|只留片名括号:仅分类标签括号被删,剩下的非标签括号里只有片名该进标题, - * 对应「[美国][恐怖][我的电影].mkv」这类片名写在括号里的标题,否则全剥会把它删成近乎空串; - * 第三档|两档都不可用则返回空串,保持调用方 addCleanSearchTitles 的既有语义, - * bestBracketTitle 候选仍在,[恐怖].mkv 这类不会因此丢候选。 - */ - private String mainTitleWithoutCategoryLabels(String text) { - // 先摘掉扩展名:否则「[美国][恐怖][我的电影].mkv」全剥后只剩「.mkv」, - // 经 effectiveSearchTitle 会变成可用标题「mkv」,第二档就永远轮不到。 - String source = text == null ? "" : EXTENSION_PATTERN.matcher(text).replaceAll(" ").trim(); - if (!hasCategoryLabel(source)) return ""; - String all = stripAllBrackets(source); - if (isUsableCleanSearchTitle(effectiveSearchTitle(all))) return all; - return lastUsableBracketTitle(source); - } - - /** - * 第二档不能整串返回:effectiveSearchTitle 只删括号字符,括号里的文字会带空格拼进标题, - * 「[北美票房][美国][阿凡达]」因此会得到「北美票房 阿凡达」。所以这里在多个括号里取最靠后的 - * 可用括号;但片名后面还可能挂着发行噪声,最靠后的括号就不再是片名,因此跳过两类括号: - * 一|分类标签括号,判据 isCategoryLabel,如「[美国]」「[恐怖]」; - * 二|发行噪声括号,判据 isNoiseTag,如「[完整版]」「[未剪辑]」「[双字]」「[北美票房]」。 - */ - private String lastUsableBracketTitle(String text) { - Matcher matcher = BRACKET_PATTERN.matcher(text == null ? "" : text); - String best = ""; - while (matcher.find()) { - if (isCategoryLabel(matcher.group(1))) continue; - if (isNoiseTag(matcher.group(1))) continue; - if (!isUsableCleanSearchTitle(effectiveSearchTitle(matcher.group(1)))) continue; - best = matcher.group(); - } - return best; - } - - private boolean hasCategoryLabel(String text) { - Matcher matcher = BRACKET_PATTERN.matcher(text == null ? "" : text); - while (matcher.find()) if (isCategoryLabel(matcher.group(1))) return true; - return false; - } - - /** 第一档:删掉所有括号内容,只留括号外的主干。 */ - private String stripAllBrackets(String text) { - return BRACKET_PATTERN.matcher(text == null ? "" : text).replaceAll(" ").trim(); - } - - private boolean isCategoryLabel(String text) { - String value = text == null ? "" : text.trim(); - if (value.isEmpty()) return false; - boolean labeled = false; - for (String token : value.split("[\\s,,、/|·]+")) { - if (token.isEmpty()) continue; - if (!isCategoryLabelToken(token)) return false; - labeled = true; - } - // 只有分隔符(如 "[//]")时一个 token 都没命中,不能算分类标签。 - return labeled; - } - - /** 判的是「电影」「美国」「恐怖」这类分类词;与上面的 looksLikeGroupToken(a-z1-4、短码、tv|bd 这类发布组标记)不是一回事。 */ - private boolean isCategoryLabelToken(String token) { - if (CATEGORY_LABELS.contains(token)) return true; - int length = token.length(); - if (length < 2) return false; - String head = token.substring(0, length - 1); - char tail = token.charAt(length - 1); - return (tail == '剧' || tail == '劇' || tail == '片' || tail == '番') && CATEGORY_LABELS.contains(head); + || value.matches(".*(真彩|臻彩|高码|无水印|无台标|国语|国配|国粤|粤语|中字|字幕|内封|简繁|双语|官中|杜比|合集|全集|完结|未删减|加长版|修复版).*"); } private int inferTrailingSeason(String raw) { diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleRequest.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleRequest.java index 6247ec47e4..f88d388a9f 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleRequest.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleRequest.java @@ -6,8 +6,6 @@ import java.util.List; public final class MediaTitleRequest { - private static final int MAX_CONTEXT_TITLES = 16; - private String siteKey; private String vodId; private String rawTitle; @@ -17,10 +15,6 @@ public final class MediaTitleRequest { private String episodeName; private String flag; private String source; - private String folderName; - private List contextTitles; - private int tmdbId; - private int tmdbSeasonNumber; private List learningExamples; private boolean allowAi; @@ -34,10 +28,6 @@ public final class MediaTitleRequest { this.episodeName = clean(builder.episodeName); this.flag = clean(builder.flag); this.source = clean(builder.source); - this.folderName = cleanFolderName(builder.folderName); - this.contextTitles = cleanContextTitles(builder.contextTitles); - this.tmdbId = builder.tmdbId; - this.tmdbSeasonNumber = builder.tmdbSeasonNumber; this.learningExamples = new ArrayList<>(builder.learningExamples); this.allowAi = builder.allowAi; } @@ -82,29 +72,6 @@ public final class MediaTitleRequest { return source; } - /** - * Returns only the final directory component. Title recognition must not - * send a complete local path or URL to an AI provider. - */ - public String getFolderName() { - return folderName; - } - - /** - * Returns bounded, de-duplicated sibling/context titles in caller order. - */ - public List getContextTitles() { - return Collections.unmodifiableList(contextTitles); - } - - public int getTmdbId() { - return tmdbId; - } - - public int getTmdbSeasonNumber() { - return tmdbSeasonNumber; - } - public List getLearningExamples() { return Collections.unmodifiableList(learningExamples); } @@ -128,10 +95,6 @@ public final class MediaTitleRequest { private String episodeName; private String flag; private String source; - private String folderName; - private List contextTitles = new ArrayList<>(); - private int tmdbId; - private int tmdbSeasonNumber; private final List learningExamples = new ArrayList<>(); private boolean allowAi; @@ -180,27 +143,6 @@ public final class MediaTitleRequest { return this; } - public Builder folderName(String folderName) { - this.folderName = folderName; - return this; - } - - public Builder contextTitles(List contextTitles) { - this.contextTitles.clear(); - if (contextTitles != null) this.contextTitles.addAll(contextTitles); - return this; - } - - public Builder tmdbId(int tmdbId) { - this.tmdbId = tmdbId; - return this; - } - - public Builder tmdbSeasonNumber(int tmdbSeasonNumber) { - this.tmdbSeasonNumber = tmdbSeasonNumber; - return this; - } - public Builder learningExamples(List values) { learningExamples.clear(); if (values != null) for (MediaTitleLearningExample value : values) if (value != null) learningExamples.add(value); @@ -216,32 +158,4 @@ public final class MediaTitleRequest { return new MediaTitleRequest(this); } } - - private static String cleanFolderName(String value) { - String text = clean(value).replace('\\', '/'); - while (text.length() > 1 && text.endsWith("/")) text = text.substring(0, text.length() - 1); - int slash = text.lastIndexOf('/'); - if (slash >= 0) text = text.substring(slash + 1); - return text.trim(); - } - - private static List cleanContextTitles(List values) { - List result = new ArrayList<>(); - if (values == null) return result; - for (String value : values) { - String text = clean(value).replaceAll("(?i)https?://\\S+", " ").trim(); - String pathText = text.replace('\\', '/'); - int slash = pathText.lastIndexOf('/'); - if (slash >= 0) text = pathText.substring(slash + 1).trim(); - if (text.isEmpty() || text.length() > 160) continue; - boolean exists = false; - for (String item : result) if (item.equalsIgnoreCase(text)) { - exists = true; - break; - } - if (!exists) result.add(text); - if (result.size() >= MAX_CONTEXT_TITLES) break; - } - return result; - } } diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolution.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolution.java index 6f34ed79f4..098419a13e 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolution.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolution.java @@ -25,13 +25,7 @@ public final class MediaTitleResolution { private List aliases = new ArrayList<>(); private List candidates = new ArrayList<>(); private float confidence; - private float contextConfidence; - private int contextGroupSize; private String source; - private String ruleSource; - private String aiReasonCode; - private String confidenceBreakdown; - private String degradationReason; private boolean needsVerification; public String getRawTitle() { @@ -136,23 +130,6 @@ public final class MediaTitleResolution { this.confidence = Float.isNaN(confidence) || confidence < 0f ? 0f : Math.min(1f, confidence); } - public float getContextConfidence() { - if (Float.isNaN(contextConfidence) || contextConfidence < 0f) return 0f; - return Math.min(1f, contextConfidence); - } - - public void setContextConfidence(float contextConfidence) { - this.contextConfidence = Float.isNaN(contextConfidence) || contextConfidence < 0f ? 0f : Math.min(1f, contextConfidence); - } - - public int getContextGroupSize() { - return Math.max(0, contextGroupSize); - } - - public void setContextGroupSize(int contextGroupSize) { - this.contextGroupSize = Math.max(0, contextGroupSize); - } - public String getSource() { return clean(source); } @@ -161,38 +138,6 @@ public final class MediaTitleResolution { this.source = clean(source); } - public String getRuleSource() { - return clean(ruleSource); - } - - public void setRuleSource(String ruleSource) { - this.ruleSource = clean(ruleSource); - } - - public String getAiReasonCode() { - return clean(aiReasonCode); - } - - public void setAiReasonCode(String aiReasonCode) { - this.aiReasonCode = clean(aiReasonCode); - } - - public String getConfidenceBreakdown() { - return clean(confidenceBreakdown); - } - - public void setConfidenceBreakdown(String confidenceBreakdown) { - this.confidenceBreakdown = clean(confidenceBreakdown); - } - - public String getDegradationReason() { - return clean(degradationReason); - } - - public void setDegradationReason(String degradationReason) { - this.degradationReason = clean(degradationReason); - } - public boolean isNeedsVerification() { return needsVerification; } diff --git a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolver.java b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolver.java index 07d8b3a93e..e15485ef79 100644 --- a/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolver.java +++ b/app/src/main/java/com/fongmi/android/tv/title/MediaTitleResolver.java @@ -1,16 +1,13 @@ package com.fongmi.android.tv.title; import com.fongmi.android.tv.bean.AiConfig; -import com.fongmi.android.tv.bean.TmdbConfig; import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.fongmi.android.tv.service.AiTitleExtractionService; import com.fongmi.android.tv.setting.Setting; import com.github.catvod.crawler.SpiderDebug; import java.util.ArrayList; import java.util.List; -import java.util.Locale; public final class MediaTitleResolver { @@ -41,11 +38,9 @@ public final class MediaTitleResolver { MediaTitleResolution resolution = parser.parse(safe); if (!forceAi) applyTmdbCache(safe, resolution); String skipReason = aiSkipReason(safe, resolution, forceAi); - SpiderDebug.log("ai-title", "rule source=%s ruleSource=%s raw=%s canonical=%s confidence=%.2f context=%.2f/%d force=%s breakdown=%s degradation=%s candidates=%s", - resolution.getSource(), resolution.getRuleSource(), safe.getRawTitle(), resolution.getCanonicalTitle(), resolution.getConfidence(), - resolution.getContextConfidence(), resolution.getContextGroupSize(), forceAi, resolution.getConfidenceBreakdown(), resolution.getDegradationReason(), resolution.queryTitles()); + SpiderDebug.log("ai-title", "rule source=%s raw=%s canonical=%s confidence=%.2f force=%s candidates=%s", + resolution.getSource(), safe.getRawTitle(), resolution.getCanonicalTitle(), resolution.getConfidence(), forceAi, resolution.queryTitles()); if (!skipReason.isEmpty()) { - if (resolution.getDegradationReason().isEmpty()) resolution.setDegradationReason(skipReason); SpiderDebug.log("ai-title", "skip title-extraction reason=%s raw=%s canonical=%s allowAi=%s enabled=%s ready=%s", skipReason, safe.getRawTitle(), resolution.getCanonicalTitle(), safe.isAllowAi(), Setting.isAiTitleExtraction(), Setting.isAiConfigReady()); return resolution; @@ -58,15 +53,7 @@ public final class MediaTitleResolver { ai = new AiTitleExtractionService(config).extract(safe, resolution); if (ai != null) cache.write(safe, config, ai); } - if (ai == null) { - resolution.setDegradationReason("ai-unavailable"); - return resolution; - } - if (!forceAi && ai.getConfidence() < 0.55f) { - resolution.setDegradationReason("ai-low-confidence"); - return resolution; - } - return merge(resolution, ai); + return ai == null || (!forceAi && ai.getConfidence() < 0.55f) ? resolution : merge(resolution, ai); } public List queryTitles(MediaTitleRequest request, int limit) { @@ -106,8 +93,6 @@ public final class MediaTitleResolver { List examples = new ArrayList<>(safe.getLearningExamples()); examples.addAll(MediaTitleLearningStore.load().find(safe, 5)); examples.addAll(Setting.getDanmakuMatchCache().learningExamples(parser.cleanTitle(safe.getRawTitle()))); - List contextTitles = new ArrayList<>(safe.getContextTitles()); - addContextTitle(contextTitles, safe.getSearchKeyword(), safe.getRawTitle()); return MediaTitleRequest.builder() .siteKey(safe.getSiteKey()) .vodId(safe.getVodId()) @@ -118,31 +103,19 @@ public final class MediaTitleResolver { .episodeName(safe.getEpisodeName()) .flag(safe.getFlag()) .source(safe.getSource()) - .folderName(safe.getFolderName()) - .contextTitles(contextTitles) - .tmdbId(safe.getTmdbId()) - .tmdbSeasonNumber(safe.getTmdbSeasonNumber()) .allowAi(safe.isAllowAi()) .learningExamples(examples) .build(); } - private void addContextTitle(List values, String candidate, String rawTitle) { - if (candidate == null || candidate.trim().isEmpty() || candidate.equalsIgnoreCase(rawTitle)) return; - for (String value : values) if (value.equalsIgnoreCase(candidate)) return; - values.add(candidate); - } - private void applyTmdbCache(MediaTitleRequest request, MediaTitleResolution resolution) { - TmdbItem item = Setting.getTmdbMatchCache().find(request.getSiteKey(), request.getVodId(), request.getRawTitle(), TmdbLanguagePolicy.requestLanguage(TmdbConfig.effectiveCurrent(), Setting.getLanguage())); + TmdbItem item = Setting.getTmdbMatchCache().find(request.getSiteKey(), request.getVodId(), request.getRawTitle()); if (item == null || item.getTitle().isEmpty()) return; if (!isTmdbCacheCompatible(item, resolution)) return; resolution.setCanonicalTitle(item.getTitle()); resolution.setMediaType(item.getMediaType()); resolution.setConfidence(0.95f); resolution.setSource(MediaTitleResolution.SOURCE_TMDB_CACHE); - resolution.setDegradationReason(""); - resolution.setConfidenceBreakdown(resolution.getConfidenceBreakdown() + ",tmdb=1.00,final=0.95"); resolution.addCandidate(MediaTitleCandidate.of(item.getTitle(), MediaTitleCandidate.SOURCE_TMDB, 0.96f)); } @@ -173,10 +146,6 @@ public final class MediaTitleResolver { merged.addCandidate(MediaTitleCandidate.of(rule.getCanonicalTitle(), MediaTitleCandidate.SOURCE_RULE, Math.min(0.75f, rule.getConfidence()))); merged.addCandidate(MediaTitleCandidate.of(rule.getRuleTitle(), MediaTitleCandidate.SOURCE_RULE, Math.min(0.7f, rule.getConfidence()))); if (!rule.getRawTitle().equals(rule.getRuleTitle())) merged.addCandidate(MediaTitleCandidate.of(rule.getRawTitle(), MediaTitleCandidate.SOURCE_RAW, 0.25f)); - String breakdown = rule.getConfidenceBreakdown(); - if (!breakdown.isEmpty()) breakdown += ","; - merged.setConfidenceBreakdown(breakdown + String.format(Locale.ROOT, "ai=%.2f,final=%.2f", ai.getConfidence(), merged.getConfidence())); - merged.setDegradationReason(""); return merged; } @@ -192,13 +161,7 @@ public final class MediaTitleResolver { result.setEpisodeNumber(source.getEpisodeNumber()); result.setEpisodeTitle(source.getEpisodeTitle()); result.setConfidence(source.getConfidence()); - result.setContextConfidence(source.getContextConfidence()); - result.setContextGroupSize(source.getContextGroupSize()); result.setSource(source.getSource()); - result.setRuleSource(source.getRuleSource()); - result.setAiReasonCode(source.getAiReasonCode()); - result.setConfidenceBreakdown(source.getConfidenceBreakdown()); - result.setDegradationReason(source.getDegradationReason()); result.setNeedsVerification(source.isNeedsVerification()); for (String alias : source.getAliases()) result.addAlias(alias); for (MediaTitleCandidate candidate : source.getCandidates()) result.addCandidate(candidate); diff --git a/app/src/main/java/com/fongmi/android/tv/tts/AudioFileTtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/AudioFileTtsEngine.java deleted file mode 100644 index acf3b10eeb..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/AudioFileTtsEngine.java +++ /dev/null @@ -1,391 +0,0 @@ -package com.fongmi.android.tv.tts; - -import android.media.MediaPlayer; -import android.os.Handler; -import android.os.Looper; - -import com.github.catvod.crawler.SpiderDebug; - -import java.io.File; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * 「按片段合成音频 → 顺序播放」的在线朗读基类。 - * - * 对齐 legado HttpReadAloudService 的关键行为,但按 WebHTV 的规模做了收敛: - * - 每个片段一次请求,结果按 MD5 落盘缓存({@link TtsAudioCache}),命中即直接播放; - * - 顺序预取后面 {@link #PREFETCH} 个片段,避免「读完一句等下一句」; - * - 播放用 {@link MediaPlayer}(片段是几秒到几十秒的 mp3,不需要 media3 的缓存/下载体系); - * - 单片段失败重试 {@link #RETRY} 次后跳过该片段;连续 {@link #MAX_CONSECUTIVE_ERRORS} - * 个片段失败则判定引擎不可用(fatal),避免坏引擎无限空转。 - */ -abstract class AudioFileTtsEngine implements TtsEngine { - - private static final String TAG = "TV-tts"; - private static final int PREFETCH = 3; - private static final int RETRY = 2; - private static final int MAX_CONSECUTIVE_ERRORS = 3; - /** 同一片段最多容忍的播放失败次数(缓存损坏重下后仍失败就跳过)。 */ - private static final int MAX_PLAY_ERRORS = 2; - - private final Handler main = new Handler(Looper.getMainLooper()); - private final ExecutorService fetchExecutor = Executors.newSingleThreadExecutor(r -> { - Thread thread = new Thread(r, "reader-tts-fetch"); - thread.setDaemon(true); - return thread; - }); - private final AtomicInteger generation = new AtomicInteger(); - private final java.util.Map playErrorCounts = new java.util.concurrent.ConcurrentHashMap<>(); - - /** - * 缓存目录必须懒建:{@code id()} 由子类实现,而子类字段(如自定义引擎的 id)在 - * 父类构造期间还没赋值,在构造函数里取 id() 会拿到 null 并把所有引擎的缓存 - * 混进同一个 default 目录。 - */ - private TtsAudioCache cache; - - protected Listener listener; - protected TtsOptions options = new TtsOptions(1f, 1f, ""); - - /** - * 最近一次合成失败的原因(HTTP 状态码/Content-Type/错误体片段/异常信息)。 - * 在线朗读最容易被吐槽的就是「点了没声音却不知道哪里错」,因此把原因带进 UI 提示。 - */ - protected volatile String lastFetchError = ""; - - private List chunks = Collections.emptyList(); - private MediaPlayer player; - private int current = -1; - private int consecutiveErrors; - private boolean playing; - private boolean paused; - private boolean released; - private String preparedPath = ""; - - AudioFileTtsEngine() { - } - - /** 合成一个片段到目标文件;返回 false 表示失败。实现必须阻塞直到完成或失败。 */ - protected abstract boolean fetchChunk(String text, File target); - - /** 缓存键文本:引擎参数(音色/语速)必须参与,否则换音色会播到旧音频。 */ - protected abstract String cacheKey(String text); - - protected TtsAudioCache cache() { - TtsAudioCache local = cache; - if (local == null) { - synchronized (playErrorCounts) { - if (cache == null) cache = new TtsAudioCache(TtsEngines.cacheRoot(), id()); - local = cache; - } - } - return local; - } - - protected Handler mainHandler() { - return main; - } - - @Override - public List voices() { - return Collections.emptyList(); - } - - @Override - public void prepare(TtsOptions opts, Listener l) { - this.listener = l; - if (opts != null) this.options = opts; - } - - @Override - public void speak(List list, int fromIndex) { - if (released) return; - chunks = list == null ? Collections.emptyList() : list; - if (chunks.isEmpty()) { - if (listener != null) listener.onTtsQueueDone(); - return; - } - releasePlayer(); - playing = true; - paused = false; - consecutiveErrors = 0; - playErrorCounts.clear(); - generation.incrementAndGet(); - int start = Math.max(0, Math.min(fromIndex, chunks.size() - 1)); - playFrom(start); - } - - @Override - public void pause() { - if (!playing || paused) return; - paused = true; - try { - if (player != null && player.isPlaying()) player.pause(); - } catch (Throwable e) { - SpiderDebug.log(TAG, "pause failed %s", e.getMessage()); - } - } - - @Override - public void resume() { - if (!playing || !paused) return; - paused = false; - MediaPlayer mp = player; - if (mp != null) { - try { - mp.start(); - // 暂停期间改过语速的话在此补上:平台文档说明已 prepared 的播放器 - // 用非零速度调 setPlaybackParams 等价于 start(),因此不会重置进度 - applyRate(mp); - return; - } catch (Throwable e) { - SpiderDebug.log(TAG, "resume failed, replay current %s", e.getMessage()); - } - } - playFrom(Math.max(0, current)); - } - - @Override - public void stop() { - playing = false; - paused = false; - generation.incrementAndGet(); - releasePlayer(); - } - - @Override - public void setRate(float rate) { - options = options.withRate(rate); - MediaPlayer mp = player; - // 暂停中不能直接改播放器速率:平台文档明确「已 prepared 的播放器用非零速度 - // 调 setPlaybackParams 等价于 start()」,会让界面显示「已暂停」时突然出声。 - // 暂停期间只更新 options,恢复播放时由 resume() 补上(后续片段也会走新语速)。 - if (mp != null && !paused) applyRate(mp); - } - - /** 把当前语速应用到已 prepared 的播放器。 */ - private void applyRate(MediaPlayer mp) { - try { - android.media.PlaybackParams params = mp.getPlaybackParams(); - params.setSpeed(options.rate); - mp.setPlaybackParams(params); - } catch (Throwable e) { - SpiderDebug.log(TAG, "setRate failed %s", e.getMessage()); - } - } - - @Override - public void setPitch(float pitch) { - options = options.withPitch(pitch); - } - - @Override - public void setVoice(String voiceId) { - options = options.withVoice(voiceId); - } - - @Override - public void release() { - released = true; - stop(); - fetchExecutor.shutdownNow(); - } - - protected boolean isReleased() { - return released; - } - - private void playFrom(int index) { - if (!playing || released) return; - if (index >= chunks.size()) { - playing = false; - if (listener != null) listener.onTtsQueueDone(); - return; - } - current = index; - final int gen = generation.get(); - String text = chunks.get(index).text; - fetch(index, text, gen, file -> { - if (!playing || generation.get() != gen) return; - if (file == null) { - // 合成失败:记为失败并跳过,连续失败则判定引擎不可用 - consecutiveErrors++; - if (listener != null) { - listener.onTtsError(index, "在线语音合成失败:" + lastFetchError + ",已跳过该段", - consecutiveErrors >= MAX_CONSECUTIVE_ERRORS); - } - if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { - playing = false; - return; - } - main.post(() -> playFrom(index + 1)); - return; - } - consecutiveErrors = 0; - startPlayer(file, index, gen); - prefetch(index + 1, gen); - }); - } - - /** 取缓存或合成;回调一定在主线程执行。 */ - private void fetch(int index, String text, int gen, FetchResult callback) { - File file = cache().file(id(), cacheKey(text)); - if (cache().has(file)) { - main.post(() -> { - if (generation.get() == gen) callback.onResult(file); - }); - return; - } - fetchExecutor.execute(() -> { - if (released || generation.get() != gen) return; - boolean ok = false; - File target = cache().file(id(), cacheKey(text)); - for (int attempt = 0; attempt <= RETRY && !ok; attempt++) { - if (released || generation.get() != gen) return; - try { - ok = fetchChunk(text, target) && cache().has(target); - } catch (Throwable e) { - SpiderDebug.log(TAG, "fetch failed engine=%s attempt=%d %s", id(), attempt, e.getMessage()); - ok = false; - } - if (!ok && attempt < RETRY) { - try { - Thread.sleep(400L * (attempt + 1)); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - return; - } - } - } - final boolean success = ok; - main.post(() -> { - if (generation.get() == gen) callback.onResult(success ? target : null); - }); - }); - } - - /** 顺序预取后续片段,填满缓存窗口。 */ - private void prefetch(int fromIndex, int gen) { - if (!playing || released) return; - for (int i = fromIndex; i < Math.min(chunks.size(), fromIndex + PREFETCH); i++) { - final int index = i; - String text = chunks.get(index).text; - File file = cache().file(id(), cacheKey(text)); - if (cache().has(file)) continue; - fetchExecutor.execute(() -> { - if (released || generation.get() != gen || !playing) return; - File target = cache().file(id(), cacheKey(text)); - try { - if (!cache().has(target)) fetchChunk(text, target); - } catch (Throwable e) { - SpiderDebug.log(TAG, "prefetch failed index=%d %s", index, e.getMessage()); - } - }); - } - } - - private void startPlayer(File file, int index, int gen) { - releasePlayer(); - try { - MediaPlayer mp = new MediaPlayer(); - player = mp; - preparedPath = file.getAbsolutePath(); - mp.setDataSource(preparedPath); - mp.setOnPreparedListener(prepared -> { - if (!playing || generation.get() != gen) { - releasePlayer(); - return; - } - try { - android.media.PlaybackParams params = prepared.getPlaybackParams(); - params.setSpeed(options.rate); - prepared.setPlaybackParams(params); - } catch (Throwable e) { - SpiderDebug.log(TAG, "apply rate failed %s", e.getMessage()); - } - if (paused) return; - prepared.start(); - if (listener != null) listener.onTtsStart(index, chunks.get(index).text); - }); - mp.setOnCompletionListener(completed -> { - if (generation.get() != gen) return; - if (listener != null) listener.onTtsChunkDone(index); - main.post(() -> playFrom(index + 1)); - }); - mp.setOnErrorListener((errored, what, extra) -> { - SpiderDebug.log(TAG, "player error index=%d what=%d extra=%d", index, what, extra); - // 缓存文件可能损坏:删掉缓存重下;同一片段最多重试两次, - // 否则「下到坏音频 → 播放失败 → 再下」会无限循环。 - File cached = new File(preparedPath); - //noinspection ResultOfMethodCallIgnored - cached.delete(); - if (generation.get() != gen) return true; - int attempts = playErrorCounts.merge(index, 1, Integer::sum); - main.post(() -> { - if (attempts <= MAX_PLAY_ERRORS) { - playFrom(index); - } else { - playErrorCounts.remove(index); - if (listener != null) { - listener.onTtsError(index, "音频播放失败,已跳过该段", false); - } - playFrom(index + 1); - } - }); - return true; - }); - mp.prepareAsync(); - } catch (Throwable e) { - SpiderDebug.log(TAG, "startPlayer failed index=%d %s", index, e.getMessage()); - if (listener != null) listener.onTtsError(index, "音频播放失败:" + e.getMessage(), false); - main.post(() -> playFrom(index + 1)); - } - } - - private void releasePlayer() { - MediaPlayer mp = player; - player = null; - preparedPath = ""; - if (mp == null) return; - try { - mp.setOnPreparedListener(null); - mp.setOnCompletionListener(null); - mp.setOnErrorListener(null); - mp.reset(); - } catch (Throwable ignore) { - } - try { - mp.release(); - } catch (Throwable ignore) { - } - } - - private interface FetchResult { - void onResult(File file); - } - - /** 便捷方法:把输入流写入指定文件(供实现类复用)。 */ - protected static boolean storeStream(File target, java.io.InputStream in) { - try (java.io.InputStream stream = in; - java.io.OutputStream out = new java.io.FileOutputStream(target)) { - byte[] buf = new byte[8192]; - long total = 0; - int n; - while ((n = stream.read(buf)) > 0) { - total += n; - if (total > 8L * 1024 * 1024) throw new IllegalStateException("audio too large"); - out.write(buf, 0, n); - } - out.flush(); - return total > 0; - } catch (Throwable e) { - //noinspection ResultOfMethodCallIgnored - target.delete(); - return false; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/BaiduTtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/BaiduTtsEngine.java deleted file mode 100644 index feadb4d98d..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/BaiduTtsEngine.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.fongmi.android.tv.tts; - -/** - * 百度在线语音(翻译发音接口,免 Key)。 - * - * 接口:{@code https://fanyi.baidu.com/gettts?lan=zh&text=…&spd=…&source=web} - * 实测(2026-10-09,测试设备):HTTP 200 + {@code audio/mpeg};{@code spd} 有效区间 1..7, - * 超出范围会返回空响应,因此 {@link HttpTtsEngine#speakSpeed(float)} 做了 1..7 夹取。 - * - * 选择它作为内置轻量音源的原因:单句延迟低、无需注册、对中文长句稳定; - * 音色质量不如微软 Neural,故保留 Edge 音源供用户选择。 - */ -final class BaiduTtsEngine extends HttpTtsEngine { - - private static final String RULE = - "https://fanyi.baidu.com/gettts?lan=zh&text={{java.encodeURI(speakText)}}&spd={{speakSpeed}}&source=web"; - - BaiduTtsEngine() { - super(config(), TtsEngines.SOURCE_BAIDU); - } - - private static TtsEngineConfig config() { - TtsEngineConfig c = new TtsEngineConfig(); - c.name = "百度在线语音"; - c.url = RULE; - c.contentType = "audio/mpeg"; - return c; - } - - @Override - public String detail() { - return "百度在线语音(免配置,依赖网络)"; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/EdgeTtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/EdgeTtsEngine.java deleted file mode 100644 index fecdcb6b2f..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/EdgeTtsEngine.java +++ /dev/null @@ -1,427 +0,0 @@ -package com.fongmi.android.tv.tts; - -import android.os.SystemClock; - -import com.github.catvod.crawler.SpiderDebug; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.TimeZone; -import java.util.UUID; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.WebSocket; -import okhttp3.WebSocketListener; -import okio.ByteString; - -/** - * 微软 Edge「大声朗读」引擎(免 Key,Neural 音色)。 - * - * 协议参考社区实现 {@code wangz-code/legado-edge-tts} 的 {@code EdgeSpeakFetch.kt}: - * 1) 连接 {@code wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1}, - * 查询参数带 {@code ConnectionId}、{@code Sec-MS-GEC}、{@code Sec-MS-GEC-Version} 与 - * {@code TrustedClientToken}; - * 2) {@code Sec-MS-GEC} = SHA-256(「Windows epoch 秒数按 300 秒取整 ×10^7」+ TrustedClientToken) 的大写十六进制; - * 3) 先发 {@code speech.config} 文本帧,再发 {@code ssml} 文本帧; - * 4) 服务端以二进制帧返回音频(前 2 字节大端为 header 长度,header 含 {@code X-RequestId}), - * 以 {@code turn.end} 文本帧结束本轮。 - * - * 与参考实现的差异:这里按 {@code X-RequestId} 匹配每个待合成片段(参考实现靠「最近一次连接」 - * 复用,句子并发时会串音),并复用同一条 WebSocket 顺序合成,避免每句重新握手带来的首句延迟。 - * - * 风险:该协议非公开稳定接口,{@code Sec-MS-GEC-Version} 需与 Edge 客户端版本常量同步, - * 上游可能调整。因此本引擎只作为可选音源,失败时由控制器回退/提示,不影响系统音源可用性。 - */ -final class EdgeTtsEngine extends AudioFileTtsEngine { - - private static final String TAG = "TV-tts"; - - private static final String TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"; - private static final String BASE_URL = "wss://speech.platform.bing.com"; - private static final String WSS_PATH = "/consumer/speech/synthesize/readaloud/edge/v1"; - static final String DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural"; - private static final String CHROMIUM_FULL_VERSION = "143.0.3650.75"; - private static final String SEC_MS_GEC_VERSION = "1-" + CHROMIUM_FULL_VERSION; - private static final long WIN_EPOCH_SECONDS = 11644473600L; - private static final String OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3"; - - private static final long CONNECT_TIMEOUT_MS = 12000L; - private static final long TURN_TIMEOUT_MS = 25000L; - - /** 内置中文音色(Neural),id 直接用于 SSML 的 voice name。 */ - static final String[][] VOICES = { - {"zh-CN-XiaoxiaoNeural", "晓晓 · 女声(通用)"}, - {"zh-CN-XiaoyiNeural", "晓伊 · 女声(年轻)"}, - {"zh-CN-YunxiNeural", "云希 · 男声(通用)"}, - {"zh-CN-YunjianNeural", "云健 · 男声(浑厚)"}, - {"zh-CN-YunxiaNeural", "云夏 · 童声"}, - {"zh-CN-YunyangNeural", "云扬 · 男声(播音)"}, - {"zh-CN-liaoning-XiaobeiNeural", "晓北 · 东北口音"}, - {"zh-CN-shaanxi-XiaoniNeural", "晓妮 · 陕西方言"}, - }; - - private static final OkHttpClient CLIENT = new OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(0, TimeUnit.MILLISECONDS) - .writeTimeout(15, TimeUnit.SECONDS) - .pingInterval(20, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .build(); - - private final Object lock = new Object(); - private final Map turns = new LinkedHashMap<>(); - - private WebSocket socket; - private boolean configSent; - private long lastFailureAt; - - private static final class Turn { - final String requestId; - final ByteArrayOutputStream audio = new ByteArrayOutputStream(16384); - final CountDownLatch done = new CountDownLatch(1); - volatile boolean success; - - Turn(String requestId) { - this.requestId = requestId; - } - } - - @Override - public String id() { - return TtsEngines.SOURCE_EDGE; - } - - @Override - public boolean available() { - return true; - } - - @Override - public String detail() { - return "微软 Edge 语音(Neural 音色,依赖网络)"; - } - - @Override - public List voices() { - List list = new ArrayList<>(VOICES.length); - for (String[] v : VOICES) list.add(new TtsVoice(v[0], v[1], "zh-CN")); - return list; - } - - @Override - protected String cacheKey(String text) { - return "edge|" + options.rate + "|" + TtsOptions.pitchOffsetHz(options.pitch) + "|" + voiceId() + "|" + text; - } - - private String voiceId() { - return options.voice == null || options.voice.isEmpty() ? DEFAULT_VOICE : options.voice; - } - - @Override - protected boolean fetchChunk(String text, File target) { - String clean = cleanText(text); - if (clean.isEmpty()) { - lastFetchError = "该片段没有可朗读内容"; - return false; - } - long elapsed = SystemClock.elapsedRealtime(); - if (lastFailureAt > 0 && elapsed - lastFailureAt < 5000L) { - // 刚失败过:等一小会儿再试,避免连接被拒时把重试全部打满 - try { - Thread.sleep(600); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } - } - Turn turn = null; - try { - if (!ensureConnected()) { - lastFetchError = "无法连接微软语音服务(网络或协议变更)"; - return false; - } - String requestId = connectId(); - turn = new Turn(requestId); - synchronized (lock) { - turns.put(requestId, turn); - } - if (!sendSsml(requestId, clean)) { - lastFetchError = "微软语音连接已断开"; - markFailed(); - return false; - } - if (!turn.done.await(TURN_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { - lastFetchError = "微软语音响应超时"; - SpiderDebug.log(TAG, "edge tts turn timeout"); - markFailed(); - return false; - } - if (!turn.success || turn.audio.size() == 0) { - lastFetchError = "微软语音没有返回音频"; - return false; - } - boolean stored = storeStream(target, new ByteArrayInputStream(turn.audio.toByteArray())); - if (!stored) { - lastFetchError = "音频写入缓存失败"; - SpiderDebug.log(TAG, "edge tts store failed bytes=%d", turn.audio.size()); - } - return stored; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } catch (Throwable e) { - lastFetchError = e.getClass().getSimpleName() + " " + (e.getMessage() == null ? "" : e.getMessage()); - SpiderDebug.log(TAG, "edge tts failed %s", e.getMessage()); - markFailed(); - return false; - } finally { - if (turn != null) { - synchronized (lock) { - turns.remove(turn.requestId); - } - } - } - } - - private boolean ensureConnected() throws InterruptedException { - WebSocket current; - synchronized (lock) { - current = socket; - } - if (current != null) return true; - CountDownLatch opened = new CountDownLatch(1); - String url = buildUrl(); - Request request = new Request.Builder().url(url) - .header("User-Agent", userAgent()) - .header("Accept-Encoding", "gzip, deflate, br, zstd") - .header("Accept-Language", "en-US,en;q=0.9") - .header("Pragma", "no-cache") - .header("Cache-Control", "no-cache") - .header("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold") - .build(); - WebSocket created = CLIENT.newWebSocket(request, new SocketListener(opened)); - if (!opened.await(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { - SpiderDebug.log(TAG, "edge tts connect timeout"); - try { - created.cancel(); - } catch (Throwable ignore) { - } - return false; - } - synchronized (lock) { - return socket != null; - } - } - - private String buildUrl() { - String connectionId = connectId(); - return String.format(Locale.US, "%s%s?ConnectionId=%s&Sec-MS-GEC=%s&Sec-MS-GEC-Version=%s&TrustedClientToken=%s", - BASE_URL, WSS_PATH, connectionId, secMsGec(), SEC_MS_GEC_VERSION, TRUSTED_CLIENT_TOKEN); - } - - private static String userAgent() { - String major = CHROMIUM_FULL_VERSION.split("\\.")[0]; - return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" - + major + ".0.0.0 Safari/537.36 Edg/" + major + ".0.0.0"; - } - - /** Sec-MS-GEC:Windows epoch 秒数按 300 秒取整后转 100 纳秒刻度,拼 Token 求 SHA-256 大写十六进制。 */ - static String secMsGec() { - long ticks = System.currentTimeMillis() / 1000L + WIN_EPOCH_SECONDS; - ticks -= ticks % 300L; - ticks *= 10_000_000L; - return sha256(ticks + TRUSTED_CLIENT_TOKEN); - } - - private static String sha256(String value) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(bytes.length * 2); - for (byte b : bytes) sb.append(String.format(Locale.US, "%02X", b)); - return sb.toString(); - } catch (Throwable e) { - return ""; - } - } - - private static String connectId() { - return UUID.randomUUID().toString().replace("-", ""); - } - - private boolean sendSsml(String requestId, String text) { - WebSocket current; - synchronized (lock) { - current = socket; - } - if (current == null) return false; - SimpleDateFormat format = new SimpleDateFormat("EEE MMM d yyyy HH:mm:ss 'GMT+0000'", Locale.US); - format.setTimeZone(TimeZone.getTimeZone("UTC")); - String timestamp = format.format(new Date()); - String ssml = "" - + "" - + "" - + escapeXml(text) - + ""; - String message = "X-RequestId:" + requestId - + "\r\nContent-Type:application/ssml+xml" - + "\r\nX-Timestamp:" + timestamp + "Z" - + "\r\nPath:ssml\r\n\r\n" + ssml; - return current.send(message); - } - - /** 朗读面板语速(0.5x–2.0x)→ SSML 百分比:1.0x 对应 +0%。 */ - private String prosodyRate() { - int percent = Math.round((options.rate - 1f) * 100f); - return (percent >= 0 ? "+" : "") + percent + "%"; - } - - private void sendSpeechConfig(WebSocket target) { - SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); - format.setTimeZone(TimeZone.getTimeZone("UTC")); - String config = "{\"context\":{\"synthesis\":{\"audio\":{\"metadataoptions\":{" - + "\"sentenceBoundaryEnabled\":\"false\",\"wordBoundaryEnabled\":\"false\"}," - + "\"outputFormat\":\"" + OUTPUT_FORMAT + "\"}}}}"; - String message = "X-Timestamp:" + format.format(new Date()) - + "\r\nContent-Type:application/json; charset=utf-8" - + "\r\nPath:speech.config\r\n\r\n" + config + "\r\n"; - target.send(message); - } - - private void markFailed() { - lastFailureAt = SystemClock.elapsedRealtime(); - WebSocket current; - synchronized (lock) { - current = socket; - socket = null; - configSent = false; - for (Turn turn : turns.values()) turn.done.countDown(); - } - if (current != null) { - try { - current.cancel(); - } catch (Throwable ignore) { - } - } - } - - @Override - public void release() { - markFailed(); - super.release(); - } - - private final class SocketListener extends WebSocketListener { - - private final CountDownLatch opened; - - SocketListener(CountDownLatch opened) { - this.opened = opened; - } - - @Override - public void onOpen(WebSocket webSocket, Response response) { - synchronized (lock) { - socket = webSocket; - configSent = false; - } - sendSpeechConfig(webSocket); - synchronized (lock) { - configSent = true; - } - opened.countDown(); - } - - @Override - public void onMessage(WebSocket webSocket, String text) { - if (text == null || !text.contains("turn.end")) return; - String requestId = headerValue(text, "X-RequestId"); - Turn turn = findTurn(requestId); - if (turn == null) return; - turn.success = turn.audio.size() > 0; - turn.done.countDown(); - } - - @Override - public void onMessage(WebSocket webSocket, ByteString bytes) { - byte[] data = bytes.toByteArray(); - if (data.length < 2) return; - int headerLength = ((data[0] & 0xFF) << 8) | (data[1] & 0xFF); - if (headerLength < 0 || headerLength + 2 > data.length) return; - String header = new String(data, 2, headerLength, StandardCharsets.UTF_8); - String path = headerValue(header, "Path"); - if (path != null && !"audio".equals(path)) return; - Turn turn = findTurn(headerValue(header, "X-RequestId")); - if (turn == null) return; - int start = headerLength + 2; - if (start >= data.length) return; - turn.audio.write(data, start, data.length - start); - } - - @Override - public void onClosed(WebSocket webSocket, int code, String reason) { - SpiderDebug.log(TAG, "edge tts closed code=%d", code); - markFailed(); - } - - @Override - public void onFailure(WebSocket webSocket, Throwable t, Response response) { - SpiderDebug.log(TAG, "edge tts failure %s", t == null ? "unknown" : t.getMessage()); - markFailed(); - opened.countDown(); - } - } - - private Turn findTurn(String requestId) { - if (requestId == null) return null; - synchronized (lock) { - return turns.get(requestId); - } - } - - /** 从协议的 header 段里取值(大小写不敏感,容忍首尾空白)。 */ - private static String headerValue(String raw, String name) { - if (raw == null) return null; - for (String line : raw.split("\r\n")) { - int colon = line.indexOf(':'); - if (colon <= 0) continue; - if (name.equalsIgnoreCase(line.substring(0, colon).trim())) { - return line.substring(colon + 1).trim(); - } - } - return null; - } - - /** 去掉朗读引擎不认识的装饰字符,保留中文与常用标点(对齐参考实现的清洗规则)。 */ - static String cleanText(String text) { - if (text == null) return ""; - StringBuilder sb = new StringBuilder(text.length()); - for (int i = 0; i < text.length(); i++) { - char c = text.charAt(i); - boolean keep = Character.isLetterOrDigit(c) - || Character.isWhitespace(c) - || ",。!?;:、()《》【】“”‘’…—,.!?;:()[]'\"-".indexOf(c) >= 0; - if (keep) sb.append(c); - } - return sb.toString().trim(); - } - - private static String escapeXml(String text) { - return text.replace("&", "&").replace("<", "<").replace(">", ">") - .replace("'", "'").replace("\"", """); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/HttpTtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/HttpTtsEngine.java deleted file mode 100644 index 1be013dd15..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/HttpTtsEngine.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.fongmi.android.tv.tts; - -import androidx.annotation.Nullable; - -import com.github.catvod.crawler.SpiderDebug; - -import java.io.File; -import java.io.InputStream; -import java.net.URLEncoder; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.ResponseBody; - -/** - * 通用在线朗读引擎:按 legado 兼容的 URL 规则请求音频流。 - * - * 同时用于内置的百度在线语音({@link BaiduTtsEngine})与用户自定义引擎 - * (规则来自 {@link TtsEngineConfig},见 {@link TtsHttpRule} 支持的模板子集)。 - */ -class HttpTtsEngine extends AudioFileTtsEngine { - - private static final String TAG = "TV-tts"; - - private static final OkHttpClient CLIENT = new OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(20, TimeUnit.SECONDS) - .callTimeout(30, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .build(); - - private final TtsEngineConfig config; - private final TtsHttpRule rule; - private final String engineId; - - HttpTtsEngine(TtsEngineConfig config) { - this(config, TtsEngines.SOURCE_CUSTOM); - } - - HttpTtsEngine(TtsEngineConfig config, String engineId) { - this.config = config; - this.engineId = engineId; - this.rule = config == null ? null : TtsHttpRule.parse(config.url, config.contentType); - } - - @Override - public String id() { - return engineId; - } - - @Override - public boolean available() { - return rule != null && rule.isUsable(); - } - - @Override - public String detail() { - if (config == null) return "未配置在线朗读引擎"; - if (rule == null || !rule.isUsable()) { - return rule == null ? "引擎规则无法解析" : rule.unsupportedReason(); - } - return config.displayName(); - } - - @Override - protected String cacheKey(String text) { - // 参数(音色/语速)参与键,避免改设置后播到旧音频 - return engineId + "|" + options.rate + "|" + options.voice + "|" + text; - } - - @Override - protected boolean fetchChunk(String text, File target) { - if (!available()) { - lastFetchError = detail(); - return false; - } - int speed = speakSpeed(options.rate); - String url = rule.url(text, speed); - String method = rule.method(); - String body = rule.body(text, speed); - try { - Request.Builder builder = new Request.Builder().url(url); - if (config != null && config.header != null && !config.header.trim().isEmpty()) { - applyHeader(builder, config.header); - } - if (TtsHttpRule.METHOD_POST.equals(method)) { - RequestBody requestBody = RequestBody.create( - body == null ? "" : body, - MediaType.parse("application/x-www-form-urlencoded; charset=utf-8")); - builder.post(requestBody); - } else { - builder.get(); - } - try (Response response = CLIENT.newCall(builder.build()).execute()) { - ResponseBody responseBody = response.body(); - if (!response.isSuccessful() || responseBody == null) { - lastFetchError = "HTTP " + response.code(); - SpiderDebug.log(TAG, "http tts bad response code=%d", response.code()); - return false; - } - String contentType = response.header("Content-Type", ""); - if (!looksLikeAudio(contentType)) { - // 引擎改成返回错误文本时,读一小段便于用户诊断(对齐 legado 的错误体提示) - String preview = peek(responseBody); - lastFetchError = "返回 " + contentType + ":" + preview; - SpiderDebug.log(TAG, "http tts non-audio contentType=%s body=%s", contentType, preview); - return false; - } - try (InputStream in = responseBody.byteStream()) { - boolean stored = storeStream(target, in); - if (!stored) lastFetchError = "音频写入缓存失败"; - return stored; - } - } - } catch (Throwable e) { - lastFetchError = e.getClass().getSimpleName() + " " + (e.getMessage() == null ? "" : e.getMessage()); - SpiderDebug.log(TAG, "http tts failed %s", e.getMessage()); - return false; - } - } - - private static boolean looksLikeAudio(String contentType) { - if (contentType == null) return false; - String ct = contentType.toLowerCase(Locale.ROOT); - return ct.startsWith("audio") || ct.startsWith("application/octet-stream") - || ct.startsWith("application/mpeg") || ct.startsWith("binary/octet-stream"); - } - - private static String peek(ResponseBody body) { - try { - InputStream in = body.byteStream(); - byte[] buf = new byte[256]; - int n = in.read(buf); - return n <= 0 ? "" : new String(buf, 0, n, "UTF-8"); - } catch (Throwable e) { - return ""; - } - } - - private static void applyHeader(Request.Builder builder, String header) { - // legado 的 header 字段允许是 JSON 或直接粘贴的 "Key: Value" 多行文本 - String text = header.trim(); - com.google.gson.JsonObject json = TtsHttpRule.parseObject(text); - if (json != null) { - for (Map.Entry e : json.entrySet()) { - if (e.getValue() == null || e.getValue().isJsonNull()) continue; - builder.header(e.getKey(), e.getValue().getAsString()); - } - return; - } - for (String line : text.split("\n")) { - int colon = line.indexOf(':'); - if (colon <= 0) continue; - builder.header(line.substring(0, colon).trim(), line.substring(colon + 1).trim()); - } - } - - /** 语速映射:朗读面板的 0.5x–2.0x → 引擎自己的速度刻度。 */ - protected int speakSpeed(float rate) { - return Math.max(1, Math.min(7, Math.round(rate * 5f))); - } - - @Nullable - static String urlEncode(String value) { - try { - return URLEncoder.encode(value, "UTF-8"); - } catch (Throwable e) { - return null; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/ReaderTtsController.java b/app/src/main/java/com/fongmi/android/tv/tts/ReaderTtsController.java deleted file mode 100644 index 2eaee823e4..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/ReaderTtsController.java +++ /dev/null @@ -1,387 +0,0 @@ -package com.fongmi.android.tv.tts; - -import android.content.Context; -import android.os.Handler; -import android.os.Looper; -import android.os.SystemClock; - -import com.github.catvod.crawler.SpiderDebug; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** - * 小说朗读编排器(对齐 legado BaseReadAloudService 的职责,去掉与正文分页模型的耦合)。 - * - * 负责:段落 → 朗读片段切分、引擎选择与生命周期、段落游标、上一段/下一段、 - * 定时停止、状态广播。真正的语音合成/播放交给 {@link TtsEngine} 实现。 - * - * 之所以把编排放在原生侧(而不是留在 reader.html 的 JS 里): - * 切后台后 WebView 的 JS 计时器会被系统挂起,而朗读必须继续; - * 同时通知栏的上一段/下一段也要能越过 JS 直接驱动队列。 - */ -public final class ReaderTtsController implements TtsEngine.Listener { - - private static final String TAG = "TV-tts"; - - public static final String STATE_PLAYING = "playing"; - public static final String STATE_PAUSED = "paused"; - public static final String STATE_STOPPED = "stopped"; - public static final String STATE_ERROR = "error"; - - /** 面向 UI(Activity / 通知)的状态回调。 */ - public interface Listener { - /** @param paragraph 当前段落下标(0 基),-1 表示无。 */ - void onTtsState(String state, int paragraph, int total, String message, String title); - - void onTtsParagraph(int paragraph, int total, String text); - - /** 本章朗读完毕,由 UI 决定是否续读下一章。 */ - void onTtsChapterEnd(); - - void onTtsVoices(List voices); - } - - private final Handler handler = new Handler(Looper.getMainLooper()); - private final Runnable timerTick = new Runnable() { - @Override - public void run() { - if (timerMinutes <= 0) return; - if (STATE_PLAYING.equals(state)) { - timerMinutes--; - if (timerMinutes <= 0) { - timerMinutes = 0; - stop("定时朗读结束"); - return; - } - notifyState("朗读定时剩余 " + timerMinutes + " 分钟"); - } - handler.postDelayed(this, 60000L); - } - }; - - private final Context context; - private TtsEngine engine; - private Listener listener; - private List chunks = Collections.emptyList(); - private int paragraphCount; - private int currentChunk = -1; - private String state = STATE_STOPPED; - private String title = ""; - private String message = ""; - private int timerMinutes; - private String sourceId = TtsEngines.SOURCE_SYSTEM; - private long startedAt; - - public ReaderTtsController(Context context) { - this.context = context.getApplicationContext(); - } - - public void setListener(Listener l) { - this.listener = l; - } - - public String state() { - return state; - } - - public int currentParagraph() { - if (currentChunk < 0 || currentChunk >= chunks.size()) return -1; - return chunks.get(currentChunk).paragraph; - } - - public int paragraphCount() { - return paragraphCount; - } - - public boolean isPlaying() { - return STATE_PLAYING.equals(state); - } - - public boolean isActive() { - return STATE_PLAYING.equals(state) || STATE_PAUSED.equals(state); - } - - public int timerMinutes() { - return timerMinutes; - } - - public String sourceId() { - return sourceId; - } - - /** 已朗读时长(毫秒),用于通知/状态展示。 */ - public long elapsed() { - return startedAt <= 0 ? 0 : SystemClock.elapsedRealtime() - startedAt; - } - - /** - * 开始朗读。 - * - * @param source 音源 id:{@code system}/{@code edge}/{@code baidu}/{@code custom} - * @param options 语速/音调/音色 - * @param customRule 自定义在线引擎(source=custom 时使用) - * @param paragraphs 段落文本(阅读器正文,按 DOM 顺序) - * @param startIndex 起始段落下标(0 基) - * @param timer 定时分钟数,0 表示不定时 - * @param trackTitle 书名/章节名,用于通知 - */ - public void start(String source, TtsOptions options, TtsEngineConfig customRule, - List paragraphs, int startIndex, int timer, String trackTitle) { - stop(null); - sourceId = source == null || source.isEmpty() ? TtsEngines.SOURCE_SYSTEM : source; - title = trackTitle == null ? "" : trackTitle; - paragraphCount = paragraphs == null ? 0 : paragraphs.size(); - if (paragraphCount == 0) { - setState(STATE_ERROR, "没有可朗读的内容"); - return; - } - engine = TtsEngines.create(context, sourceId, customRule); - if (engine instanceof HttpTtsEngine && (!engine.available())) { - setState(STATE_ERROR, engine.detail()); - releaseEngine(); - return; - } - if (sourceId.equals(TtsEngines.SOURCE_SYSTEM) && !engine.available()) { - setState(STATE_ERROR, engine.detail()); - releaseEngine(); - return; - } - // 系统引擎自己按标点断句更自然;在线引擎按句请求,首句更快出声且便于预取 - int maxChars = TtsEngines.SOURCE_SYSTEM.equals(sourceId) ? 0 : TtsTextSplitter.MAX_CHUNK; - chunks = TtsTextSplitter.split(paragraphs, maxChars); - if (chunks.isEmpty()) { - setState(STATE_ERROR, "没有可朗读的内容"); - releaseEngine(); - return; - } - int start = Math.max(0, Math.min(startIndex, paragraphCount - 1)); - currentChunk = firstChunkOfParagraph(start); - timerMinutes = Math.max(0, Math.min(timer, 600)); - startedAt = SystemClock.elapsedRealtime(); - SpiderDebug.log(TAG, "start source=%s paragraphs=%d chunks=%d start=%d timer=%d", - sourceId, paragraphCount, chunks.size(), start, timerMinutes); - engine.prepare(options, this); - engine.speak(chunks, currentChunk); - setState(STATE_PLAYING, null); - scheduleTimer(); - } - - public void pause() { - if (!STATE_PLAYING.equals(state)) return; - if (engine != null) engine.pause(); - setState(STATE_PAUSED, null); - } - - public void resume() { - if (!STATE_PAUSED.equals(state)) return; - if (engine != null) engine.resume(); - setState(STATE_PLAYING, null); - scheduleTimer(); - } - - public void toggle() { - if (STATE_PLAYING.equals(state)) pause(); - else if (STATE_PAUSED.equals(state)) resume(); - } - - /** 上一段:回到当前段起点;已在段首则退到上一段。 */ - public void previous() { - if (!isActive() || chunks.isEmpty()) return; - int paragraph = currentParagraph(); - int firstOfCurrent = firstChunkOfParagraph(paragraph); - int target = currentChunk > firstOfCurrent ? firstOfCurrent : firstChunkOfParagraph(Math.max(0, paragraph - 1)); - seekChunk(target); - } - - /** 下一段:跳到下一段起点;没有下一段则视为本章读完。 */ - public void next() { - if (!isActive() || chunks.isEmpty()) return; - int paragraph = currentParagraph(); - int target = firstChunkOfParagraph(paragraph + 1); - if (target < 0 || target >= chunks.size() || chunks.get(target).paragraph <= paragraph) { - handleQueueDone(); - return; - } - seekChunk(target); - } - - /** 跳到指定段落。 */ - public void seekParagraph(int paragraph) { - if (!isActive() || chunks.isEmpty()) return; - if (paragraph < 0 || paragraph >= paragraphCount) return; - seekChunk(firstChunkOfParagraph(paragraph)); - } - - public void setRate(float rate) { - if (engine != null) engine.setRate(rate); - } - - public void setPitch(float pitch) { - if (engine != null) engine.setPitch(pitch); - } - - public void setVoice(String voice) { - if (engine == null) return; - engine.setVoice(voice); - // 换音色后从当前段重新合成/朗读 - if (isActive() && !chunks.isEmpty()) seekChunk(firstChunkOfParagraph(Math.max(0, currentParagraph()))); - } - - /** 设置定时的绝对分钟数(0 表示取消)。 */ - public void setTimer(int minutes) { - timerMinutes = Math.max(0, Math.min(minutes, 600)); - notifyState(timerMinutes > 0 ? "朗读定时 " + timerMinutes + " 分钟" : null); - scheduleTimer(); - } - - /** 在现有定时上追加(通知栏「+30 分钟」),上限 3 小时。 */ - public void addTimer(int delta) { - setTimer(Math.min(180, timerMinutes + Math.max(0, delta))); - } - - public void stop(String reason) { - handler.removeCallbacks(timerTick); - if (engine != null) { - engine.stop(); - } - releaseEngine(); - chunks = Collections.emptyList(); - currentChunk = -1; - startedAt = 0; - if (STATE_STOPPED.equals(state) && reason == null) return; - setState(STATE_STOPPED, reason); - } - - /** 用户退出阅读页:停播并释放全部资源。 */ - public void release() { - stop(null); - listener = null; - } - - private void releaseEngine() { - if (engine == null) return; - try { - engine.release(); - } catch (Throwable e) { - SpiderDebug.log(TAG, "engine release failed %s", e.getMessage()); - } - engine = null; - } - - private void seekChunk(int index) { - if (engine == null || chunks.isEmpty()) return; - if (index < 0 || index >= chunks.size()) return; - currentChunk = index; - engine.speak(chunks, index); - setState(STATE_PLAYING, null); - scheduleTimer(); - } - - private int firstChunkOfParagraph(int paragraph) { - for (int i = 0; i < chunks.size(); i++) { - if (chunks.get(i).paragraph >= paragraph) return i; - } - return -1; - } - - private void scheduleTimer() { - handler.removeCallbacks(timerTick); - if (timerMinutes > 0) handler.postDelayed(timerTick, 60000L); - } - - private void setState(String newState, String info) { - state = newState; - message = info == null ? "" : info; - if (STATE_STOPPED.equals(newState)) { - startedAt = 0; - timerMinutes = 0; - } - notifyState(null); - } - - private void notifyState(String info) { - if (listener == null) return; - listener.onTtsState(state, currentParagraph(), paragraphCount, - info != null ? info : message, title); - } - - /** - * 本章读完。 - * - * 必须先发「章末」再落 STOPPED:阅读页靠这个事件决定是否续读下一章, - * 而它判断「当前是否在读」依据的是 state;若先变 stopped,续读会被误判成 - * 「用户已停止」而丢弃,表现为读完一章就彻底停下。 - */ - private void handleQueueDone() { - handler.removeCallbacks(timerTick); - if (engine != null) engine.stop(); - currentChunk = -1; - if (listener != null) listener.onTtsChapterEnd(); - setState(STATE_STOPPED, null); - } - - /* ---------------- TtsEngine.Listener ---------------- */ - - /** - * 引擎回调统一切到主线程。 - * - * 系统 TTS 的 UtteranceProgressListener、MediaPlayer 的完成回调虽然多在主线程, - * 但 HTTP 合成失败/超时等路径可能从工作线程直接回调;而回调链路下游要做 - * startForeground/stopSelf、通知更新与 WebView evaluateJavascript,都要求主线程语义。 - */ - private void onMain(Runnable action) { - if (Looper.myLooper() == Looper.getMainLooper()) action.run(); - else handler.post(action); - } - - @Override - public void onTtsStart(int index, String text) { - onMain(() -> handleStart(index, text)); - } - - private void handleStart(int index, String text) { - currentChunk = index; - if (listener == null) return; - int paragraph = index >= 0 && index < chunks.size() ? chunks.get(index).paragraph : -1; - listener.onTtsParagraph(paragraph, paragraphCount, text); - notifyState(null); - } - - @Override - public void onTtsChunkDone(int index) { - // 进度推进以 onTtsStart 为准(引擎已在下一次 start 里给出新下标) - } - - @Override - public void onTtsQueueDone() { - onMain(this::handleQueueDone); - } - - @Override - public void onTtsError(int index, String errorMessage, boolean fatal) { - onMain(() -> handleError(index, errorMessage, fatal)); - } - - @Override - public void onTtsVoices(List voices) { - onMain(() -> { - if (listener != null) listener.onTtsVoices(new ArrayList<>(voices)); - }); - } - - private void handleError(int index, String errorMessage, boolean fatal) { - SpiderDebug.log(TAG, "engine error index=%d fatal=%b %s", index, fatal, errorMessage); - if (fatal) { - setState(STATE_ERROR, errorMessage); - releaseEngine(); - chunks = Collections.emptyList(); - currentChunk = -1; - } else { - message = errorMessage == null ? "" : errorMessage; - notifyState(null); - } - } - -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/SystemTtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/SystemTtsEngine.java deleted file mode 100644 index 69d679b88c..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/SystemTtsEngine.java +++ /dev/null @@ -1,352 +0,0 @@ -package com.fongmi.android.tv.tts; - -import android.content.Context; -import android.os.Build; -import android.os.Bundle; -import android.speech.tts.TextToSpeech; -import android.speech.tts.UtteranceProgressListener; -import android.speech.tts.Voice; - -import com.github.catvod.crawler.SpiderDebug; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * 系统朗读引擎(Android TextToSpeech)。 - * - * 对齐 legado TTSReadAloudService: - * - 首句 SPEC:QUEUE_FLUSH,其余 QUEUE_ADD; - * - 整段文本交给系统引擎(系统引擎自己按标点断句,比应用层切碎更自然); - * - {@code onDone} 推进游标;{@code onError} 跳过当前段继续(单段失败不该中断整章朗读)。 - * - * 与本设备相关的失败路径:设备可能没有安装任何 TTS 引擎(本机测试设备即是如此), - * 此时 {@code onInit} 返回 ERROR,引擎上报 fatal 错误,由上层给出可操作提示。 - */ -final class SystemTtsEngine implements TtsEngine { - - private static final String TAG = "TV-tts"; - private static final String UTTERANCE_PREFIX = "webhtv-tts-"; - - private final Context context; - private final List voices = new ArrayList<>(); - private final AtomicBoolean speaking = new AtomicBoolean(false); - private final AtomicBoolean paused = new AtomicBoolean(false); - - private TextToSpeech textToSpeech; - private TtsEngine.Listener listener; - private List chunks = Collections.emptyList(); - private int current = -1; - private boolean released; - private boolean ready; - private float rate = 1f; - private float pitch = 1f; - private String voiceId = ""; - private boolean lastUtteranceQueued; - /** 队列里已经全部入队,只等最后一段 onDone 触发队列完成。 */ - private boolean queueExhausted; - - SystemTtsEngine(Context context) { - this.context = context; - } - - @Override - public String id() { - return TtsEngines.SOURCE_SYSTEM; - } - - @Override - public boolean available() { - return TtsEngines.systemAvailable(context); - } - - @Override - public String detail() { - return available() ? "设备系统语音" : "设备未安装系统 TTS 引擎"; - } - - @Override - public List voices() { - return new ArrayList<>(voices); - } - - @Override - public void prepare(TtsOptions options, Listener listener) { - this.listener = listener; - if (released) return; - if (!available()) { - listener.onTtsError(-1, "设备未安装系统 TTS 引擎,请在系统设置中安装语音包或改用在线音源", true); - return; - } - applyOptions(options); - // 引擎初始化是异步的;重复 prepare(换音色/改语速)时复用已建好的实例 - if (textToSpeech == null) { - textToSpeech = new TextToSpeech(context.getApplicationContext(), status -> { - if (status != TextToSpeech.SUCCESS) { - SpiderDebug.log(TAG, "system tts init failed status=%d", status); - if (listener != null) listener.onTtsError(-1, "系统 TTS 初始化失败", true); - return; - } - ready = true; - configure(); - collectVoices(); - if (listener != null) listener.onTtsVoices(voices()); - }); - } else if (ready) { - configure(); - } - } - - private void applyOptions(TtsOptions options) { - if (options == null) return; - rate = options.rate; - pitch = options.pitch; - voiceId = options.voice; - } - - private void configure() { - TextToSpeech tts = textToSpeech; - if (tts == null) return; - try { - tts.setSpeechRate(rate); - tts.setPitch(pitch); - if (voiceId != null && !voiceId.isEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - Voice target = findVoice(voiceId); - if (target != null) tts.setVoice(target); - } else { - tts.setLanguage(Locale.CHINA); - } - tts.setOnUtteranceProgressListener(new Progress()); - } catch (Throwable e) { - SpiderDebug.log(TAG, "system tts configure failed %s", e.getMessage()); - } - } - - private Voice findVoice(String id) { - TextToSpeech tts = textToSpeech; - if (tts == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null; - try { - Set all = tts.getVoices(); - if (all == null) return null; - for (Voice v : all) if (id.equals(v.getName())) return v; - } catch (Throwable e) { - SpiderDebug.log(TAG, "findVoice failed %s", e.getMessage()); - } - return null; - } - - private void collectVoices() { - voices.clear(); - TextToSpeech tts = textToSpeech; - if (tts == null) return; - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - Set all = tts.getVoices(); - if (all != null) { - for (Voice v : all) { - String lang = v.getLocale() == null ? "" : v.getLocale().getLanguage(); - if (!lang.startsWith("zh")) continue; - if (v.isNetworkConnectionRequired() && v.getFeatures() != null - && v.getFeatures().contains(TextToSpeech.Engine.KEY_FEATURE_NOT_INSTALLED)) { - continue; - } - voices.add(new TtsVoice(v.getName(), v.getName(), v.getLocale().toString())); - } - } - } - if (voices.isEmpty()) { - for (android.speech.tts.TextToSpeech.EngineInfo info : tts.getEngines()) { - voices.add(new TtsVoice(info.name, info.label, "")); - } - } - } catch (Throwable e) { - SpiderDebug.log(TAG, "collectVoices failed %s", e.getMessage()); - } - voices.sort((a, b) -> a.name.compareToIgnoreCase(b.name)); - } - - @Override - public void speak(List list, int fromIndex) { - if (textToSpeech == null || !ready) { - // 初始化尚未完成:记下请求,初始化回调里不会自动重放,因此明确报错让 UI 提示重试 - if (listener != null) { - listener.onTtsError(fromIndex, available() ? "系统语音正在初始化,请稍候重试" : "设备未安装系统 TTS 引擎", !available()); - } - return; - } - chunks = list == null ? Collections.emptyList() : list; - queueExhausted = false; - lastUtteranceQueued = false; - paused.set(false); - speaking.set(true); - current = Math.max(0, Math.min(fromIndex, chunks.size() - 1)); - try { - textToSpeech.stop(); - } catch (Throwable ignore) { - } - boolean first = true; - for (int i = current; i < chunks.size(); i++) { - String text = chunks.get(i).text; - int mode = first ? TextToSpeech.QUEUE_FLUSH : TextToSpeech.QUEUE_ADD; - int result = speakChunk(i, text, mode); - if (result == TextToSpeech.ERROR) { - SpiderDebug.log(TAG, "system tts speak error index=%d", i); - if (first && listener != null) { - listener.onTtsError(i, "系统语音播报失败", false); - } - continue; - } - first = false; - } - if (first) { - // 一个片段都没入队 - speaking.set(false); - if (listener != null) listener.onTtsQueueDone(); - return; - } - queueExhausted = true; - // 立即上报起始进度,UI 不必等引擎回调 - if (listener != null) listener.onTtsStart(current, chunks.get(current).text); - } - - private int speakChunk(int index, String text, int mode) { - TextToSpeech tts = textToSpeech; - if (tts == null) return TextToSpeech.ERROR; - Bundle params = new Bundle(); - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - return tts.speak(text, mode, params, UTTERANCE_PREFIX + index); - } - java.util.HashMap legacy = new java.util.HashMap<>(); - legacy.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, UTTERANCE_PREFIX + index); - return tts.speak(text, mode, legacy); - } catch (Throwable e) { - SpiderDebug.log(TAG, "speakChunk failed index=%d %s", index, e.getMessage()); - return TextToSpeech.ERROR; - } - } - - @Override - public void pause() { - if (textToSpeech == null || !speaking.get()) return; - paused.set(true); - try { - textToSpeech.stop(); - } catch (Throwable ignore) { - } - } - - @Override - public void resume() { - if (textToSpeech == null || !speaking.get()) return; - paused.set(false); - // 系统引擎的 stop() 会清空队列,恢复时从当前片段重新入队(复读当前段,语义与 legado 一致) - speak(chunks, Math.max(0, current)); - } - - @Override - public void stop() { - speaking.set(false); - paused.set(false); - queueExhausted = false; - try { - if (textToSpeech != null) textToSpeech.stop(); - } catch (Throwable ignore) { - } - } - - @Override - public void setRate(float value) { - rate = value; - try { - if (textToSpeech != null && ready) textToSpeech.setSpeechRate(value); - } catch (Throwable ignore) { - } - } - - @Override - public void setPitch(float value) { - pitch = value; - try { - if (textToSpeech != null && ready) textToSpeech.setPitch(value); - } catch (Throwable ignore) { - } - } - - @Override - public void setVoice(String voice) { - voiceId = voice == null ? "" : voice; - configure(); - } - - @Override - public void release() { - released = true; - speaking.set(false); - try { - if (textToSpeech != null) { - textToSpeech.stop(); - textToSpeech.shutdown(); - } - } catch (Throwable ignore) { - } - textToSpeech = null; - ready = false; - } - - private final class Progress extends UtteranceProgressListener { - - @Override - public void onStart(String utteranceId) { - int index = indexOf(utteranceId); - if (index < 0 || index >= chunks.size()) return; - current = index; - if (listener != null) listener.onTtsStart(index, chunks.get(index).text); - } - - @Override - public void onDone(String utteranceId) { - int index = indexOf(utteranceId); - if (index < 0) return; - if (listener != null) listener.onTtsChunkDone(index); - if (queueExhausted && index >= chunks.size() - 1) { - speaking.set(false); - if (listener != null) listener.onTtsQueueDone(); - } - } - - @Override - public void onError(String utteranceId, int errorCode) { - handleError(utteranceId, "errorCode=" + errorCode); - } - - @SuppressWarnings("deprecation") - @Override - public void onError(String utteranceId) { - handleError(utteranceId, "engine error"); - } - - private void handleError(String utteranceId, String message) { - int index = indexOf(utteranceId); - // 单段失败只跳过该段,不中断整章(对齐 legado 的 nextParagraph 行为) - if (listener != null) listener.onTtsError(index, "系统语音播报失败(" + message + ")", false); - if (queueExhausted && index >= 0 && index >= chunks.size() - 1) { - speaking.set(false); - if (listener != null) listener.onTtsQueueDone(); - } - } - - private int indexOf(String utteranceId) { - if (utteranceId == null || !utteranceId.startsWith(UTTERANCE_PREFIX)) return -1; - try { - return Integer.parseInt(utteranceId.substring(UTTERANCE_PREFIX.length())); - } catch (NumberFormatException e) { - return -1; - } - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsAudioCache.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsAudioCache.java deleted file mode 100644 index d2a1ca2eca..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/TtsAudioCache.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.fongmi.android.tv.tts; - -import com.github.catvod.crawler.SpiderDebug; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.InputStream; -import java.io.OutputStream; -import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; - -/** - * 在线朗读音频缓存(对齐 legado HttpReadAloudService 的「按段落 MD5 落盘」思路)。 - * - * 与 legado 的差异:不引入 media3 的 {@code SimpleCache} / {@code DownloadRequest}, - * 因为这里每次只需复用一小段 mp3,直接用应用缓存目录 + 按最后修改时间淘汰的 - * 容量上限即可,避免为朗读再加一个数据库与索引。 - */ -public final class TtsAudioCache { - - private static final String TAG = "TV-tts"; - private static final long MAX_BYTES = 64L * 1024 * 1024; - private static final long MIN_KEEP_BYTES = 4L * 1024 * 1024; - private static final String EXTENSION = ".mp3"; - - private final File dir; - - public TtsAudioCache(File root, String engineId) { - this.dir = new File(root, sanitize(engineId)); - if (!dir.exists()) dir.mkdirs(); - } - - public File dir() { - return dir; - } - - /** 缓存文件(不保证存在)。 */ - public File file(String engineId, String text) { - return new File(dir, key(engineId, text) + EXTENSION); - } - - public boolean has(File file) { - return file != null && file.isFile() && file.length() > 0; - } - - /** 把输入流写入缓存文件(先写临时文件再原子改名,避免半截文件被当成命中)。 */ - public boolean store(File target, InputStream in) { - if (target == null || in == null) return false; - File tmp = new File(target.getAbsolutePath() + ".part"); - try (InputStream stream = in; OutputStream out = new FileOutputStream(tmp)) { - byte[] buf = new byte[8192]; - long total = 0; - int n; - while ((n = stream.read(buf)) > 0) { - total += n; - out.write(buf, 0, n); - // 单段音频超过 8MB 视为异常响应,避免坏引擎把缓存撑爆 - if (total > 8L * 1024 * 1024) throw new IllegalStateException("audio too large"); - } - out.flush(); - if (total <= 0) throw new IllegalStateException("empty audio"); - } catch (Throwable e) { - SpiderDebug.log(TAG, "store failed %s: %s", target.getName(), e.getMessage()); - //noinspection ResultOfMethodCallIgnored - tmp.delete(); - return false; - } - //noinspection ResultOfMethodCallIgnored - if (target.exists()) target.delete(); - if (!tmp.renameTo(target)) { - //noinspection ResultOfMethodCallIgnored - tmp.delete(); - return false; - } - evictIfNeeded(); - return true; - } - - private void evictIfNeeded() { - File[] files = dir.listFiles(); - if (files == null || files.length < 8) return; - long total = 0; - List audio = new ArrayList<>(files.length); - for (File f : files) { - if (!f.isFile()) continue; - if (!f.getName().endsWith(EXTENSION)) { - //noinspection ResultOfMethodCallIgnored - f.delete(); - continue; - } - total += f.length(); - audio.add(f); - } - if (total <= MAX_BYTES) return; - audio.sort((a, b) -> Long.compare(a.lastModified(), b.lastModified())); - long target = Math.max(MIN_KEEP_BYTES, MAX_BYTES / 2); - for (File f : audio) { - if (total <= target) break; - long len = f.length(); - if (f.delete()) total -= len; - } - SpiderDebug.log(TAG, "evict cache remaining=%d bytes", total); - } - - /** 清空缓存(用户切引擎/清理时调用)。 */ - public void clear() { - File[] files = dir.listFiles(); - if (files == null) return; - for (File f : files) { - //noinspection ResultOfMethodCallIgnored - f.delete(); - } - } - - /** 缓存键:引擎 id + 文本(文本包含语速/音色造成的差异,因此由调用方拼进 text)。 */ - public static String key(String engineId, String text) { - try { - MessageDigest digest = MessageDigest.getInstance("MD5"); - byte[] bytes = digest.digest(((engineId == null ? "" : engineId) + "\u0000" + (text == null ? "" : text)).getBytes("UTF-8")); - StringBuilder sb = new StringBuilder(bytes.length * 2); - for (byte b : bytes) sb.append(String.format(Locale.US, "%02x", b)); - return sb.toString(); - } catch (Throwable e) { - return Integer.toHexString((engineId + text).hashCode()); - } - } - - private static String sanitize(String value) { - if (value == null || value.isEmpty()) return "default"; - StringBuilder sb = new StringBuilder(value.length()); - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - sb.append(Character.isLetterOrDigit(c) || c == '_' || c == '-' ? c : '_'); - } - return sb.toString(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsEngine.java deleted file mode 100644 index 85a36c8575..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/TtsEngine.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.fongmi.android.tv.tts; - -import java.util.List; - -/** - * 朗读引擎抽象。 - * - * 引擎自己持有「朗读片段队列 + 游标」,逐片段回调进度(对齐 legado 让朗读服务 - * 持有 contentList/nowSpeak 的做法):系统朗读靠 TTS 引擎的 QUEUE_ADD 排队, - * 在线朗读靠落盘缓存 + 预取排队,上层只关心片段下标。 - */ -public interface TtsEngine { - - /** 引擎事件回调(可能来自主线程或工作线程)。 */ - interface Listener { - /** 开始朗读某个片段。 */ - void onTtsStart(int index, String text); - - /** 某个片段朗读完成。 */ - void onTtsChunkDone(int index); - - /** 整个队列读完(章节读完)。 */ - void onTtsQueueDone(); - - /** 出错;fatal=true 表示引擎已不可用,需要上层停止朗读。 */ - void onTtsError(int index, String message, boolean fatal); - - /** 音色列表异步就绪(系统引擎初始化完成后才有)。 */ - void onTtsVoices(List voices); - } - - String id(); - - /** 引擎在当前设备/配置下是否可用(不含网络可达性,网络失败在朗读时上报)。 */ - boolean available(); - - /** 不可用的原因或补充说明,用于 UI 提示。 */ - String detail(); - - /** 可用音色;可能为空。 */ - List voices(); - - /** 初始化并准备朗读(系统引擎是异步初始化)。 */ - void prepare(TtsOptions options, Listener listener); - - /** 从 fromIndex 开始朗读整个队列。 */ - void speak(List chunks, int fromIndex); - - void pause(); - - void resume(); - - void stop(); - - void setRate(float rate); - - void setPitch(float pitch); - - void setVoice(String voiceId); - - /** 释放资源,之后不可再用。 */ - void release(); -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsEngineConfig.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsEngineConfig.java deleted file mode 100644 index bc435b4c45..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/TtsEngineConfig.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.fongmi.android.tv.tts; - -import com.google.gson.Gson; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -/** - * 自定义在线朗读引擎配置(与 legado 在线朗读引擎字段兼容的最小子集)。 - * - * 只保留真正被使用到的字段:名称、URL 规则、Content-Type、Header。 - * legado 的 {@code loginUrl} / {@code loginUi} / {@code loginCheckJs} / {@code jsLib} - * 需要规则引擎与 JS 运行时支撑,本实现不引入(见任务文档 §3 的取舍决策)。 - */ -public final class TtsEngineConfig { - - public String name = ""; - public String url = ""; - public String contentType = "audio/mpeg"; - public String header = ""; - - public static TtsEngineConfig fromJson(String json) { - if (json == null) return null; - try { - JsonObject o = JsonParser.parseString(json.trim()).getAsJsonObject(); - TtsEngineConfig config = new TtsEngineConfig(); - config.name = optString(o, "name"); - config.url = optString(o, "url"); - config.contentType = optString(o, "contentType"); - config.header = optString(o, "header"); - if (config.contentType.isEmpty()) config.contentType = "audio/mpeg"; - return config.url.isEmpty() ? null : config; - } catch (Throwable e) { - return null; - } - } - - private static String optString(JsonObject o, String key) { - if (o == null || !o.has(key) || o.get(key).isJsonNull()) return ""; - return o.get(key).getAsString(); - } - - public String toJson() { - JsonObject o = new JsonObject(); - o.addProperty("name", name == null ? "" : name); - o.addProperty("url", url == null ? "" : url); - o.addProperty("contentType", contentType == null ? "" : contentType); - o.addProperty("header", header == null ? "" : header); - return new Gson().toJson(o); - } - - public String displayName() { - if (name != null && !name.trim().isEmpty()) return name.trim(); - String u = url == null ? "" : url.trim(); - int cut = u.indexOf(','); - if (cut > 0) u = u.substring(0, cut); - return u.length() > 48 ? u.substring(0, 48) + "…" : u; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsEngines.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsEngines.java deleted file mode 100644 index b2b87f73e6..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/TtsEngines.java +++ /dev/null @@ -1,152 +0,0 @@ -package com.fongmi.android.tv.tts; - -import android.content.Context; -import android.speech.tts.TextToSpeech; - -import com.fongmi.android.tv.App; -import com.github.catvod.crawler.SpiderDebug; - -import java.io.File; - -/** - * 朗读引擎注册表:构造内置引擎、保存/读取用户配置的自定义在线引擎。 - * - * 内置音源(均不需要申请 Key): - * - {@code system}:设备自带 TTS 引擎(Android TextToSpeech),离线可用、质量取决于系统语音包; - * - {@code edge}:微软 Edge 大声朗读协议(Neural 音色,质量最好,依赖网络); - * - {@code baidu}:百度翻译发音接口(轻量、低延迟,依赖网络)。 - * - * 另有用户自定义在线引擎(legado 规则子集),配置持久化在 SharedPreferences。 - */ -public final class TtsEngines { - - private static final String TAG = "TV-tts"; - public static final String SOURCE_SYSTEM = "system"; - public static final String SOURCE_EDGE = "edge"; - public static final String SOURCE_BAIDU = "baidu"; - public static final String SOURCE_CUSTOM = "custom"; - - private static final String PREF = "reader_tts"; - private static final String KEY_CUSTOM = "custom_engine"; - - private TtsEngines() { - } - - public static File cacheRoot() { - File dir = new File(App.get().getCacheDir(), "tts"); - if (!dir.exists()) dir.mkdirs(); - return dir; - } - - public static TtsEngine create(Context context, String source, TtsEngineConfig custom) { - String id = source == null ? SOURCE_SYSTEM : source.trim(); - switch (id) { - case SOURCE_EDGE: - return new EdgeTtsEngine(); - case SOURCE_BAIDU: - return new BaiduTtsEngine(); - case SOURCE_CUSTOM: - return new HttpTtsEngine(custom); - case SOURCE_SYSTEM: - default: - return new SystemTtsEngine(context); - } - } - - /** 是否有系统 TTS 引擎可用(同步探测,供 UI 提前给出提示)。 */ - public static boolean systemAvailable(Context context) { - try { - return !context.getPackageManager() - .queryIntentServices(new android.content.Intent(TextToSpeech.Engine.INTENT_ACTION_TTS_SERVICE), 0) - .isEmpty(); - } catch (Throwable e) { - SpiderDebug.log(TAG, "systemAvailable failed %s", e.getMessage()); - return false; - } - } - - /** 内置音源的静态音色表(系统与百度音色需运行时探测,返回空表)。 */ - public static java.util.List staticVoices(String source) { - if (SOURCE_EDGE.equals(source)) { - java.util.List list = new java.util.ArrayList<>(); - for (String[] voice : EdgeTtsEngine.VOICES) { - list.add(new TtsVoice(voice[0], voice[1], "zh-CN")); - } - return list; - } - return java.util.Collections.emptyList(); - } - - /** - * 探测系统引擎的音色列表(异步)。 - * - * TextToSpeech 的音色必须在初始化完成后才能读取,因此这里建一个临时实例, - * 拿到音色后立即释放。仅在设备没有系统引擎时同步回调空表。 - * - * 注意:临时实例与正在朗读的实例并存时,部分设备会互相干扰, - * 因此调用方(朗读面板)只在用户切到系统音源时探测一次。 - */ - public static void probeSystemVoices(Context context, java.util.function.Consumer> callback) { - if (callback == null) return; - if (!systemAvailable(context)) { - callback.accept(java.util.Collections.emptyList()); - return; - } - final SystemTtsEngine engine = new SystemTtsEngine(context.getApplicationContext()); - final java.util.concurrent.atomic.AtomicBoolean done = new java.util.concurrent.atomic.AtomicBoolean(); - final android.os.Handler handler = new android.os.Handler(android.os.Looper.getMainLooper()); - Runnable timeout = () -> finishProbe(done, engine, callback, java.util.Collections.emptyList()); - engine.prepare(new TtsOptions(1f, 1f, ""), new TtsEngine.Listener() { - @Override - public void onTtsStart(int index, String text) { - } - - @Override - public void onTtsChunkDone(int index) { - } - - @Override - public void onTtsQueueDone() { - } - - @Override - public void onTtsError(int index, String message, boolean fatal) { - SpiderDebug.log(TAG, "probeSystemVoices error %s", message); - handler.removeCallbacks(timeout); - finishProbe(done, engine, callback, java.util.Collections.emptyList()); - } - - @Override - public void onTtsVoices(java.util.List voices) { - handler.removeCallbacks(timeout); - finishProbe(done, engine, callback, voices); - } - }); - handler.postDelayed(timeout, 8000L); - } - - private static void finishProbe(java.util.concurrent.atomic.AtomicBoolean done, SystemTtsEngine engine, - java.util.function.Consumer> callback, - java.util.List voices) { - if (!done.compareAndSet(false, true)) return; - try { - callback.accept(voices == null ? java.util.Collections.emptyList() : voices); - } finally { - try { - engine.release(); - } catch (Throwable ignore) { - } - } - } - - public static TtsEngineConfig loadCustomConfig() { - String json = App.get().getSharedPreferences(PREF, Context.MODE_PRIVATE).getString(KEY_CUSTOM, ""); - if (json == null || json.isEmpty()) return null; - return TtsEngineConfig.fromJson(json); - } - - public static void saveCustomConfig(TtsEngineConfig config) { - App.get().getSharedPreferences(PREF, Context.MODE_PRIVATE) - .edit().putString(KEY_CUSTOM, config == null ? "" : config.toJson()).apply(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsHttpRule.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsHttpRule.java deleted file mode 100644 index dafb03d644..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/TtsHttpRule.java +++ /dev/null @@ -1,331 +0,0 @@ -package com.fongmi.android.tv.tts; - -import com.google.gson.Gson; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; -import java.util.LinkedHashMap; -import java.util.Locale; -import java.util.Map; - -/** - * 在线朗读引擎 URL 规则(legado 兼容子集)。 - * - * 支持 legado 在线朗读规则的常用形态: - *

- * https://host/path?text={{java.encodeURI(speakText)}}&spd={{speakSpeed}}
- * http://tts.baidu.com/text2audio,{
- *     "method": "POST",
- *     "body": "tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{(speakSpeed + 5) / 10 + 4}}",
- *     "headers": {"User-Agent": "..."}
- * }
- * 
- * - * 支持的模板表达式: - * - 变量:{@code speakText}、{@code speakSpeed} - * - 函数:{@code java.encodeURI()}(等价 JS encodeURI,整体转义后保留保留字符) - * - 包装:{@code String()} - * - 算术:{@code + - * / ( )} 与数字常量 - * - * 不支持(构造时即判定不可用,避免运行期静默出错):{@code @js:} 规则、 - * {@code loginUrl} / {@code loginCheckJs} / {@code jsLib}。 - */ -public final class TtsHttpRule { - - public static final String METHOD_GET = "GET"; - public static final String METHOD_POST = "POST"; - - private final String raw; - private final String url; - private final String method; - private final String body; - private final Map headers; - private final String contentType; - private final String unsupported; - - private TtsHttpRule(String raw, String url, String method, String body, - Map headers, String contentType, String unsupported) { - this.raw = raw == null ? "" : raw; - this.url = url == null ? "" : url; - this.method = method; - this.body = body; - this.headers = headers; - this.contentType = contentType; - this.unsupported = unsupported; - } - - /** 解析引擎规则;参数非法时构造出的对象 {@link #isUsable()} 为 false。 */ - public static TtsHttpRule parse(String rule, String contentType) { - String text = rule == null ? "" : rule.trim(); - if (text.isEmpty()) return unusable(rule, contentType, "引擎规则为空"); - if (text.startsWith("@js:")) return unusable(rule, contentType, "暂不支持 @js: 规则引擎"); - String url = text; - String method = METHOD_GET; - String body = null; - Map headers = new LinkedHashMap<>(); - int split = indexOfOptions(text); - if (split > 0) { - url = text.substring(0, split).trim(); - JsonObject options = parseObject(text.substring(split + 1)); - if (options == null) return unusable(rule, contentType, "规则 JSON 解析失败"); - if (options.has("method") && !options.get("method").isJsonNull()) { - method = options.get("method").getAsString().trim().toUpperCase(Locale.ROOT); - } - if (options.has("body") && !options.get("body").isJsonNull()) { - JsonObject bodyObject = options.get("body").isJsonObject() ? options.getAsJsonObject("body") : null; - body = bodyObject != null ? new Gson().toJson(bodyObject) : options.get("body").getAsString(); - } - if (options.has("headers") && options.get("headers").isJsonObject()) { - for (Map.Entry e : options.getAsJsonObject("headers").entrySet()) { - if (e.getValue() == null || e.getValue().isJsonNull()) continue; - headers.put(e.getKey(), e.getValue().getAsString()); - } - } - } - if (url.isEmpty()) return unusable(rule, contentType, "引擎地址为空"); - if (METHOD_POST.equals(method) && (body == null || body.isEmpty())) { - return unusable(rule, contentType, "POST 规则缺少 body"); - } - return new TtsHttpRule(rule, url, method, body, headers, contentType, null); - } - - /** - * 找到 URL 与 JSON 选项的分界点(legado 的 {@code url,{...}} 形式)。 - * 只有当 URL 部分不含 {@code {}} 且逗号后紧跟 JSON 对象时才算选项分界, - * 否则把整串当作 URL(URL 自身可能带逗号,如 gettts 的查询串)。 - */ - private static int indexOfOptions(String text) { - // 从右往左找:URL 模板里也可能出现逗号({java.encodeURI(...)}), - // 只有「逗号后面的整段能解析成 JSON 对象」才是选项分界。 - int comma = text.lastIndexOf(','); - while (comma >= 0) { - String rest = text.substring(comma + 1).trim(); - if (rest.startsWith("{") && parseObject(rest) != null) return comma; - comma = text.lastIndexOf(',', comma - 1); - } - return -1; - } - - /** 解析 JSON 对象;失败返回 null(不抛出,交给调用方给出可读原因)。 */ - static JsonObject parseObject(String json) { - if (json == null) return null; - try { - com.google.gson.JsonElement element = JsonParser.parseString(json.trim()); - return element != null && element.isJsonObject() ? element.getAsJsonObject() : null; - } catch (Throwable e) { - return null; - } - } - - private static TtsHttpRule unusable(String rule, String contentType, String reason) { - return new TtsHttpRule(rule, "", METHOD_GET, null, new LinkedHashMap<>(), contentType, reason); - } - - public boolean isUsable() { - return unsupported == null; - } - - public String unsupportedReason() { - return unsupported; - } - - public String raw() { - return raw; - } - - public String contentType() { - return contentType; - } - - public String method() { - return method; - } - - public Map headers() { - return headers; - } - - public String url(String speakText, int speakSpeed) { - return render(url, speakText, speakSpeed); - } - - public String body(String speakText, int speakSpeed) { - return body == null ? null : render(body, speakText, speakSpeed); - } - - /** 渲染模板:把 {{表达式}} 求值后替换。 */ - public static String render(String template, String speakText, int speakSpeed) { - if (template == null) return null; - StringBuilder out = new StringBuilder(template.length() + 32); - int i = 0; - while (i < template.length()) { - int start = template.indexOf("{{", i); - if (start < 0) { - out.append(template, i, template.length()); - break; - } - int end = template.indexOf("}}", start + 2); - if (end < 0) { - out.append(template, i, template.length()); - break; - } - out.append(template, i, start); - String expr = template.substring(start + 2, end); - out.append(evaluateExpression(expr, speakText, speakSpeed)); - i = end + 2; - } - return out.toString(); - } - - private static String evaluateExpression(String expr, String speakText, int speakSpeed) { - String e = expr == null ? "" : expr.trim(); - if (e.isEmpty()) return ""; - if (e.equals("speakText")) return speakText == null ? "" : speakText; - if (e.equals("speakSpeed")) return String.valueOf(speakSpeed); - if (e.startsWith("String(") && e.endsWith(")")) { - String inner = e.substring("String(".length(), e.length() - 1).trim(); - Double value = tryEvaluateNumber(inner, speakText, speakSpeed); - return value != null ? numberToString(value) : evaluateExpression(inner, speakText, speakSpeed); - } - if (e.startsWith("java.encodeURI(") && e.endsWith(")")) { - String inner = e.substring("java.encodeURI(".length(), e.length() - 1).trim(); - return encodeUri(evaluateExpression(inner, speakText, speakSpeed)); - } - Double value = tryEvaluateNumber(e, speakText, speakSpeed); - if (value != null) return numberToString(value); - return "{{" + e + "}}"; - } - - /** 简单递归下降算术求值;含未知标识符时返回 null(保持原文)。 */ - private static Double tryEvaluateNumber(String expr, String speakText, int speakSpeed) { - try { - ArithmeticParser parser = new ArithmeticParser(expr, speakSpeed); - double v = parser.parse(); - if (!parser.isConsumed()) return null; - return v; - } catch (RuntimeException e) { - return null; - } - } - - private static String numberToString(double value) { - if (value == Math.rint(value) && !Double.isInfinite(value) && Math.abs(value) < 1e15) { - return String.valueOf((long) value); - } - String s = String.format(Locale.US, "%.4f", value); - while (s.contains(".") && (s.endsWith("0") || s.endsWith("."))) s = s.substring(0, s.length() - 1); - return s; - } - - /** 等价 JS encodeURI:保留 URI 保留字与未保留字,其余按 UTF-8 转义为大写十六进制。 */ - public static String encodeUri(String value) { - if (value == null) return ""; - // URLEncoder 把空格编成 '+',而 JS encodeURI 是 %20:先对齐空格, - // 否则模板拼出的查询串在按 JS 语义解析的服务端会得到字面加号。 - String encoded = urlEncode(value).replace("+", "%20"); - StringBuilder sb = new StringBuilder(encoded.length()); - for (int i = 0; i < encoded.length(); i++) { - char c = encoded.charAt(i); - if (c == '%' && i + 2 < encoded.length()) { - sb.append('%').append(Character.toUpperCase(encoded.charAt(i + 1))) - .append(Character.toUpperCase(encoded.charAt(i + 2))); - i += 2; - } else { - sb.append(c); - } - } - // URLEncoder 把 JS 视为保留的字符也转义了,这里还原,避免百度等接口解析差异 - return sb.toString() - .replace("%2A", "*").replace("%2D", "-").replace("%2E", ".") - .replace("%5F", "_").replace("%21", "!").replace("%7E", "~") - .replace("%27", "'").replace("%28", "(").replace("%29", ")") - .replace("%3B", ";").replace("%2C", ",").replace("%2F", "/") - .replace("%3F", "?").replace("%3A", ":").replace("%40", "@") - .replace("%26", "&").replace("%3D", "=").replace("%2B", "+") - .replace("%24", "$").replace("%23", "#"); - } - - private static String urlEncode(String value) { - try { - return URLEncoder.encode(value, "UTF-8"); - } catch (UnsupportedEncodingException e) { - return value; - } - } - - /** 只支持 speakSpeed 变量的整数算术,够用且能挡住任意表达式注入。 */ - private static final class ArithmeticParser { - private final String s; - private final int speakSpeed; - private int pos; - - ArithmeticParser(String s, int speakSpeed) { - this.s = s; - this.speakSpeed = speakSpeed; - } - - boolean isConsumed() { - skipSpace(); - return pos >= s.length(); - } - - double parse() { - double v = expression(); - return v; - } - - private double expression() { - double v = term(); - while (true) { - skipSpace(); - if (accept('+')) v += term(); - else if (accept('-')) v -= term(); - else return v; - } - } - - private double term() { - double v = factor(); - while (true) { - skipSpace(); - if (accept('*')) v *= factor(); - else if (accept('/')) v /= factor(); - else return v; - } - } - - private double factor() { - skipSpace(); - if (accept('(')) { - double v = expression(); - skipSpace(); - if (!accept(')')) throw new IllegalArgumentException("missing )"); - return v; - } - if (accept('-')) return -factor(); - if (accept('+')) return factor(); - if (pos < s.length() && s.startsWith("speakSpeed", pos)) { - pos += "speakSpeed".length(); - return speakSpeed; - } - int start = pos; - while (pos < s.length() && (Character.isDigit(s.charAt(pos)) || s.charAt(pos) == '.')) pos++; - if (start == pos) throw new IllegalArgumentException("unexpected token"); - return Double.parseDouble(s.substring(start, pos)); - } - - private void skipSpace() { - while (pos < s.length() && Character.isWhitespace(s.charAt(pos))) pos++; - } - - private boolean accept(char c) { - if (pos < s.length() && s.charAt(pos) == c) { - pos++; - return true; - } - return false; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsOptions.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsOptions.java deleted file mode 100644 index 50b9ca836e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/TtsOptions.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.fongmi.android.tv.tts; - -/** 朗读参数(来自阅读器面板,均由 JSON 传入)。 */ -public final class TtsOptions { - - public static final float RATE_MIN = 0.5f; - public static final float RATE_MAX = 2.0f; - public static final float PITCH_MIN = 0.5f; - public static final float PITCH_MAX = 1.5f; - - public final float rate; - public final float pitch; - public final String voice; - - public TtsOptions(float rate, float pitch, String voice) { - this.rate = clamp(rate <= 0f ? 1f : rate, RATE_MIN, RATE_MAX); - this.pitch = clamp(pitch <= 0f ? 1f : pitch, PITCH_MIN, PITCH_MAX); - this.voice = voice == null ? "" : voice; - } - - public TtsOptions withRate(float value) { - return new TtsOptions(value, pitch, voice); - } - - public TtsOptions withPitch(float value) { - return new TtsOptions(rate, value, voice); - } - - public TtsOptions withVoice(String value) { - return new TtsOptions(rate, pitch, value); - } - - private static float clamp(float v, float min, float max) { - return v < min ? min : (v > max ? max : v); - } - - /** - * 面板音调(0.5x–1.5x)→ SSML prosody pitch 的 Hz 偏移。 - * - * 仅微软 Edge 音源支持音调;百度发音接口没有音调参数, - * 因此朗读面板只在支持的音源上显示音调控件(见 reader.html 的 ttsRefreshSource)。 - */ - public static String pitchOffsetHz(float pitch) { - int offset = Math.round((clamp(pitch, PITCH_MIN, PITCH_MAX) - 1f) * 100f); - return (offset >= 0 ? "+" : "") + offset + "Hz"; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsTextSplitter.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsTextSplitter.java deleted file mode 100644 index 49f0ac9711..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/TtsTextSplitter.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.fongmi.android.tv.tts; - -import java.util.ArrayList; -import java.util.List; - -/** - * 朗读文本切分(纯逻辑,便于单测)。 - * - * 对齐 legado 的朗读分句策略: - * - 系统朗读按「段落」入队,交给 TTS 引擎处理长文本韵律; - * - 在线朗读按「分句」入队,因为每个分句是一次 HTTP/WS 请求, - * 分句越短首句越快出声、预取也越平滑。 - * - * 两种模式都丢弃纯标点/空白片段(legado 的 notReadAloudRegex 语义), - * 避免朗读引擎对「……」「——」之类片段报错或产生长时间静音。 - */ -public final class TtsTextSplitter { - - /** 单次在线合成的文本上限(URL 长度与首句时延的折中)。 */ - public static final int MAX_CHUNK = 160; - - private static final String SENTENCE_END = "。!?!?;;…\n"; - private static final String SOFT_BREAK = ",,、::"; - - private TtsTextSplitter() { - } - - /** 一个朗读片段:文本 + 它所属的段落下标(0 基)。 */ - public static final class Chunk { - public final String text; - public final int paragraph; - - public Chunk(String text, int paragraph) { - this.text = text; - this.paragraph = paragraph; - } - - @Override - public String toString() { - return paragraph + ":" + text; - } - } - - /** - * @param paragraphs 段落文本(来自阅读器正文) - * @param maxChars 单片段最大字数;<= 0 表示按段落(系统朗读) - */ - public static List split(List paragraphs, int maxChars) { - List out = new ArrayList<>(); - if (paragraphs == null) return out; - for (int i = 0; i < paragraphs.size(); i++) { - String paragraph = normalize(paragraphs.get(i)); - if (paragraph.isEmpty() || isSilent(paragraph)) continue; - if (maxChars <= 0) { - out.add(new Chunk(paragraph, i)); - continue; - } - for (String piece : splitParagraph(paragraph, maxChars)) { - if (piece.isEmpty() || isSilent(piece)) continue; - out.add(new Chunk(piece, i)); - } - } - return out; - } - - /** 规范化:去掉控制字符与多余空白,保留正文标点。 */ - public static String normalize(String text) { - if (text == null) return ""; - StringBuilder sb = new StringBuilder(text.length()); - for (int i = 0; i < text.length(); i++) { - char c = text.charAt(i); - if (c == '\r' || c == '\t' || c == '\u00a0' || c == '\u3000') { - sb.append(' '); - } else if (c < 0x20 && c != '\n') { - // 控制字符直接丢弃(朗读引擎遇到会报错) - } else { - sb.append(c); - } - } - return sb.toString().trim(); - } - - /** 是否没有可朗读内容(只有标点/空白/装饰符)。 */ - public static boolean isSilent(String text) { - if (text == null) return true; - for (int i = 0; i < text.length(); i++) { - if (isSpeakable(text.charAt(i))) return false; - } - return true; - } - - private static boolean isSpeakable(char c) { - // 中日韩统一表意文字在 Unicode 里就是 Letter,isLetterOrDigit 已覆盖; - // 这里只额外保留需要的边界判断,其余一律视为标点/装饰符。 - return Character.isLetterOrDigit(c); - } - - private static List splitParagraph(String paragraph, int maxChars) { - List sentences = new ArrayList<>(); - StringBuilder cur = new StringBuilder(); - for (int i = 0; i < paragraph.length(); i++) { - char c = paragraph.charAt(i); - cur.append(c); - if (SENTENCE_END.indexOf(c) >= 0) { - sentences.add(cur.toString().trim()); - cur.setLength(0); - } - } - if (cur.length() > 0) sentences.add(cur.toString().trim()); - List out = new ArrayList<>(); - for (String s : sentences) { - if (s.isEmpty()) continue; - if (s.length() <= maxChars) { - out.add(s); - } else { - out.addAll(hardSplit(s, maxChars)); - } - } - return out; - } - - /** 超长句:优先在逗号等软停顿处切,实在没有就按长度硬切。 */ - private static List hardSplit(String sentence, int maxChars) { - List out = new ArrayList<>(); - StringBuilder cur = new StringBuilder(); - for (int i = 0; i < sentence.length(); i++) { - char c = sentence.charAt(i); - cur.append(c); - boolean soft = SOFT_BREAK.indexOf(c) >= 0; - if (cur.length() >= maxChars || (soft && cur.length() >= maxChars / 2)) { - out.add(cur.toString().trim()); - cur.setLength(0); - } - } - if (cur.length() > 0) out.add(cur.toString().trim()); - List trimmed = new ArrayList<>(out.size()); - for (String s : out) if (!s.isEmpty()) trimmed.add(s); - return trimmed; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsVoice.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsVoice.java deleted file mode 100644 index 58a5a31bca..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/tts/TtsVoice.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.fongmi.android.tv.tts; - -/** 朗读音色。 */ -public final class TtsVoice { - - public final String id; - public final String name; - public final String lang; - - public TtsVoice(String id, String name, String lang) { - this.id = id == null ? "" : id; - this.name = name == null ? "" : name; - this.lang = lang == null ? "" : lang; - } - - @Override - public String toString() { - return id; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/AudioActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/AudioActivity.java index aaddea21ee..5d854489b2 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/AudioActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/AudioActivity.java @@ -1,6 +1,7 @@ package com.fongmi.android.tv.ui.activity; import android.app.Activity; +import android.app.AlertDialog; import android.content.Intent; import android.graphics.drawable.ColorDrawable; import android.os.Bundle; @@ -41,7 +42,6 @@ import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.audio.AudioHistory; import com.fongmi.android.tv.ui.audio.AudioMiniPlayer; import com.fongmi.android.tv.ui.audio.AudioPlaybackReconciliation; @@ -751,7 +751,7 @@ public class AudioActivity extends PlaybackActivity { } String[] labels = new String[options.size()]; for (int i = 0; i < options.size(); i++) labels[i] = options.get(i).getLabel(); - new WebHtvAlertDialogBuilder(this) + new AlertDialog.Builder(this) .setTitle("选择歌词") .setItems(labels, (dialog, which) -> applyManualLyric(request, options.get(which), title, subtitle, duration)) .show(); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java index cbcb9f5a8b..e1d48366ac 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java @@ -1,27 +1,16 @@ package com.fongmi.android.tv.ui.activity; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; import android.os.Bundle; -import android.view.WindowManager; +import android.text.TextUtils; import androidx.appcompat.app.AlertDialog; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.ActivityCrashBinding; -import com.fongmi.android.tv.server.Server; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.utils.AppVersion; -import com.fongmi.android.tv.utils.CrashLogSession; -import com.fongmi.android.tv.utils.CrashLogText; -import com.fongmi.android.tv.utils.CrashRestartMode; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.Task; -import com.github.catvod.utils.Util; +import com.github.catvod.utils.Prefers; import java.util.Objects; @@ -30,8 +19,6 @@ import cat.ereza.customactivityoncrash.CustomActivityOnCrash; public class CrashActivity extends BaseActivity { private ActivityCrashBinding mBinding; - private String logUrl; - private String localUrl; @Override protected boolean customWall() { @@ -46,123 +33,26 @@ public class CrashActivity extends BaseActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - // The address is meant to be read or copied from the screen; keep it visible. - getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + setCrash(); } @Override protected void initEvent() { mBinding.details.setOnClickListener(v -> showError()); - mBinding.restart.setOnClickListener(v -> restartApplication()); - mBinding.silentRestart.setOnClickListener(v -> { - CrashRestartMode.arm(); - restartApplication(); - }); - mBinding.copyLogAddress.setOnClickListener(v -> copyLogAddress()); - mBinding.openLog.setOnClickListener(v -> openLog()); - mBinding.logAddress.setText(R.string.crash_log_preparing); - setLogActionsEnabled(false); - Task.execute(this::startLogService); + mBinding.restart.setOnClickListener(v -> CustomActivityOnCrash.restartApplication(this, Objects.requireNonNull(CustomActivityOnCrash.getConfigFromIntent(getIntent())))); } - /** The crash screen is a dead end; leaving it would keep the LAN log service alive unseen. */ - @Override - protected void onBackInvoked() { - } - - @Override - protected void onDestroy() { - if (isFinishing()) { - try { - Server.get().shutdown(); - } catch (Throwable ignored) { + private void setCrash() { + String log = CustomActivityOnCrash.getActivityLogFromIntent(getIntent()); + if (TextUtils.isEmpty(log)) return; + String[] lines = log.split("\n"); + for (int i = lines.length - 1; i >= 0; i--) { + if (lines[i].isEmpty()) continue; + if (lines[i].contains(HomeActivity.class.getSimpleName())) { + Prefers.put("crash", true); + break; } } - super.onDestroy(); - } - - private void startLogService() { - try { - CrashLogSession.prepare(this); - } catch (Throwable ignored) { - // The address can still be offered; the page will report its own coverage. - } - boolean running = false; - try { - Server.get().start(); - running = Server.get().isRunning(); - } catch (Throwable ignored) { - } - if (!running) { - post(this::showLogFailure); - return; - } - String host; - try { - host = Util.getIp(); - } catch (Throwable ignored) { - host = ""; - } - String lan = Server.get().getAddress(false) + "/debug/logs"; - String local = Server.get().getAddress("/debug/logs"); - String lanHost = host; - post(() -> bindLogAddress(lanHost, lan, local)); - } - - private void bindLogAddress(String host, String lan, String local) { - localUrl = local; - if (host == null || host.isEmpty()) { - logUrl = local; - mBinding.logAddress.setText(getString(R.string.crash_log_no_lan, CrashLogText.wrapUrlForDisplay(local))); - } else { - logUrl = lan; - mBinding.logAddress.setText(CrashLogText.wrapUrlForDisplay(lan)); - } - mBinding.logHint.setText(R.string.crash_log_hint); - setLogActionsEnabled(true); - } - - private void showLogFailure() { - logUrl = null; - localUrl = null; - mBinding.logAddress.setText(R.string.crash_log_service_failed); - mBinding.logHint.setText(R.string.crash_log_unavailable); - setLogActionsEnabled(false); - } - - private void setLogActionsEnabled(boolean enabled) { - mBinding.copyLogAddress.setEnabled(enabled); - mBinding.openLog.setEnabled(enabled); - } - - private void copyLogAddress() { - if (logUrl == null) return; - ClipboardManager manager = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - if (manager == null) return; - manager.setPrimaryClip(ClipData.newPlainText(getString(R.string.crash_log_title), logUrl)); - Notify.show(R.string.crash_log_copied); - } - - private void openLog() { - if (localUrl == null) return; - try { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(localUrl))); - } catch (RuntimeException error) { - Notify.show(R.string.debug_log_no_browser); - } - } - - private void post(Runnable action) { - runOnUiThread(() -> { - try { - action.run(); - } catch (Throwable ignored) { - } - }); - } - - private void restartApplication() { - CustomActivityOnCrash.restartApplication(this, Objects.requireNonNull(CustomActivityOnCrash.getConfigFromIntent(getIntent()))); } private void showError() { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java deleted file mode 100644 index 4e3bb136dc..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java +++ /dev/null @@ -1,562 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import android.Manifest; -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.os.Build; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.View; -import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.LinearLayout; - -import androidx.annotation.Nullable; -import androidx.annotation.NonNull; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; -import androidx.recyclerview.widget.LinearLayoutManager; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.databinding.ActivityFollowingBinding; -import com.fongmi.android.tv.following.AlistSubscriptionImporter; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingNotifier; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingIdentity; -import com.fongmi.android.tv.following.FollowingMetadataSnapshot; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingSource; -import com.fongmi.android.tv.following.FollowingStore; -import com.fongmi.android.tv.following.FollowingUpdateCoordinator; -import com.fongmi.android.tv.following.FollowingUpdatePolicy; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.ui.adapter.FollowingAdapter; -import com.fongmi.android.tv.ui.custom.CustomWallView; -import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.PermissionUtil; -import com.fongmi.android.tv.utils.Task; -import com.fongmi.android.tv.utils.Util; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; - -public class FollowingActivity extends AppCompatActivity implements FollowingAdapter.Listener { - - public static final String EXTRA_IDENTITY_KEY = "following_identity_key"; - private static final int FILTER_ALL = 0; - private static final int FILTER_UPDATES = 1; - private static final int FILTER_UNWATCHED = 2; - private static final int FILTER_ENDED = 3; - private static final int FILTER_FAILED = 4; - private static final int FILTER_COUNT = 5; - private static final Comparator LIST_ORDER = Comparator - .comparing((Following item) -> !item.hasUpdate) - .thenComparing(Comparator.comparingInt((Following item) -> item.unwatchedCount).reversed()) - .thenComparing(Comparator.comparingLong((Following item) -> item.updatedAt).reversed()) - .thenComparing(Comparator.comparingLong((Following item) -> item.metadataUpdatedAt).reversed()) - .thenComparing(Comparator.comparingLong((Following item) -> item.createdAt).reversed()); - private ActivityFollowingBinding binding; - private FollowingAdapter adapter; - private String focusIdentity; - private String pendingNotifyIdentity; - private int filterIndex = FILTER_ALL; - private final ActivityResultLauncher notificationPermission = registerForActivityResult( - new ActivityResultContracts.RequestPermission(), granted -> { - if (pendingNotifyIdentity == null) return; - if (granted) { - FollowingSettings.setNotificationsEnabled(true); - String identityKey = pendingNotifyIdentity; - FollowingPlaybackBridge.setNotifyEnabledAsync(identityKey, true, error -> load()); - pendingNotifyIdentity = null; - return; - } - pendingNotifyIdentity = null; - load(); - }); - - public static Intent intent(Context context, String identityKey) { - Intent intent = new Intent(context, FollowingActivity.class); - if (!TextUtils.isEmpty(identityKey)) intent.putExtra(EXTRA_IDENTITY_KEY, identityKey); - return intent; - } - - public static void start(Activity activity, String identityKey) { - activity.startActivity(intent(activity, identityKey)); - } - - public static void start(Context context) { - context.startActivity(intent(context, "")); - } - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - setTheme(R.style.Theme_App); - super.onCreate(savedInstanceState); - // This page predates the shared BaseActivity, so it must opt into the same - // appearance contract by hand: resolve the persisted tokens before the first - // content view, then bind the tree once it exists and again after initView(). - ThemeController.applyFromPreferences(this); - if (!FollowingSettings.isEnabled()) { - Notify.show(R.string.following_enabled_hint); - finish(); - return; - } - binding = ActivityFollowingBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - ThemeController.bindTheme(binding.getRoot()); - addWallpaper(); - focusIdentity = getIntent().getStringExtra(EXTRA_IDENTITY_KEY); - FollowingNotifier.createChannel(); - FollowingScheduler.ensurePeriodic(this); - initView(); - ThemeController.bindTheme(binding.getRoot()); - FollowingScheduler.enqueueDueNow(this); - } - - /** - * 与其它内容页保持一致:全局壁纸作为页面背景,壁纸视图插在内容根视图之下。 - * 页面根布局保持透明,顶栏与卡片用半透明面板承载文字,动效跟随全局壁纸设置。 - */ - private void addWallpaper() { - ViewGroup content = findViewById(android.R.id.content); - if (content == null || content.getChildCount() == 0) return; - content.addView(new CustomWallView(this, null), 0, - new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - } - - private void initView() { - binding.recycler.setLayoutManager(new LinearLayoutManager(this)); - binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 8)); - binding.recycler.setAdapter(adapter = new FollowingAdapter(this)); - binding.check.setOnClickListener(view -> checkAll()); - binding.readAll.setOnClickListener(view -> markAllRead()); - binding.filter.setOnClickListener(view -> { - filterIndex = (filterIndex + 1) % FILTER_COUNT; - load(); - }); - binding.alistImport.setOnClickListener(view -> showServerImportDialog()); - load(); - } - - @Override - protected void onResume() { - super.onResume(); - if (binding != null) load(); - } - - @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - if (hasFocus) Util.hideSystemUI(this); - } - - private void load() { - binding.loading.setVisibility(View.VISIBLE); - Task.execute(() -> { - List following = FollowingStore.list(); - List rows = new ArrayList<>(); - for (Following item : following) { - if (!matchesFilter(item)) continue; - rows.add(new FollowingAdapter.Row(item, FollowingStore.preferredSource(item.identityKey))); - } - rows.sort((left, right) -> LIST_ORDER.compare(left.following, right.following)); - int unread = FollowingStore.unreadCount(); - App.post(() -> render(rows, unread)); - }); - } - - private void render(List rows, int unread) { - if (isFinishing() || binding == null) return; - binding.loading.setVisibility(View.GONE); - binding.empty.setVisibility(rows.isEmpty() ? View.VISIBLE : View.GONE); - binding.filter.setText(filterLabel()); - binding.emptyText.setText(filterIndex == FILTER_ALL ? R.string.following_empty : R.string.following_filter_empty); - binding.recycler.setVisibility(rows.isEmpty() ? View.GONE : View.VISIBLE); - binding.summary.setText(getString(R.string.following_summary, rows.size(), unread)); - adapter.setItems(rows); - int index = TextUtils.isEmpty(focusIdentity) ? -1 : adapter.indexOf(focusIdentity); - if (index >= 0) { - binding.recycler.post(() -> { - binding.recycler.scrollToPosition(index); - focusIdentity = null; - }); - } - } - - private void markAllRead() { - List keys = new ArrayList<>(); - for (FollowingAdapter.Row row : adapter.getItems()) { - if (row.following.hasUpdate && !TextUtils.isEmpty(row.following.identityKey)) keys.add(row.following.identityKey); - } - markReadNow(keys); - } - - private void markReadNow(List keys) { - if (keys == null || keys.isEmpty()) return; - adapter.markReadLocally(keys); - FollowingPlaybackBridge.markReadAllAsync(keys, error -> { - if (error != null) load(); - else refreshUnreadSummary(); - }); - } - - private void refreshUnreadSummary() { - FollowingPlaybackBridge.refreshUnreadCountAsync(unread -> { - if (binding == null || isFinishing() || isDestroyed()) return; - binding.summary.setText(getString(R.string.following_summary, adapter.getItemCount(), unread)); - }); - } - - private boolean matchesFilter(Following item) { - return switch (filterIndex) { - case FILTER_UPDATES -> item.hasUpdate || item.unwatchedCount > 0; - case FILTER_UNWATCHED -> item.unwatchedCount > 0; - case FILTER_ENDED -> FollowingMetadataSnapshot.ENDED.equals(item.officialStatus) - || FollowingMetadataSnapshot.CANCELED.equals(item.officialStatus); - case FILTER_FAILED -> FollowingUpdateCoordinator.sourceCheckFailed(item.lastError); - default -> true; - }; - } - - private int filterLabel() { - return switch (filterIndex) { - case FILTER_UPDATES -> R.string.following_filter_updates; - case FILTER_UNWATCHED -> R.string.following_filter_unwatched; - case FILTER_ENDED -> R.string.following_filter_ended; - case FILTER_FAILED -> R.string.following_filter_failed; - default -> R.string.following_filter_all; - }; - } - - private void checkAll() { - binding.check.setEnabled(false); - Notify.show(R.string.following_checking); - Task.execute(() -> { - List items = FollowingStore.list(); - if (items.isEmpty()) { - App.post(() -> { - if (binding == null) return; - binding.check.setEnabled(true); - load(); - }); - return; - } - FollowingUpdateCoordinator coordinator = new FollowingUpdateCoordinator(); - boolean success = false; - for (Following item : items) success |= coordinator.checkNow(item.identityKey, true); - boolean finalSuccess = success; - App.post(() -> { - if (binding == null) return; - binding.check.setEnabled(true); - Notify.show(finalSuccess ? R.string.following_check_done : R.string.following_check_failed); - load(); - }); - }); - } - - private void showServerImportDialog() { - LinearLayout form = new LinearLayout(this); - form.setOrientation(LinearLayout.VERTICAL); - int horizontal = dp(20); - form.setPadding(horizontal, dp(8), horizontal, 0); - EditText url = field(R.string.following_server_url); - EditText token = field(R.string.following_server_token); - token.setInputType(android.text.InputType.TYPE_CLASS_TEXT | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD); - form.addView(url, new LinearLayout.LayoutParams(-1, -2)); - form.addView(token, new LinearLayout.LayoutParams(-1, -2)); - new WebHtvAlertDialogBuilder(this) - .setTitle(R.string.following_server_import) - .setView(form) - .setNegativeButton(R.string.dialog_negative, null) - .setNeutralButton(R.string.following_server_preview, (dialog, which) -> startServerImport(url, token, true)) - .setPositiveButton(R.string.following_server_import_action, (dialog, which) -> startServerImport(url, token, false)) - .show(); - } - - private EditText field(int hint) { - EditText field = new EditText(this); - field.setHint(hint); - field.setSingleLine(true); - return field; - } - - private void startServerImport(EditText urlView, EditText tokenView, boolean preview) { - String url = urlView.getText().toString().trim(); - if (!url.startsWith("http://") && !url.startsWith("https://")) { - Notify.show(R.string.following_server_invalid); - return; - } - String token = tokenView.getText().toString(); - FollowingSettings.setServerImportEnabled(true); - binding.loading.setVisibility(View.VISIBLE); - Task.execute(() -> { - try { - List candidates = AlistSubscriptionImporter.fetch(url, token); - if (preview) { - App.post(() -> { - if (binding == null) return; - binding.loading.setVisibility(View.GONE); - showServerPreview(candidates); - }); - return; - } - AlistSubscriptionImporter.ImportResult result = AlistSubscriptionImporter.importCandidates(candidates); - App.post(() -> { - if (binding == null) return; - binding.loading.setVisibility(View.GONE); - Notify.show(getString(R.string.following_server_result, result.created, result.updated)); - load(); - }); - } catch (Throwable error) { - App.post(() -> { - if (binding != null) binding.loading.setVisibility(View.GONE); - Notify.show(error.getMessage()); - }); - } - }); - } - - private void showServerPreview(List candidates) { - if (candidates == null || candidates.isEmpty()) { - Notify.show(R.string.following_server_empty); - return; - } - StringBuilder message = new StringBuilder(); - int limit = Math.min(20, candidates.size()); - for (int i = 0; i < limit; i++) { - AlistSubscriptionImporter.Candidate candidate = candidates.get(i); - if (i > 0) message.append('\n'); - message.append(candidate.title).append(" · S").append(candidate.season + 1); - if (candidate.currentEpisodes > 0) message.append(" · E").append(candidate.currentEpisodes); - } - if (candidates.size() > limit) message.append("\n… +").append(candidates.size() - limit); - new WebHtvAlertDialogBuilder(this) - .setTitle(R.string.following_server_preview_title) - .setMessage(message) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.following_server_import_action, (dialog, which) -> { - Task.execute(() -> { - AlistSubscriptionImporter.ImportResult result = AlistSubscriptionImporter.importCandidates(candidates); - App.post(() -> { - Notify.show(getString(R.string.following_server_result, result.created, result.updated)); - load(); - }); - }); - }) - .show(); - } - - private int dp(int value) { - return Math.round(value * getResources().getDisplayMetrics().density); - } - - @Override - public void onOpenDetail(Following item, FollowingSource source) { - if (item == null) return; - StringBuilder message = new StringBuilder(); - int released = FollowingUpdatePolicy.releasedEpisode(item); - message.append(released > 0 ? getString(R.string.following_official, released) : getString(R.string.following_official_unknown)); - message.append('\n'); - message.append(source != null && source.playableEpisode > 0 - ? getString(R.string.following_source, source.playableEpisode) - : getString(FollowingUpdateCoordinator.sourceCheckFailed(item.lastError) - ? R.string.following_source_failed : R.string.following_source_unknown)); - message.append('\n'); - message.append(item.watchedEpisode > 0 ? getString(R.string.following_watched, item.watchedEpisode) : getString(R.string.following_watched_unknown)); - if (item.unwatchedCount > 0) message.append('\n').append(getString(R.string.following_unwatched, item.unwatchedCount)); - else if (released > 0) message.append('\n').append(getString(R.string.following_unwatched_none)); - if (item.lastError != null && !item.lastError.isBlank()) message.append('\n').append(item.lastError); - String[] actions = new String[]{ - getString(R.string.following_continue), - getString(R.string.following_check), - getString(R.string.following_read), - getString(R.string.following_change_source), - getString(R.string.following_cancel) - }; - new WebHtvAlertDialogBuilder(this) - .setTitle(item.vodName) - .setMessage(message) - .setNegativeButton(R.string.dialog_close, null) - .setItems(actions, (dialog, which) -> { - if (which == 0) onContinue(item, source); - else if (which == 1) onCheck(item); - else if (which == 2) onRead(item); - else if (which == 3) onChangeSource(item); - else if (which == 4) onDelete(item); - }) - .show(); - } - - @Override - public void onContinue(Following item, FollowingSource source) { - Task.execute(() -> { - FollowingStore.reconcile(item); - History history = FollowingStore.historyFor(item, source); - App.post(() -> { - if (isFinishing() || isDestroyed()) return; - if (history != null) { - VideoActivity.startFromFollowingHistory(this, history); - return; - } - FollowingSource target = preferred(item, source); - if (target == null || TextUtils.isEmpty(target.siteKey) || TextUtils.isEmpty(target.vodId)) { - Notify.show(R.string.following_check_failed); - return; - } - TmdbItem tmdb = item.tmdbId > 0 ? new TmdbItem(item.tmdbId, item.mediaType, item.vodName, - "", "", item.vodPic, "", "", 0.0) : null; - TmdbDetailActivity.start(this, target.siteKey, target.vodId, item.vodName, item.vodPic, "", tmdb, Setting.getDetailOpenMode()); - }); - }); - } - - @Override - public void onFollowNextSeason(Following item) { - if (item == null || item.latestReleasedSeason <= item.trackedSeason || TextUtils.isEmpty(item.seriesKey)) return; - String identityKey = FollowingIdentity.identityKey(item.seriesKey, item.latestReleasedSeason); - long now = System.currentTimeMillis(); - Task.execute(() -> { - if (FollowingStore.find(identityKey) != null) { - App.post(() -> FollowingActivity.start(this, identityKey)); - return; - } - Following next = item.copy(); - next.identityKey = identityKey; - next.trackedSeason = item.latestReleasedSeason; - next.trackedEpisode = 0; - next.watchedSeason = item.latestReleasedSeason; - next.watchedEpisode = 0; - next.position = 0; - next.duration = 0; - next.latestReleasedSeason = item.latestReleasedSeason; - next.latestReleasedEpisode = 0; - next.seasonTotalEpisodes = 0; - next.seasonReleasedEpisodes = 0; - next.nextAirSeason = item.nextAirSeason == next.trackedSeason ? item.nextAirSeason : 0; - next.nextAirEpisode = item.nextAirSeason == next.trackedSeason ? item.nextAirEpisode : 0; - next.nextAirAt = item.nextAirSeason == next.trackedSeason ? item.nextAirAt : 0; - next.readWatermarkEpisode = 0; - next.lastNotifiedEpisode = 0; - next.lastNotifiedAt = 0; - next.lastObservedEpisode = 0; - next.hasUpdate = false; - next.unwatchedCount = 0; - next.failureCount = 0; - next.lastError = ""; - next.createdAt = now; - next.updatedAt = now; - next.nextCheckAt = now; - FollowingSource source = FollowingStore.preferredSource(item.identityKey); - if (source != null) { - source = source.copy(); - source.followingKey = identityKey; - source.playableSeason = next.trackedSeason; - source.playableEpisode = 0; - source.playableCount = 0; - source.lastProbeAt = 0; - source.lastError = ""; - } - if (source == null) { - source = new FollowingSource(); - source.followingKey = identityKey; - source.cid = next.cid; - source.siteKey = next.siteKey; - source.vodId = next.vodId; - source.vodName = next.vodName; - source.vodPic = next.vodPic; - source.preferred = true; - } - try { - FollowingStore.saveNew(next, source); - App.post(() -> { - FollowingScheduler.enqueueDueNow(this); - Notify.show(R.string.following_added); - load(); - }); - } catch (Throwable error) { - App.post(() -> Notify.show(error.getMessage())); - } - }); - } - - @Override - public void onCheck(Following item) { - binding.loading.setVisibility(View.VISIBLE); - Task.execute(() -> { - boolean success = new FollowingUpdateCoordinator().checkNow(item.identityKey, true); - App.post(() -> { - if (binding == null) return; - Notify.show(success ? R.string.following_check_done : R.string.following_check_failed); - load(); - }); - }); - } - - @Override - public void onRead(Following item) { - if (item == null || TextUtils.isEmpty(item.identityKey)) return; - markReadNow(List.of(item.identityKey)); - } - - @Override - public void onToggleNotify(Following item) { - boolean enabled = !item.notifyEnabled; - if (enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - pendingNotifyIdentity = item.identityKey; - notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS); - return; - } - if (enabled) FollowingSettings.setNotificationsEnabled(true); - FollowingPlaybackBridge.setNotifyEnabledAsync(item.identityKey, enabled, error -> load()); - } - - @Override - public void onChangeSource(Following item) { - try { - Class type = Class.forName("com.fongmi.android.tv.ui.activity.SearchActivity"); - type.getMethod("start", Activity.class, String.class).invoke(null, this, item.vodName); - } catch (Throwable error) { - Notify.show(error.getMessage()); - } - } - - @Override - public void onDelete(Following item) { - new WebHtvAlertDialogBuilder(this, R.style.Theme_WebHTV_Dialog) - .setTitle(R.string.following_delete_title) - .setMessage(R.string.following_delete_message) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.following_cancel, (dialog, which) -> { - FollowingScheduler.cancelNext(this, item.identityKey); - FollowingPlaybackBridge.deleteAsync(item.identityKey, error -> load()); - }) - .show(); - } - - private FollowingSource preferred(Following item, FollowingSource source) { - if (source != null) return source; - FollowingSource target = new FollowingSource(); - target.followingKey = item.identityKey; - target.cid = item.cid; - target.siteKey = item.siteKey; - target.vodId = item.vodId; - target.vodName = item.vodName; - target.vodPic = item.vodPic; - target.preferred = true; - return target; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/HistoryResumeCoordinator.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/HistoryResumeCoordinator.java index c7bb9e3f5d..18cb0451e5 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/HistoryResumeCoordinator.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/HistoryResumeCoordinator.java @@ -19,12 +19,11 @@ import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.history.HistorySourceResolver; import com.fongmi.android.tv.playback.HistoryResumePayload; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.dialog.LightDialog; -import com.fongmi.android.tv.ui.novel.NovelRouter; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; @@ -40,8 +39,6 @@ public final class HistoryResumeCoordinator { if (activity == null || history == null) return; REQUESTS.incrementAndGet(); if (!Setting.isGlobalHistoryEnabled() || history.isCurrentSourceAvailable()) { - if (NovelRouter.openHistory(activity, history, VodConfig.getCid(), - () -> VideoActivity.startFromHistory(activity, history))) return; VideoActivity.startFromHistory(activity, history); return; } @@ -87,9 +84,6 @@ public final class HistoryResumeCoordinator { Notify.show(R.string.history_source_episode_missing); return; } - if (NovelRouter.openHistory(activity, current, resolved.vod(), resolved.flag(), resolved.episode(), targetCid, - () -> VideoActivity.startFromResolvedHistory(activity, current, - resolved.vod(), resolved.flag(), resolved.episode()))) return; VideoActivity.startFromResolvedHistory(activity, current, resolved.vod(), resolved.flag(), resolved.episode()); }); }); @@ -137,9 +131,6 @@ public final class HistoryResumeCoordinator { showSearchFallback(activity, current, targetCid); return; } - if (NovelRouter.openHistory(activity, current, resolved.vod(), resolved.flag(), resolved.episode(), targetCid, - () -> VideoActivity.startFromResolvedHistory(activity, current, - resolved.vod(), resolved.flag(), resolved.episode()))) return; VideoActivity.startFromResolvedHistory(activity, current, resolved.vod(), resolved.flag(), resolved.episode()); }); }); @@ -166,7 +157,7 @@ public final class HistoryResumeCoordinator { messageParams.leftMargin = ResUtil.dp2px(16); content.addView(message, messageParams); - AlertDialog loading = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog loading = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setView(content) .setCancelable(false) .create(); @@ -177,7 +168,7 @@ public final class HistoryResumeCoordinator { } private static void showSearchFallback(Activity activity, History history, int targetCid) { - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setMessage(R.string.history_source_not_found) .setNegativeButton(R.string.dialog_negative, null) .setPositiveButton(R.string.history_source_open_search, (ignored, which) -> openSearch(activity, history.getCid(), HistoryResumePayload.encode(history), targetCid, history.getVodName())) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java index 0151e8fa6c..3362f67631 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java @@ -15,21 +15,15 @@ import android.view.TextureView; import android.view.View; import android.view.ViewTreeObserver; import android.view.WindowManager; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.ViewConfiguration; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; -import androidx.lifecycle.Lifecycle; import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Format; import androidx.media3.common.Player; import androidx.media3.common.VideoSize; -import androidx.media3.mpvplayer.MpvPlayer; -import androidx.media3.mpvplayer.MpvDiscMenuPolicy; import androidx.media3.exoplayer.drm.FrameworkMediaDrm; import androidx.media3.session.MediaController; import androidx.media3.session.SessionToken; @@ -37,12 +31,9 @@ import androidx.media3.ui.AspectRatioFrameLayout; import androidx.media3.ui.PlayerView; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.Sub; import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.player.PlaybackAutoContext; -import com.fongmi.android.tv.player.PlaybackOwnershipKeys; import com.fongmi.android.tv.player.PlaybackServiceReleasePolicy; import com.fongmi.android.tv.player.PlaybackTelemetry; import com.fongmi.android.tv.player.PlayerManager; @@ -51,15 +42,12 @@ import com.fongmi.android.tv.player.engine.PlaySpec; import com.fongmi.android.tv.player.exo.ExoOutputModeManager; import com.fongmi.android.tv.player.exo.ExoOutputModePolicy; import com.fongmi.android.tv.player.exo.ExoUtil; -import com.fongmi.android.tv.plugin.PluginHostEvents; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; -import com.fongmi.android.tv.player.exo.ass.ExoAssSession; -import com.fongmi.android.tv.player.exo.subtitle.ExoSubtitleSession; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.subtitle.RealtimeSubtitleController; import com.fongmi.android.tv.ui.base.BaseActivity; -import com.fongmi.android.tv.ui.dialog.DiscMenuDialog; +import com.fongmi.android.tv.ui.dialog.AdSkipPromptPresenter; import com.fongmi.android.tv.ui.dialog.VideoAspectModeDialog; import com.fongmi.android.tv.ui.novel.NovelRouter; import com.fongmi.android.tv.ui.custom.CustomSeekView; @@ -72,7 +60,6 @@ import java.util.function.IntConsumer; public abstract class PlaybackActivity extends BaseActivity implements MediaController.Listener, Player.Listener, ServiceConnection { private static final String SIZE_TAG = "MPV_SIZE"; - private static final String STATE_PLAYBACK_KEY = "playback:ownershipKey"; private ListenableFuture mControllerFuture; private MediaController mController; @@ -81,24 +68,14 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont private boolean redirect; private boolean playbackExiting; private String preparedPlaybackKey; - private String pinnedPlaybackKey; private boolean nativeOutputPending; private boolean bound; private boolean stop; private boolean lock; private int render = -1; private int requestedAspectMode = VideoAspectMode.ORIGINAL; - private boolean resizeApplied; - private int resizeAppliedMode; - private int resizeAppliedEffectiveMode; - private int resizeAppliedTargetAspect; - private int resizeAppliedContentAspect; - private boolean resizeAppliedStretch; private ExoOutputModeManager exoOutputModeManager; - private ExoAssSession attachedAssSession; - private ExoSubtitleSession attachedSubtitleSession; - private final com.fongmi.android.tv.player.SurfaceDiagnosticCollector surfaceDiagnostics = - new com.fongmi.android.tv.player.SurfaceDiagnosticCollector(); + private AdSkipPromptPresenter adSkipPromptPresenter; protected MediaController controller() { return mController; @@ -116,6 +93,16 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont return mService != null && mService.player() != null && !mService.player().isReleased(); } + private void bindAdAudioPrompt() { + if (!isServiceReady() || !isOwner()) return; + if (adSkipPromptPresenter == null) adSkipPromptPresenter = new AdSkipPromptPresenter(this); + player().bindAdAudioUi(adSkipPromptPresenter); + } + + private void unbindAdAudioPrompt() { + if (isServiceReady() && isOwner()) player().unbindAdAudioUi(); + } + protected View.OnClickListener guarded(Runnable action) { return v -> { if (isServiceReady()) action.run(); @@ -136,7 +123,7 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont this.redirect = redirect; if (mService == null) return; if (redirect) mService.clearNavigationCallback(getNavigationCallback()); - else mService.setNavigationCallback(getNavigationCallback(), activePlaybackKey()); + else mService.setNavigationCallback(getNavigationCallback(), getPlaybackKey()); } protected boolean isPlaybackExiting() { @@ -153,29 +140,16 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont finish(); } - /** - * 退出播放页时的确定性停播。 - * - *

归属令牌({@link #activePlaybackKey()})在一次会话里可能失配:换集、onNewIntent 换条目, - * 或 TMDB 富集改写 intent 的 id 之后 {@link #isOwner()} 会落到 false。旧实现此时退化成 - * {@code mController.stop()},而控制器是异步连上的、播放又是由服务侧直接起播的,于是留下两个 - * 漏播窗口:控制器还没连上时整次调用是空操作;播放器处于 IDLE(取流中退出)时会被会话里的 - * stop 直接忽略。两者都不撤销前台通知,用户看到的就是「退回详情页后还有声音、通知栏还显示播放中」。 - * - *

所以只要服务还在,退出就必须由服务自己停播:归属方关停服务,非归属方也让服务停播并撤掉 - * 前台通知,避免退出被迟到的会话状态吞掉。控制器只保留为「服务不可用」时的兜底。 - */ protected void stopPlayback() { - if (mService == null) { - if (mController != null) mController.stop(); - return; + if (mService != null && isOwner()) { + mService.shutdown(); + } else if (mController != null) { + mController.stop(); } - if (isOwner()) mService.shutdown(); - else mService.suspend(); } protected void updateNavigationKey() { - if (mService != null) mService.setNavigationCallback(getNavigationCallback(), activePlaybackKey()); + if (mService != null) mService.setNavigationCallback(getNavigationCallback(), getPlaybackKey()); } protected boolean isAudioOnly() { @@ -210,56 +184,14 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont protected abstract String getPlaybackKey(); - /** - * Last known total duration for the current item. Engines that never publish - * a duration (EXO on some HLS/TS sources) leave the timeline at 0, so the - * seek view uses this while the engine still reports an unknown duration. - */ - protected long getDurationFallbackMs() { - return C.TIME_UNSET; - } - protected boolean deferPlaybackServiceBinding() { return false; } - /** - * 本次播放会话的归属令牌。 - * - *

{@link #getPlaybackKey()} 由子类从 intent 现算,而 intent 的 id 会在起播之后被 - * 详情结果改写(TMDB 富集回来的 vodId,见 VideoActivity#updateVod)。播放器里的 key 是 - * 起播那一刻固化进 PlaySpec 的,之后无从更改;两者一旦不等,{@link #isOwner()} 便永久 - * 为 false,于是 STATE_READY 不再下发(转圈不收)、每秒的进度采样直接返回(进度不落库、 - * 刷新回起点)、切集也因 PlaybackService#isNavigationOwner 失配而不派发。 - * - *

该 key 的语义是"归属与路由令牌",必须在一次播放会话内保持稳定;History 行的 key - * 迁移是另一件事,二者解耦。因此起播时钉住,会话结束或换集重新起播时再更新。 - */ - protected final String activePlaybackKey() { - return pinnedPlaybackKey != null ? pinnedPlaybackKey : getPlaybackKey(); - } - - /** - * 丢弃上一次播放会话的归属令牌,让 {@link #activePlaybackKey()} 重新回落到 intent。 - * 换条目(onNewIntent)时调用:此时旧会话已作废,新会话尚未起播。 - */ - protected final void resetPlaybackOwnership() { - pinnedPlaybackKey = null; - } - - /** - * 本次播放会话是否由本页面拥有。 - * - *

比较必须走 {@link PlaybackOwnershipKeys#sameSession(String, String)} 而不是字符串相等: - * 自动连播时服务侧用「浏览下一集」的 {@code VE:|<线路>|<集>} 键起播,与本页面现算的 - * 记录键不是同一个字符串,但仍是同一次播放会话。旧实现在这里判假,导致回调里的 - * {@code onPlayerRebuild(...)} 整段跳过、Surface 永不重绑 —— 设备日志里那次「有声音没画面、 - * 重开才恢复」正是这条路径。 - */ protected boolean isOwner() { - String key = activePlaybackKey(); + String key = getPlaybackKey(); PlayerManager manager = player(); - return PlaybackOwnershipKeys.sameSession(key, manager == null ? null : manager.getKey()); + return key == null || (manager != null && key.equals(manager.getKey())); } public final boolean pauseForOverlayPlayback() { @@ -304,73 +236,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont return !isBuffering() && !isIdle(); } - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - if (dispatchDiscMenuKey(event)) return true; - return super.dispatchKeyEvent(event); - } - - protected boolean dispatchDiscMenuKey(KeyEvent event) { - if (mService == null || !isOwner() || event == null) return false; - Player active = player().getPlayer(); - if (!(active instanceof MpvPlayer mpv)) return false; - String action = MpvDiscMenuPolicy.keyAction(event.getKeyCode()); - if (action == null) return false; - if (!mpv.isDiscMenuActive() - && !(event.getKeyCode() == KeyEvent.KEYCODE_MENU && mpv.isDiscMenuAvailable())) return false; - if (event.getAction() != KeyEvent.ACTION_DOWN || event.getRepeatCount() > 0) return true; - mpv.sendDiscNav(action); - return true; - } - - protected boolean hasDiscMenu() { - return mService != null && isOwner() && player().getPlayer() instanceof MpvPlayer mpv - && mpv.isDiscMenuAvailable(); - } - - protected boolean hasDiscNavigationTimeline() { - return mService != null && isOwner() && player().getPlayer() instanceof MpvPlayer mpv - && mpv.hasDiscNavigationTimeline(); - } - - protected boolean canSavePlaybackHistory(History history) { - if (history == null) return false; - boolean navigationStarted = mService != null && isOwner() - && player().getPlayer() instanceof MpvPlayer mpv - && mpv.hasStartedDiscNavigation(); - return MpvDiscMenuPolicy.canSaveHistory(history.canSave(), navigationStarted); - } - - protected void showDiscMenuControls() { - if (hasDiscMenu()) DiscMenuDialog.show(this, (MpvPlayer) player().getPlayer()); - } - - protected void openDiscMenu() { - if (hasDiscMenu()) ((MpvPlayer) player().getPlayer()).sendDiscNav("menu"); - } - - protected boolean dispatchDiscMenuTouch(MotionEvent event) { - if (isLock() || mService == null || !isOwner()) return false; - Player active = player().getPlayer(); - if (!(active instanceof MpvPlayer mpv) || !mpv.isDiscMenuActive()) return false; - View surface = getExoView().getVideoSurfaceView(); - if (surface == null || event.getPointerCount() != 1) return false; - int[] origin = new int[2]; - surface.getLocationOnScreen(origin); - int pointerX = Math.round(event.getRawX() - origin[0]); - int pointerY = Math.round(event.getRawY() - origin[1]); - if (pointerX < 0 || pointerY < 0 || pointerX >= surface.getWidth() || pointerY >= surface.getHeight()) return false; - if (event.getActionMasked() == MotionEvent.ACTION_UP - && event.getEventTime() - event.getDownTime() >= ViewConfiguration.getLongPressTimeout()) { - showDiscMenuControls(); - } else if (event.getActionMasked() == MotionEvent.ACTION_DOWN - || event.getActionMasked() == MotionEvent.ACTION_MOVE - || event.getActionMasked() == MotionEvent.ACTION_UP) { - mpv.sendDiscNavPointer(pointerX, pointerY, event.getActionMasked() == MotionEvent.ACTION_UP); - } - return true; - } - protected void onServiceConnected() { } @@ -384,42 +249,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont protected void onPlayerRebuilt() { } - /** - * 保证「当前引擎」此刻确实挂在 PlayerView 上,必要时补一次绑定。 - * - *

这是设备日志里那次「有声音没画面、重开才恢复」的收口点:自动连播由服务侧 - * {@code PlaybackService#startBrowse} 用 {@code VE:} 选集键起播,页面一度判不出归属, - * 于是既没有重建回调去重绑,也没有其它路径补绑;解码器与音频照常工作,视频帧却全部被丢弃。 - * - *

与 {@link #attachSurface(boolean)} 的区别:它不重设 render、不接 ASS/字幕会话,也不解绑 - * (引擎未就绪时直接返回),只把「当前引擎」补到视图上并重放当前快门策略;绑定已正确时立刻 - * 返回。且只在页面 RESUMED 时执行——后台的旧播放页同样会收到服务侧回调,在那里补绑会把引擎 - * 从用户正在看的页面上抢走。 - */ - private void ensureVideoSurfaceBound(String reason) { - if (mService == null || isFinishing() || isDestroyed()) return; - // 只有正处在 RESUMED 的页面可能是这条会话的显示方。后台/返回栈里的旧播放页同样会收到 - // 服务侧回调;在那里补绑会把同一个引擎从用户正在看的页面上抢走,结果还是黑屏。 - if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) return; - PlayerManager manager = player(); - if (manager == null || manager.isReleased()) return; - PlayerView view = getExoView(); - if (view == null) return; - Player currentPlayer = view.getPlayer(); - Player nextPlayer = manager.getPlayer(); - // 引擎尚未就绪时保持现状:这个兜底路径只负责「绑上当前引擎」,绝不在这里解绑。 - if (nextPlayer == null) return; - VideoSurfaceBinding.Result binding = VideoSurfaceBinding.resolve(currentPlayer, nextPlayer); - if (!binding.applied()) return; - if (binding.detachedFirst()) view.setPlayer(null); - view.setPlayer(binding.player()); - surfaceDiagnostics.rebind(reason, binding.detachedFirst(), view.getVideoSurfaceView()); - logSurfaceState("ensure bound reason=" + reason); - syncVideoSurfaceSize(null); - syncShutter(); - if (manager.isNativePlayer()) view.post(this::syncShutter); - } - /** * The rendered frame is revealed by clearing the shutter in the caller. The * loading spinner deliberately stays up until STATE_READY: a first frame does @@ -433,13 +262,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont protected void onTracksChanged() { } - /** - * 用户选中了一个外挂字幕。有本地历史语义的子类覆写它把来源记下来, - * 好让下次从历史进来时自动挂回同一个字幕。 - */ - protected void onSubtitleSelected(Sub sub) { - } - protected void onTitlesChanged() { } @@ -497,19 +319,12 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont PlayerView view = getExoView(); VideoAspectMode.Spec spec = VideoAspectMode.resolve(resizeMode, viewportAspectRatio(view), PlayerSetting.getCustomAspectRatio()); int effectiveResizeMode = effectiveResizeMode(spec.resizeMode()); - float contentAspectRatio = spec.hasTargetAspectRatio() ? spec.targetAspectRatio() : sourceAspectRatio(); - if (!VideoAspectMode.isValidRatio(contentAspectRatio)) contentAspectRatio = 0f; - // 同一次进入播放页里,这个方法会被 onPlayerRenderRequired / onPlayerRebuild / 子类 setScale - // 以及 applyResizeMode 自己排的第二次 view.post 反复触发(设备实测 14-16 对)。 - // 每一对都要拼 20+ 字段的日志、写 4 行 logcat、再 requestLayout 两次, - // 全都压在首帧窗口的主线程上。决策输入完全相同时直接返回。 - // 键包含所有影响结果的量(模式/有效模式/目标比例/来源比例/stretch), - // 任一输入变化(视频尺寸到位、切集、切渲染、改比例)都会让键不同,仍然真实施加。 - if (isResizeModeApplied(resizeMode, effectiveResizeMode, spec.targetAspectRatio(), contentAspectRatio, spec.stretch())) return; logSurfaceState("applyResizeMode before mode=" + resizeMode + " effective=" + effectiveResizeMode + " aspect=" + spec.targetAspectRatio()); if (mService != null) player().setVideoAspect(spec.targetAspectRatio(), spec.stretch()); view.setResizeMode(effectiveResizeMode); AspectRatioFrameLayout content = view.findViewById(androidx.media3.ui.R.id.exo_content_frame); + float contentAspectRatio = spec.hasTargetAspectRatio() ? spec.targetAspectRatio() : sourceAspectRatio(); + if (!VideoAspectMode.isValidRatio(contentAspectRatio)) contentAspectRatio = 0f; if (content != null) content.setAspectRatio(contentAspectRatio); view.requestLayout(); View surface = view.getVideoSurfaceView(); @@ -517,30 +332,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont logSurfaceState("applyResizeMode after mode=" + resizeMode + " effective=" + effectiveResizeMode + " aspect=" + contentAspectRatio); } - private boolean isResizeModeApplied(int resizeMode, int effectiveResizeMode, - float targetAspectRatio, float contentAspectRatio, boolean stretch) { - int targetBits = Float.floatToIntBits(targetAspectRatio); - int contentBits = Float.floatToIntBits(contentAspectRatio); - if (resizeApplied && resizeAppliedMode == resizeMode - && resizeAppliedEffectiveMode == effectiveResizeMode - && resizeAppliedTargetAspect == targetBits - && resizeAppliedContentAspect == contentBits - && resizeAppliedStretch == stretch) { - return true; - } - resizeApplied = true; - resizeAppliedMode = resizeMode; - resizeAppliedEffectiveMode = effectiveResizeMode; - resizeAppliedTargetAspect = targetBits; - resizeAppliedContentAspect = contentBits; - resizeAppliedStretch = stretch; - return false; - } - - private void invalidateAppliedResizeMode() { - resizeApplied = false; - } - private float viewportAspectRatio(PlayerView view) { View container = view.getParent() instanceof View parent ? parent : view; int width = container.getWidth(); @@ -576,10 +367,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont return PlayerSetting.isBackgroundOff(); } - protected boolean shouldAutoPlay() { - return PlayerSetting.isAutoPlay(); - } - protected boolean seekTo(long deltaMs) { onSeekStarted(); long targetMs = Math.max(0, player().getPosition() + deltaMs); @@ -596,13 +383,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont protected void startPlayer(String key, Result result, boolean useParse, long timeout, MediaMetadata metadata, long startPositionMs) { - // 已经决定退出的播放页不能再起播。自动换源、换线路、取流结果这些链路最终都回到这里, - // 迟到的一次起播会在服务里正常播放并挂上前台通知,而页面已经退出,没有人再停它—— - // 用户看到的就是「退回详情页后还有声音、通知栏还显示播放中」。 - if (isPlaybackExiting() || isFinishing() || isDestroyed()) { - if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-flow", "startPlayer skipped exiting=%s finishing=%s destroyed=%s key=%s", isPlaybackExiting(), isFinishing(), isDestroyed(), key); - return; - } // 小说/漫画阅读器路由(播放内核前的最后一道拦截) // novel:// / pics:// / manga:// 是「阅读内容协议」而非播放地址。 // 正常情况下 ContentDispatcher 已在更早的汇聚点分流;这里兜底处理漏网的解析结果。 @@ -620,14 +400,12 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont onError(ResUtil.getString(R.string.error_play_url)); } else if (result.needParse() || useParse) { preparedPlaybackKey = null; - pinnedPlaybackKey = key; attachSurface(); - player().parse(key, result, useParse, metadata, shouldAutoPlay(), startPositionMs); + player().parse(key, result, useParse, metadata, PlayerSetting.isAutoPlay(), startPositionMs); } else { preparedPlaybackKey = null; - pinnedPlaybackKey = key; attachSurface(); - player().start(PlaySpec.from(result, key, metadata), timeout, shouldAutoPlay(), startPositionMs); + player().start(PlaySpec.from(result, key, metadata), timeout, PlayerSetting.isAutoPlay(), startPositionMs); } syncKeepScreenOn(); } @@ -649,7 +427,7 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont } private boolean isSelectedMpvPlayer() { - return mService != null ? player().isMpv() : PlayerSetting.getActivePlayer() == PlayerSetting.MPV; + return mService != null ? player().isMpv() : PlayerSetting.getPlayer() == PlayerSetting.MPV; } private void bindPlaybackService() { @@ -664,7 +442,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont private void bindPlaybackServiceAfterFirstFrame() { View root = getExoView().getRootView(); - root.postDelayed(this::bindPlaybackServiceIfIdle, 500L); root.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { @@ -677,14 +454,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont }); } - // C101: safety net for the deferred binding. If the first frame never arrives (the - // activity is finishing or never draws), playback must still connect instead of - // hanging on the loading spinner forever. - private void bindPlaybackServiceIfIdle() { - if (isFinishing() || isDestroyed() || bound) return; - bindPlaybackService(); - } - private void buildControllerAsync(SessionToken token) { if (mControllerFuture != null || shouldRejectPlaybackConnection()) return; if (token == null) { @@ -720,7 +489,7 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont MediaItem controllerItem = mController.getCurrentMediaItem(); String managerMediaId = managerItem == null ? null : managerItem.mediaId; String controllerMediaId = controllerItem == null ? null : controllerItem.mediaId; - if (!PlaybackStateReconciliation.shouldReplayReady(activePlaybackKey(), preparedPlaybackKey, manager.getKey(), managerMediaId, controllerMediaId, manager.getPlaybackState(), mController.getPlaybackState())) return; + if (!PlaybackStateReconciliation.shouldReplayReady(getPlaybackKey(), preparedPlaybackKey, manager.getKey(), managerMediaId, controllerMediaId, manager.getPlaybackState(), mController.getPlaybackState())) return; onControllerReadyReconciled(); } @@ -731,7 +500,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont mController = mControllerFuture.get(); getSeekView().setPlayer(mController); getSeekView().setSeekListener(this::onSeekStarted); - getSeekView().setDurationFallback(this::getDurationFallbackMs); onControllerReady(mController); mController.addListener(this); reconcileControllerReadyState(); @@ -784,23 +552,8 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont else hideVideoShutter(); logSurfaceState("attach after setRender target=" + targetRender); } - surfaceDiagnostics.bind(getExoView(), player().getPlaybackTraceId()); - // 切集换新媒体前 PlayerManager.clear() 会把 spec 置空,preparePlayer() 再重建引擎。 - // 此时 render 值通常不变,旧的 attachSurface 只按“PlayerView 是否为空”判断, - // 会把已释放的旧引擎继续留在 PlayerView 上,新引擎从未绑定 Surface。 - // 以引擎实例身份而不是“是否为空”判断,自动下一集的黑屏路径才能强制重绑。 - // - // 判定本身交给 VideoSurfaceBinding:口径是「PlayerView 上挂着的引擎实例是否就是当前引擎」, - // 与 Activity 是否自认归属无关。设备日志里的那次「有声音没画面」正因归属判假而漏绑。 - Player currentPlayer = getExoView().getPlayer(); - Player nextPlayer = player().getPlayer(); - VideoSurfaceBinding.Result binding = VideoSurfaceBinding.resolve(currentPlayer, nextPlayer); - if (binding.applied()) { - if (binding.detachedFirst()) getExoView().setPlayer(null); - getExoView().setPlayer(binding.player()); - // 换了引擎实例,PlayerView 内部状态与视频尺寸都可能变,比例必须重算一次。 - invalidateAppliedResizeMode(); - surfaceDiagnostics.rebind("attach", binding.detachedFirst(), getExoView().getVideoSurfaceView()); + if (getExoView().getPlayer() == null) { + getExoView().setPlayer(player().getPlayer()); logSurfaceState("attach after setPlayer"); syncVideoSurfaceSize(null); if (restoreExoShutter) syncShutter(); @@ -808,18 +561,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont if (player().isNativePlayer()) getExoView().post(this::syncShutter); } publishRenderTarget(getExoView().getVideoSurfaceView()); - ExoAssSession assSession = player().getAssSession(); - if (attachedAssSession != assSession) { - detachAssSurface(); - attachedAssSession = assSession; - } - if (attachedAssSession != null) attachedAssSession.attach(getExoView()); - ExoSubtitleSession subtitleSession = player().getSubtitleSession(); - if (attachedSubtitleSession != subtitleSession) { - if (attachedSubtitleSession != null) attachedSubtitleSession.detach(); - attachedSubtitleSession = subtitleSession; - } - if (attachedSubtitleSession != null) attachedSubtitleSession.attach(getExoView()); onSurfaceAttached(); logSurfaceState("attach done"); } @@ -829,7 +570,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont View surface = getExoView().getVideoSurfaceView(); if (!(surface instanceof SurfaceView surfaceView)) return; if (!PlaybackPerformanceSetting.isSurfaceFixedSizeEnabled() || getRender() != PlayerSetting.RENDER_SURFACE || player().isNativePlayer()) { - surfaceDiagnostics.resize("layout", -1, -1); surfaceView.getHolder().setSizeFromLayout(); logSurfaceState("syncVideoSurfaceSize layout size=" + (size == null ? "null" : size.width + "x" + size.height)); return; @@ -843,7 +583,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont width = Math.max(1, Math.round(width * scale)); height = Math.max(1, Math.round(height * scale)); } - surfaceDiagnostics.resize("fixed", width, height); surfaceView.getHolder().setFixedSize(width, height); logSurfaceState("syncVideoSurfaceSize fixed=" + width + "x" + height); } @@ -920,7 +659,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont getExoView().setShutterBackgroundColor(Color.BLACK); if (shutter != null) shutter.setVisibility(View.VISIBLE); } - surfaceDiagnostics.snapshot("shutter-policy"); } private void hideVideoShutter() { @@ -930,21 +668,11 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont } private void detachSurface() { - surfaceDiagnostics.unbind(); - detachAssSurface(); getExoView().setPlayer(null); if (mService != null) player().publishPlaybackRenderTarget(PlaybackAutoContext.RenderTarget.DETACHED); } - private void detachAssSurface() { - if (attachedAssSession != null) attachedAssSession.detach(); - attachedAssSession = null; - if (attachedSubtitleSession != null) attachedSubtitleSession.detach(); - attachedSubtitleSession = null; - } - private void resetVideoSurfaceForDecoderSwitch() { - detachAssSurface(); int targetRender = getRender(); int temporaryRender = targetRender == PlayerSetting.RENDER_TEXTURE ? PlayerSetting.RENDER_SURFACE : PlayerSetting.RENDER_TEXTURE; if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-flow", "reset video surface for decoder switch temp=%d target=%d", temporaryRender, targetRender); @@ -952,8 +680,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont getExoView().setRender(temporaryRender); getExoView().setRender(targetRender); render = -1; - // 引擎换过、PlayerView 也被重置过,上一次施加的比例不再可信。 - invalidateAppliedResizeMode(); } protected void reattachVideoSurfaceAfterReparent() { @@ -1020,9 +746,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont bound = false; if (mService != null) mService.removePlayerCallback(mPlayerCallback); getSeekView().setProgressPlayer(null); - // 页面解绑后引擎可能被别的页面接管,兜底必须跟着本页面一起撤掉, - // 否则别的播放会读到上一集的时长。 - if (player() != null) player().setDurationFallback(null); unbindService(this); mService = null; } @@ -1133,19 +856,10 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont if (shutter != null) shutter.setVisibility(View.INVISIBLE); getExoView().setShutterBackgroundColor(Color.TRANSPARENT); PlaybackActivity.this.onExoFirstFrame(); - PlaybackActivity.this.onFirstFrameRendered(); - } - - @Override - public void onSubtitleSelected(Sub sub) { - if (isOwner()) PlaybackActivity.this.onSubtitleSelected(sub); } @Override public void onPlayerRebuild(Player player, boolean resetVideoSurface) { - // 归属必须与「当前引擎是否属于本页面」一致:AUTONEXT-02 之前这里判假的原因是 - // 服务侧用 VE: 选集键起播而页面拿记录键比对(见 PlaybackOwnershipKeys)。判假时 - // 整段被跳过,新引擎永远拿不到 Surface,画面全黑而声音正常。 if (isOwner()) { nativeOutputPending = player().shouldKeepVideoShutterClosed(); getSeekView().setProgressPlayer(player); @@ -1161,7 +875,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont protected void initView(Bundle savedInstanceState) { long start = System.currentTimeMillis(); super.initView(savedInstanceState); - restorePlaybackKey(savedInstanceState); if (!shouldBindPlaybackService()) return; ExoUtil.setPlayerView(getExoView()); RealtimeSubtitleController.get().bind(getExoView()); @@ -1171,27 +884,9 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-flow", "initView cost=%dms key=%s deferred=%s", System.currentTimeMillis() - start, getPlaybackKey(), deferPlaybackServiceBinding()); } - private void restorePlaybackKey(Bundle savedInstanceState) { - if (savedInstanceState == null) return; - String key = savedInstanceState.getString(STATE_PLAYBACK_KEY); - if (key != null) pinnedPlaybackKey = key; - } - - @Override - protected void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - if (!playbackExiting && pinnedPlaybackKey != null) { - outState.putString(STATE_PLAYBACK_KEY, pinnedPlaybackKey); - } - } - @Override public void onIsPlayingChanged(boolean isPlaying) { syncKeepScreenOn(); - // 埋点放在 isOwner() 早退之前:同一台设备上非 owner 的页面同样在播(服务侧起播、画中画、 - // 多窗口),只有 owner 才广播会让订阅者以为"播放停了"。埋点只带宿主身份与本页拿到的 - // 布尔值,不触碰视图也不再读 controller,越权观感与早退后的既有逻辑无关。 - PluginHostEvents.playbackPlaying(this, isPlaying); if (!isOwner()) return; syncShutter(); if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "playing changed isPlaying=%s state=%d %s", isPlaying, mController == null ? -1 : mController.getPlaybackState(), lifecycleState()); @@ -1210,18 +905,13 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { @Override public void onPlaybackStateChanged(int state) { - // 同样放在 isOwner() 早退之前:Surface 绑定那段注释已经证明状态回调对非 owner 也照常到达, - // 而状态机是播放器的客观事实,不随本页是否有所有权而改变。 - PluginHostEvents.playbackState(this, state); - // 绑定视频 Surface 是所有权无关的必经动作:服务侧「浏览下一集」(VE: 选集键)起播时 - // isOwner() 为假,旧实现让下面的早退把 Surface 重绑一起吞掉,新引擎于是永远拿不到 - // Surface —— 解码器/音频正常、画面全黑,只有重开 App 才好。每次 BUFFERING/READY 都是 - // 新媒体起播的必经点,在这里补一次「当前引擎是否已挂在视图上」的校验。 - if (state == Player.STATE_BUFFERING || state == Player.STATE_READY) ensureVideoSurfaceBound("state=" + state); - if (mService != null && isOwner()) surfaceDiagnostics.bind(getExoView(), player().getPlaybackTraceId()); if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "state changed state=%d %s", state, lifecycleState()); syncKeepScreenOn(); if (!isOwner()) return; + // Ownership is established after onServiceConnected/onResume have already run, so + // the earlier bind attempts were rejected by the isOwner() guard. Bind here too or + // the ad-audio runtime never gets a UI and stays deactivated for the whole session. + bindAdAudioPrompt(); syncShutter(); onStateChanged(state); } @@ -1262,8 +952,6 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { private void publishDisplayFacts(ExoOutputModeManager.Result result) { if (mService == null || result == null) return; - surfaceDiagnostics.snapshot("display-mode-request"); - surfaceDiagnostics.display(result); player().publishPlaybackDisplayFacts( toDisplayMode(result.currentMode()), toDisplayMode(result.requestedMode())); @@ -1341,30 +1029,28 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { mService.replaceBinding(this::closePiP); mService.setSessionActivity(buildSessionIntent()); mService.setPlaybackForeground(true); - mService.setNavigationCallback(getNavigationCallback(), activePlaybackKey()); + mService.setNavigationCallback(getNavigationCallback(), getPlaybackKey()); mService.addPlayerCallback(mPlayerCallback); getSeekView().setProgressPlayer(player().getPlayer()); - // 引擎可能还没吐出总时长,展示层要能用本页记住的这一集时长先顶上。 - player().setDurationFallback(this::getDurationFallbackMs); player().setLutAllowed(isLutAllowed()); + bindAdAudioPrompt(); syncKeepScreenOn(); player().setDanmakuForeground(true); publishRenderTarget(getExoView().getVideoSurfaceView()); applyExoOutputMode(); if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-flow", "service connected cost=%dms key=%s", System.currentTimeMillis() - start, getPlaybackKey()); if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "service connected %s", lifecycleState()); - // 放在收口内、onServiceConnected() 之前:此刻控制器、回调与 Surface 都已就位, - // 订阅者拿到事件就能安全地读播放器状态。 - PluginHostEvents.playbackReady(this); onServiceConnected(); } @Override public void onServiceDisconnected(ComponentName name) { if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "service disconnected name=%s %s", name, lifecycleState()); + unbindAdAudioPrompt(); + if (adSkipPromptPresenter != null) adSkipPromptPresenter.close(); + adSkipPromptPresenter = null; releaseController(); getSeekView().setProgressPlayer(null); - if (player() != null) player().setDurationFallback(null); mService = null; preparedPlaybackKey = null; } @@ -1379,13 +1065,13 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "activity resume %s", lifecycleState()); playbackExiting = false; setRedirect(false); + bindAdAudioPrompt(); applyExoOutputMode(); if (shouldReclaim()) { detachSurface(); onReclaim(); } syncKeepScreenOn(); - PluginHostEvents.playbackResume(this); } @Override @@ -1393,11 +1079,11 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "activity pause %s", lifecycleState()); super.onPause(); if (isRedirect() && mController != null) mController.pause(); - PluginHostEvents.playbackPause(this); } @Override protected void onStop() { + unbindAdAudioPrompt(); if (mService != null) { mService.setPlaybackForeground(false); if (isOwner()) player().setDanmakuForeground(false); @@ -1416,9 +1102,10 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { @Override protected void onDestroy() { - surfaceDiagnostics.unbind(); - detachAssSurface(); if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "activity destroy beforeRelease %s", lifecycleState()); + unbindAdAudioPrompt(); + if (adSkipPromptPresenter != null) adSkipPromptPresenter.close(); + adSkipPromptPresenter = null; RealtimeSubtitleController.get().unbind(getExoView()); restoreExoOutputMode(); super.onDestroy(); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackRecoveryActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackRecoveryActivity.java deleted file mode 100644 index e91b2c3e57..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackRecoveryActivity.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import android.app.Activity; -import android.content.Intent; -import android.graphics.Color; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.os.Process; -import android.os.SystemClock; -import android.view.Gravity; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.LinearLayout; -import android.widget.TextView; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.player.mpv.PlaybackRecoveryMonitor; -import com.fongmi.android.tv.player.mpv.PlaybackRecoveryPolicy; - -/** Deliberately not BaseActivity: this process must never load a player or start servers. */ -public final class PlaybackRecoveryActivity extends Activity { - private final Handler handler = new Handler(Looper.getMainLooper()); - private volatile PlaybackRecoveryMonitor.Request request; - private TextView description; - private Button exit; - private volatile boolean destroyed; - private final Runnable recoveryPoll = this::pollRecovery; - - @Override public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - request = PlaybackRecoveryMonitor.requestForToken(this, - getIntent().getStringExtra(PlaybackRecoveryMonitor.EXTRA_TOKEN)); - if (request == null) { - finishAndRemoveTask(); - return; - } - LinearLayout content = new LinearLayout(this); - content.setOrientation(LinearLayout.VERTICAL); - content.setGravity(Gravity.CENTER); - int padding = Math.round(32 * getResources().getDisplayMetrics().density); - content.setPadding(padding, padding, padding, padding); - content.setBackgroundColor(Color.rgb(24, 24, 24)); - TextView title = new TextView(this); - title.setText(R.string.playback_recovery_title); - title.setTextColor(Color.WHITE); - title.setTextSize(24); - content.addView(title); - description = new TextView(this); - description.setText(R.string.playback_recovery_description); - description.setTextColor(Color.LTGRAY); - description.setTextSize(16); - description.setPadding(0, padding / 2, 0, padding / 2); - content.addView(description); - Button wait = new Button(this); - wait.setText(R.string.playback_recovery_wait); - wait.setOnClickListener(view -> description.setText(R.string.playback_recovery_waiting)); - content.addView(wait); - exit = new Button(this); - exit.setText(R.string.playback_recovery_exit); - exit.setOnClickListener(view -> endPlayback()); - content.addView(exit); - setContentView(content, new ViewGroup.LayoutParams(-1, -1)); - wait.requestFocus(); // Never default remote focus to a destructive action. - handler.postDelayed(recoveryPoll, 1000); - } - - private void endPlayback() { - exit.setEnabled(false); - PlaybackRecoveryMonitor.Request selected = request; - // Filesystem verification and process exit can never block this page. - new Thread(() -> endPlaybackOffUi(selected), "mpv-user-recovery").start(); - } - - private void endPlaybackOffUi(PlaybackRecoveryMonitor.Request selected) { - // Revalidate the private request and process generation at the moment - // of consent. Reused PIDs, already recovered playback and stale pages - // must not terminate anything. - if (!PlaybackRecoveryMonitor.mayEndProcess(this, selected)) { - PlaybackRecoveryMonitor.Request current = PlaybackRecoveryMonitor.requestForToken(this, selected.token()); - int message = current != null && !current.armed() - ? R.string.playback_recovery_already_responsive : R.string.playback_recovery_refused; - handler.post(() -> showCannotEnd(message)); - return; - } - if (!PlaybackRecoveryMonitor.recordUserExit(this, selected)) { - handler.post(() -> showCannotEnd(R.string.playback_recovery_refused)); - return; - } - if (destroyed || isFinishing() || request != selected - || !PlaybackRecoveryMonitor.mayEndProcess(this, selected)) { - handler.post(() -> showCannotEnd(R.string.playback_recovery_refused)); - return; - } - try { - Process.killProcess(selected.target().pid()); - } catch (RuntimeException error) { - handler.post(() -> showCannotEnd(R.string.playback_recovery_refused)); - return; - } - long startedMs = SystemClock.elapsedRealtime(); - handler.post(() -> awaitExit(startedMs)); - } - - private void showCannotEnd(int message) { - if (destroyed || isFinishing()) return; - description.setText(message); - exit.setEnabled(true); - if (message == R.string.playback_recovery_already_responsive) { - exit.setText(R.string.playback_recovery_home); - exit.setOnClickListener(view -> returnHome()); - } else { - exit.setText(R.string.playback_recovery_exit); - exit.setOnClickListener(view -> endPlayback()); - } - } - - private void pollRecovery() { - if (destroyed || isFinishing() || request == null) return; - PlaybackRecoveryMonitor.Request current = PlaybackRecoveryMonitor.requestForToken(this, request.token()); - if (current != null && !current.armed()) { - finishAndRemoveTask(); - return; - } - handler.postDelayed(recoveryPoll, 1000); - } - - @Override protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - request = PlaybackRecoveryMonitor.requestForToken(this, - intent.getStringExtra(PlaybackRecoveryMonitor.EXTRA_TOKEN)); - if (request == null) { - finishAndRemoveTask(); - return; - } - description.setText(R.string.playback_recovery_description); - exit.setText(R.string.playback_recovery_exit); - exit.setEnabled(true); - exit.setOnClickListener(view -> endPlayback()); - } - - private void awaitExit(long startedMs) { - if (isFinishing() || isDestroyed()) return; - PlaybackRecoveryPolicy.Target current = PlaybackRecoveryMonitor.readTarget(request.target().pid()); - if (!request.target().equals(current)) { - returnHome(); - } else if (SystemClock.elapsedRealtime() - startedMs >= 5000) { - description.setText(R.string.playback_recovery_refused); - exit.setText(R.string.playback_recovery_home); - exit.setEnabled(true); - exit.setOnClickListener(view -> returnHome()); - } else { - handler.postDelayed(() -> awaitExit(startedMs), 100); - } - } - - private void returnHome() { - Intent launch = getPackageManager().getLaunchIntentForPackage(getApplicationInfo().packageName); - if (launch == null) launch = getPackageManager().getLeanbackLaunchIntentForPackage(getApplicationInfo().packageName); - if (launch != null) { - launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - startActivity(launch); // Launcher only: never carry the failed media Intent. - } - finishAndRemoveTask(); - } - - @Override protected void onDestroy() { - destroyed = true; - handler.removeCallbacksAndMessages(null); - super.onDestroy(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/PluginManagerActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/PluginManagerActivity.java deleted file mode 100644 index 364d852072..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/PluginManagerActivity.java +++ /dev/null @@ -1,942 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import android.app.Activity; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.graphics.Typeface; -import android.net.Uri; -import android.os.Bundle; -import android.text.InputType; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.LinearLayout; - -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.databinding.ActivityPluginManagerBinding; -import com.fongmi.android.tv.databinding.AdapterPluginEntryBinding; -import com.fongmi.android.tv.databinding.DialogPluginCandidatesBinding; -import com.fongmi.android.tv.databinding.ItemPluginCandidateBinding; -import com.fongmi.android.tv.plugin.PluginBootstrap; -import com.fongmi.android.tv.plugin.PluginBundleIndex; -import com.fongmi.android.tv.plugin.PluginCapabilityRegistry; -import com.fongmi.android.tv.plugin.PluginEnvironmentStore; -import com.fongmi.android.tv.plugin.PluginPackageImporter; -import com.fongmi.android.tv.plugin.PluginRemoteSource; -import com.fongmi.android.tv.plugin.PluginSourceConfig; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.ui.base.BaseActivity; -import com.fongmi.android.tv.ui.dialog.LightDialog; -import com.fongmi.android.tv.utils.FileChooser; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.PermissionUtil; -import com.github.catvod.utils.Path; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.textfield.TextInputEditText; -import com.google.android.material.textfield.TextInputLayout; -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -/** - * 插件管理整页:列出已导入插件、逐条启停、整包导入与删除。 - * - *

为什么全部后台工作跑在单线程执行器上。{@link PluginBootstrap#installAndReloadQuietly()} - * 是阻塞调用(QuickJS 初始化最长 5 秒),而 {@code PluginController.reload} 不是可重入的: - * 连点开关、导入、删除如果各投一个后台任务,就会有两个 reload 同时改同一份运行时表。 - * 单线程队列把"装载"天然串行化,比加锁更简单,也不会阻塞主线程。 - * - *

为什么开关与删除都先写配置再回到 {@link PluginSourceConfig#entries()} 重读列表。 - * 界面上的开关状态必须与持久化状态一致;本地改一份、配置改一份,复用 ViewHolder 时就会 - * 出现"看着开着、其实没启用"。所有结果都以后台重读出来的快照为准。 - */ -public class PluginManagerActivity extends BaseActivity { - - /** 远程插件包的临时文件名(位于 {@link #getCacheDir()} 下,流程的任何出口都会删掉它)。 */ - private static final String REMOTE_ZIP_NAME = "plugin-bundle.zip"; - - /** 下载超时:够慢网络拉一个脚本包,又不至于让界面在"获取中"停太久。 */ - private static final int REMOTE_TIMEOUT_SECONDS = 20; - - private final ExecutorService mWorker = Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, "plugin-manager")); - private final ActivityResultLauncher mDirectoryLauncher = - registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), this::onDirectoryPicked); - - private ActivityPluginManagerBinding mBinding; - private EntryAdapter mAdapter; - private boolean mBusy; - private boolean mReloadQueued; - - public static void start(Activity activity) { - activity.startActivity(new Intent(activity, PluginManagerActivity.class)); - } - - @Override - protected ViewBinding getBinding() { - return mBinding = ActivityPluginManagerBinding.inflate(getLayoutInflater()); - } - - @Override - protected void initView(Bundle savedInstanceState) { - mBinding.toolbar.setNavigationOnClickListener(view -> finish()); - mAdapter = new EntryAdapter(new EntryAdapter.Listener() { - @Override - public void onToggle(PluginSourceConfig.Entry entry, boolean enabled) { - setEnabled(entry, enabled); - } - - @Override - public void onParams(PluginSourceConfig.Entry entry) { - editParams(entry); - } - - @Override - public void onDelete(PluginSourceConfig.Entry entry) { - confirmDelete(entry); - } - }); - mBinding.pluginList.setLayoutManager(new LinearLayoutManager(this)); - mBinding.pluginList.setAdapter(mAdapter); - refreshEntries(); - } - - @Override - protected void initEvent() { - mBinding.importPlugin.setOnClickListener(view -> chooseDirectory()); - mBinding.emptyImport.setOnClickListener(view -> chooseDirectory()); - mBinding.remoteImportPlugin.setOnClickListener(view -> importFromUrl()); - mBinding.reloadPlugins.setOnClickListener(view -> scheduleReload()); - } - - @Override - protected void onDestroy() { - // 页面在候选框还开着时被销毁,对话框的 dismiss 回调未必会来,临时包就留在缓存里; - // 这里补一刀。只在没有后台任务时删:正在跑的那条流程自己会在出口处清理, - // 从外面删掉它正在读的 zip 只会把一次正常导入变成中途失败。 - if (!mBusy) delete(new File(getCacheDir(), REMOTE_ZIP_NAME)); - mWorker.shutdownNow(); - super.onDestroy(); - } - - private void chooseDirectory() { - if (mBusy) return; - // 选目录要读外部存储里的插件包;无权限时 FileActivity 只会显示一个空目录。 - PermissionUtil.requestFile(this, granted -> { - if (!granted) { - Notify.show(R.string.plugin_manager_permission_required); - return; - } - FileChooser.from(mDirectoryLauncher).showDirectory(); - }); - } - - private void onDirectoryPicked(ActivityResult result) { - if (result.getResultCode() != RESULT_OK || result.getData() == null) return; - Uri uri = result.getData().getData(); - // FileActivity 用 Uri.fromFile 回传,getPath() 已是明文绝对路径; - // 再做一次 URL 解码会把路径里的 "+" 变成空格,所以这里只取 getPath()。 - String path = uri == null ? null : uri.getPath(); - if (path == null || path.isEmpty()) { - Notify.show(R.string.plugin_manager_invalid_path); - return; - } - importPlugin(new File(path)); - } - - private void importPlugin(File source) { - if (!beginBusy()) return; - mWorker.execute(() -> { - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(source); - boolean imported = result != null && result.entry() != null; - String name = imported ? result.entry().name() : null; - String error = imported || result == null ? null : result.error(); - // 导入成功后条目默认启用,但只有重新装载才真正生效。 - if (imported) PluginBootstrap.installAndReloadQuietly(); - Snapshot snapshot = readEntries(); - App.post(() -> { - endBusy(); - if (!canTouchView()) return; - applySnapshot(snapshot); - if (imported) Notify.show(getString(R.string.plugin_manager_import_ok, name == null ? "" : name)); - else Notify.show(getString(R.string.plugin_manager_import_failed, error == null ? "" : error)); - }); - }); - } - - /** - * 远程导入第一步:要一条地址。 - * - *

形状照 {@link #editParams}:同一个对话框主题、外框 + 就地校验,只是这里输入的是纯文本, - * 所以校验放在"点确认之后、关闭之前"——地址能不能用必须先知道,否则用户要等一次失败的下载 - * 才被告知地址写错了。 - * - *

地址不落盘。它只在本次流程的局部变量与闭包里活着:不写 SharedPreferences、 - * 不写数据库、不写文件。这个功能是一次性导入,没有"记住上次地址"这回事。 - */ - private void importFromUrl() { - if (mBusy) return; - TextInputEditText input = new TextInputEditText(this); - input.setSingleLine(true); - input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); - - TextInputLayout layout = new TextInputLayout(this); - layout.setHint(getString(R.string.plugin_manager_remote_hint)); - // helperText 必须先启用再赋值:只调 setHelperText 在未启用时不会显示, - // 而"只接受 https / GitHub 或直链 zip"这句话恰恰决定了用户该粘什么。 - layout.setHelperTextEnabled(true); - layout.setHelperText(getString(R.string.plugin_manager_remote_helper)); - layout.setBoxBackgroundMode(TextInputLayout.BOX_BACKGROUND_OUTLINE); - layout.addView(input, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); - - MaterialButton paste = new MaterialButton(this); - paste.setText(R.string.plugin_manager_remote_paste); - paste.setAllCaps(false); - paste.setOnClickListener(view -> pasteClipboard(input)); - - LinearLayout container = new LinearLayout(this); - container.setOrientation(LinearLayout.VERTICAL); - int horizontal = dp(20); - container.setPadding(horizontal, dp(8), horizontal, 0); - container.addView(layout); - LinearLayout.LayoutParams pasteParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT); - pasteParams.topMargin = dp(8); - container.addView(paste, pasteParams); - - androidx.appcompat.app.AlertDialog dialog = new WebHtvAlertDialogBuilder(this, R.style.Theme_WebHTV_Dialog) - .setTitle(getString(R.string.plugin_manager_remote_title)) - .setView(container) - .setNegativeButton(R.string.dialog_cancel, null) - .setPositiveButton(R.string.plugin_manager_remote_start, null) - .create(); - dialog.setOnShowListener(shown -> { - dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener(view -> { - String value = input.getText() == null ? "" : input.getText().toString().trim(); - PluginRemoteSource.Resolution resolution = PluginRemoteSource.resolve(value); - if (!resolution.isSuccess()) { - layout.setError(resolution.error()); - return; - } - dialog.dismiss(); - startRemoteImport(value); - }); - // 遥控器进对话框先落在输入框上,返回键退到"取消",方向键往下能到"粘贴/获取"。 - input.requestFocus(); - }); - dialog.show(); - LightDialog.apply(dialog); - } - - /** 从剪贴板填地址;空剪贴或非文本剪贴什么都不做,不留一句"没有内容"的多余提示。 */ - private void pasteClipboard(TextInputEditText input) { - ClipboardManager manager = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - if (manager == null || !manager.hasPrimaryClip()) return; - ClipData clip = manager.getPrimaryClip(); - if (clip == null || clip.getItemCount() == 0) return; - CharSequence text = clip.getItemAt(0).getText(); - if (text == null || text.length() == 0) return; - input.setText(text.toString().trim()); - input.setSelection(input.length()); - } - - /** - * 远程导入第二步:下载 + 索引,全程在 {@link #mWorker} 上。 - * - *

临时包落在 {@code getCacheDir()}:它不是用户数据,放 files 目录会让人以为盘上有一份 - * "已安装的插件";也刻意不放进插件根目录,否则装载期会把半成品当真实插件扫到。 - */ - private void startRemoteImport(String rawUrl) { - if (!beginBusy()) { - // 校验通过到点确认之间,别的操作可能已经把页面占住了;这时候不能悄悄什么都不做。 - Notify.show(R.string.plugin_manager_remote_busy); - return; - } - mWorker.execute(() -> { - File zip = new File(getCacheDir(), REMOTE_ZIP_NAME); - // 上一次流程异常退出可能留下同名文件;不先删就会把旧包当成这次下载的结果。 - delete(zip); - - PluginRemoteSource.Resolution resolution = PluginRemoteSource.resolve(rawUrl); - if (!resolution.isSuccess()) { - failRemoteImport(zip, resolution.error()); - return; - } - try { - PluginRemoteSource.download(resolution.urls(), zip, REMOTE_TIMEOUT_SECONDS); - } catch (IOException | RuntimeException error) { - failRemoteImport(zip, error.getMessage()); - return; - } - - PluginBundleIndex.Result index = PluginBundleIndex.inspect(zip); - if (index == null || !index.isSuccess()) { - failRemoteImport(zip, index == null ? null : index.error()); - return; - } - if (index.candidates().isEmpty()) { - failRemoteImport(zip, getString(R.string.plugin_manager_remote_empty)); - return; - } - List candidates = index.candidates(); - App.post(() -> { - endBusy(); - if (!canTouchView()) { - delete(zip); - return; - } - showCandidates(candidates, zip, rawUrl); - }); - }); - } - - private void failRemoteImport(File zip, String error) { - delete(zip); - App.post(() -> { - endBusy(); - if (!canTouchView()) return; - Notify.show(getString(R.string.plugin_manager_remote_failed, error == null ? "" : error)); - }); - } - - /** - * 第三步:让用户挑插件。 - * - *

默认全选:用户刚指了一个包,最常见的意思是"把这个包装上"。但每条都仍然是独立可取消的 - * 勾选——一个 GitHub 仓库常带多个插件,装哪一个只能由用户决定。 - * - * @param rawUrl 只用于显示来源主机,绝不被保存 - */ - private void showCandidates(List candidates, File zip, String rawUrl) { - DialogPluginCandidatesBinding binding = DialogPluginCandidatesBinding.inflate(getLayoutInflater()); - binding.candidateCount.setText(getString(R.string.plugin_manager_remote_found, candidates.size())); - binding.candidateSource.setText(getString(R.string.plugin_manager_remote_source, hostOf(rawUrl))); - - CandidateAdapter adapter = new CandidateAdapter(candidates); - binding.candidateList.setLayoutManager(new LinearLayoutManager(this)); - binding.candidateList.setAdapter(adapter); - - androidx.appcompat.app.AlertDialog dialog = new WebHtvAlertDialogBuilder(this, R.style.Theme_WebHTV_Dialog) - .setView(binding.getRoot()) - .setNegativeButton(R.string.dialog_cancel, null) - .setPositiveButton(R.string.plugin_manager_remote_do_import, null) - .create(); - boolean[] handedOff = new boolean[1]; - final Button[] positive = new Button[1]; - - // 勾选集合变化后两处状态要一起动:全选按钮的文案、导入按钮的可点性。 - adapter.onCheckedChanged = () -> { - binding.selectAll.setText(adapter.isAllChecked() - ? R.string.plugin_manager_remote_select_none - : R.string.plugin_manager_remote_select_all); - if (positive[0] != null) positive[0].setEnabled(adapter.checkedCount() > 0); - }; - binding.selectAll.setOnClickListener(view -> { - adapter.setAllChecked(!adapter.isAllChecked()); - adapter.notifyDataSetChanged(); - adapter.onCheckedChanged.run(); - }); - - dialog.setOnShowListener(shown -> { - positive[0] = dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE); - positive[0].setOnClickListener(view -> { - List selected = adapter.checkedCandidates(); - // 一个都没勾时按钮本就是灰的;这里再判一次,避免无障碍/脚本路径绕过可用性。 - if (selected.isEmpty()) return; - handedOff[0] = true; - dialog.dismiss(); - confirmInstall(selected, zip); - }); - positive[0].setEnabled(adapter.checkedCount() > 0); - adapter.onCheckedChanged.run(); - // 焦点先落在"全选/取消全选"上:它是确定可达的实体控件, - // 从这里按方向键向下就进列表;把焦点直接交给 RecyclerView 是否落到首行取决于布局时序。 - binding.selectAll.requestFocus(); - }); - // 只有"没走到确认"的关闭才由这里删包:交给 confirmInstall 之后,清理归它的每个出口管。 - dialog.setOnDismissListener(shown -> { - if (!handedOff[0]) delete(zip); - }); - dialog.show(); - LightDialog.apply(dialog); - } - - /** 第四步:安装前的唯一一次确认。 */ - private void confirmInstall(List selected, File zip) { - StringBuilder names = new StringBuilder(); - Set union = new LinkedHashSet<>(); - for (PluginBundleIndex.Candidate candidate : selected) { - if (names.length() > 0) names.append(getString(R.string.plugin_manager_remote_name_separator)); - String name = candidate.name() == null || candidate.name().isEmpty() ? candidate.id() : candidate.name(); - names.append(name); - if (candidate.permissions() != null) union.addAll(candidate.permissions()); - } - - SpannableStringBuilder message = new SpannableStringBuilder(getString( - R.string.plugin_manager_remote_confirm_message, selected.size(), names.toString(), permissionText(union))); - // 这句必须无条件出现:它说的是"没有任何沙箱与安全校验",不是某个权限的注解, - // 不能因为权限清单里恰好没有 net.request/ui.panel 就一起消失。 - message.append("\n\n").append(getString(R.string.plugin_manager_remote_confirm_not_sandboxed)); - - // 两个高风险权限各用一句大白话单独说一遍:摘要里的"联网(net.request)"只是一个词, - // 用户未必读得出后果;只在确实申请时才出现,避免变成一段永远在的噪声。 - int riskStart = message.length(); - if (union.contains(PluginCapabilityRegistry.PERMISSION_NET_REQUEST)) { - message.append("\n\n").append(getString(R.string.plugin_manager_perm_net_request_plain)); - } - if (union.contains(PluginCapabilityRegistry.PERMISSION_UI_PANEL)) { - message.append("\n").append(getString(R.string.plugin_manager_perm_ui_panel_plain)); - } - if (message.length() > riskStart) { - message.setSpan(new ForegroundColorSpan(ThemeController.current().colorError()), riskStart, message.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - message.setSpan(new StyleSpan(Typeface.BOLD), riskStart, message.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - - // 取消只有两条路:负向按钮,或者返回键/点外部触发的 cancel。两个都显式删包, - // 不用 setOnDismissListener 统一处理——按钮监听与 dismiss 的先后由 AppCompat 决定, - // 依赖它会让"点安装"偶尔先把包删掉。 - new WebHtvAlertDialogBuilder(this, R.style.Theme_WebHTV_Dialog) - .setTitle(R.string.plugin_manager_remote_confirm_title) - .setMessage(message) - .setNegativeButton(R.string.dialog_cancel, (clicked, which) -> delete(zip)) - .setOnCancelListener(cancelled -> delete(zip)) - .setPositiveButton(R.string.plugin_manager_remote_install, (clicked, which) -> runBundleImport(selected, zip)) - .show(); - } - - /** - * 第五步:真正安装。 - * - *

只有 {@code selected} 里被勾选的候选会传进导入器——未勾选的插件在这一步之后没有任何 - * 写入路径,界面上的勾选状态就是安装集合本身。 - */ - private void runBundleImport(List selected, File zip) { - if (!beginBusy()) { - // 同一时刻只允许一个导入流程;按钮此时是禁用的,走到这里只会是重复回调。 - delete(zip); - Notify.show(R.string.plugin_manager_remote_busy); - return; - } - mWorker.execute(() -> { - PluginPackageImporter.BundleResult result = PluginPackageImporter.importBundle(zip, selected); - boolean installed = false; - if (result != null && result.isSuccess()) { - for (PluginPackageImporter.ItemResult item : result.items()) { - if (item.entry() != null) installed = true; - } - } - // 与 importPlugin(File) 同一条刷新路径:先重装生效,再重读配置,最后回主线程刷界面。 - if (installed) PluginBootstrap.installAndReloadQuietly(); - Snapshot snapshot = readEntries(); - delete(zip); - App.post(() -> { - endBusy(); - if (!canTouchView()) return; - applySnapshot(snapshot); - showRemoteResult(result); - }); - }); - } - - private void showRemoteResult(PluginPackageImporter.BundleResult result) { - if (result == null || !result.isSuccess()) { - Notify.show(getString(R.string.plugin_manager_remote_batch_failed, result == null ? "" : result.error())); - return; - } - List lines = new ArrayList<>(); - int installed = 0; - for (PluginPackageImporter.ItemResult item : result.items()) { - String name = item.candidate() == null ? "" : item.candidate().name(); - if (name == null || name.isEmpty()) name = item.candidate() == null ? "" : item.candidate().id(); - if (item.entry() != null) { - installed++; - lines.add(getString(R.string.plugin_manager_remote_item_ok, name)); - } else { - lines.add(getString(R.string.plugin_manager_remote_item_failed, name, item.error() == null ? "" : item.error())); - } - } - if (lines.isEmpty()) { - Notify.show(R.string.plugin_manager_remote_empty); - return; - } - if (installed == lines.size()) { - Notify.show(getString(R.string.plugin_manager_remote_all_ok, installed)); - return; - } - new WebHtvAlertDialogBuilder(this) - .setTitle(R.string.plugin_manager_remote_result_title) - .setMessage(String.join("\n", lines)) - .setPositiveButton(R.string.dialog_confirm, null) - .show(); - } - - /** 来源主机:解析用户输入的那条地址,取不到主机时退回原文,绝不显示空行。 */ - private static String hostOf(String rawUrl) { - try { - String host = Uri.parse(rawUrl).getHost(); - if (host != null && !host.isEmpty()) return host; - } catch (RuntimeException ignored) { - } - return rawUrl == null ? "" : rawUrl; - } - - private static void delete(File file) { - if (file == null || !file.exists()) return; - // 删不掉无法补救(占用/权限);缓存目录会被系统回收,所以这里只尽力一次。 - file.delete(); - } - - /** - * 权限令牌 → 人话。 - * - *

未知令牌原样显示:把它藏起来等于让用户在不知情的情况下授权,比显示一个生僻英文词糟得多。 - */ - private String permissionLabel(String token) { - if (PluginCapabilityRegistry.PERMISSION_NET_REQUEST.equals(token)) return getString(R.string.plugin_manager_perm_net_request); - if (PluginCapabilityRegistry.PERMISSION_UI_PANEL.equals(token)) return getString(R.string.plugin_manager_perm_ui_panel); - if (PluginCapabilityRegistry.PERMISSION_UI_FLOATING.equals(token)) return getString(R.string.plugin_manager_perm_ui_floating); - if (PluginCapabilityRegistry.PERMISSION_UI_TOOLBAR.equals(token)) return getString(R.string.plugin_manager_perm_ui_toolbar); - if (PluginCapabilityRegistry.PERMISSION_UI_PAGE.equals(token)) return getString(R.string.plugin_manager_perm_ui_page); - if (PluginCapabilityRegistry.PERMISSION_EVENTS.equals(token)) return getString(R.string.plugin_manager_perm_events); - if (PluginCapabilityRegistry.PERMISSION_EPISODE_DATA.equals(token)) return getString(R.string.plugin_manager_perm_episode_data); - return token; - } - - /** 高风险权限:联网与自绘面板都能把数据带出设备或把界面画成任意样子,必须显眼。 */ - private static boolean isHighRisk(String token) { - return PluginCapabilityRegistry.PERMISSION_NET_REQUEST.equals(token) - || PluginCapabilityRegistry.PERMISSION_UI_PANEL.equals(token); - } - - private CharSequence permissionText(Set permissions) { - if (permissions == null || permissions.isEmpty()) return getString(R.string.plugin_manager_perm_none); - List sorted = new ArrayList<>(permissions); - Collections.sort(sorted); - StringBuilder text = new StringBuilder(); - for (String token : sorted) { - if (text.length() > 0) text.append(getString(R.string.plugin_manager_perm_separator)); - text.append(permissionLabel(token)); - } - return text.toString(); - } - - /** 单个候选行的权限展示:高风险项加粗并用错误色,令牌本身始终可见。 */ - private CharSequence permissionSpans(Set permissions) { - if (permissions == null || permissions.isEmpty()) return getString(R.string.plugin_manager_perm_none); - List sorted = new ArrayList<>(permissions); - Collections.sort(sorted); - SpannableStringBuilder text = new SpannableStringBuilder(); - for (String token : sorted) { - if (text.length() > 0) text.append(getString(R.string.plugin_manager_perm_separator)); - int start = text.length(); - text.append(permissionLabel(token)); - if (isHighRisk(token)) { - text.setSpan(new ForegroundColorSpan(ThemeController.current().colorError()), start, text.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - text.setSpan(new StyleSpan(Typeface.BOLD), start, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - } - return text; - } - - /** - * 编辑某个插件的参数(用户自填的键值 JSON)。 - * - *

形状照 {@code SubtitleSettingsDialog.showSourceEnvironment}:多行输入 + 外框 + 就地校验, - * 因为用户填的是手打 JSON,错误必须在对话框里当场指出,而不是保存后在别处才炸。 - * - *

页面本身不重建布局:WebHtvAlertDialogBuilder 里 setView 一个临时容器即可。 - */ - private void editParams(PluginSourceConfig.Entry entry) { - String name = entry.name() == null || entry.name().isEmpty() ? entry.id() : entry.name(); - TextInputEditText input = new TextInputEditText(this); - input.setSingleLine(false); - input.setMinLines(4); - input.setText(PluginEnvironmentStore.get().get(entry.id())); - - TextInputLayout layout = new TextInputLayout(this); - layout.setHint(getString(R.string.plugin_manager_params_hint)); - layout.setBoxBackgroundMode(TextInputLayout.BOX_BACKGROUND_OUTLINE); - layout.addView(input, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); - - LinearLayout container = new LinearLayout(this); - container.setOrientation(LinearLayout.VERTICAL); - int horizontal = dp(20); - container.setPadding(horizontal, dp(8), horizontal, 0); - container.addView(layout); - - androidx.appcompat.app.AlertDialog dialog = new WebHtvAlertDialogBuilder(this, R.style.Theme_WebHTV_Dialog) - .setTitle(getString(R.string.plugin_manager_params_title, name)) - .setView(container) - .setNegativeButton(R.string.dialog_cancel, null) - .setPositiveButton(R.string.dialog_confirm, null) - .create(); - dialog.setOnShowListener(shown -> dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE) - .setOnClickListener(view -> { - String value = input.getText() == null ? "" : input.getText().toString().trim(); - if (!value.isEmpty() && !isJsonObject(value)) { - layout.setError(getString(R.string.plugin_manager_params_invalid)); - return; - } - PluginEnvironmentStore store = PluginEnvironmentStore.get(); - if (!store.put(entry.id(), value)) { - layout.setError(getString(R.string.plugin_manager_params_too_large)); - return; - } - dialog.dismiss(); - // 参数改了要重装插件:脚本在 init 阶段就把 config 读走了,不重装等于没改。 - scheduleReload(); - })); - dialog.show(); - LightDialog.apply(dialog); - } - - private static boolean isJsonObject(String value) { - try { - JsonElement element = JsonParser.parseString(value); - return element != null && element.isJsonObject(); - } catch (RuntimeException error) { - return false; - } - } - - private int dp(int value) { - return Math.round(getResources().getDisplayMetrics().density * value); - } - - private void confirmDelete(PluginSourceConfig.Entry entry) { - new WebHtvAlertDialogBuilder(this) - .setTitle(R.string.plugin_manager_delete_title) - .setMessage(getString(R.string.plugin_manager_delete_message, entry.name())) - .setNegativeButton(R.string.dialog_cancel, null) - .setPositiveButton(R.string.setting_delete, (dialog, which) -> deletePlugin(entry)) - .show(); - } - - private void deletePlugin(PluginSourceConfig.Entry entry) { - if (!beginBusy()) return; - mWorker.execute(() -> { - // 用单元素数组而不是局部变量:下面的 App.post lambda 要求捕获实际上的最终变量。 - String[] saveError = new String[1]; - try { - PluginSourceConfig config = PluginSourceConfig.get(); - config.remove(entry.id()); - config.save(); - // 参数必须跟着插件一起删:留着的话,用户日后重新装同名 id 的插件会静默继承旧 token。 - PluginEnvironmentStore.get().remove(entry.id()); - } catch (Throwable error) { - saveError[0] = error.toString(); - } - // 配置删成功才删目录:反过来的话,删目录失败会留下一个指向空目录的条目。 - boolean removed = removePackageDir(entry.directory()); - PluginBootstrap.installAndReloadQuietly(); - Snapshot snapshot = readEntries(); - App.post(() -> { - endBusy(); - if (!canTouchView()) return; - applySnapshot(snapshot); - if (saveError[0] != null) Notify.show(getString(R.string.plugin_manager_save_failed, saveError[0])); - else if (!removed) Notify.show(getString(R.string.plugin_manager_delete_failed, entry.directory() == null ? "" : entry.directory())); - }); - }); - } - - private void setEnabled(PluginSourceConfig.Entry entry, boolean enabled) { - try { - PluginSourceConfig config = PluginSourceConfig.get(); - config.setEnabled(entry.id(), enabled); - config.save(); - } catch (Throwable error) { - Notify.show(getString(R.string.plugin_manager_save_failed, error.toString())); - // 写失败时开关已经翻过去了,重读一次把它拨回持久化的真实状态。 - refreshEntries(); - return; - } - scheduleReload(); - } - - /** - * 排队一次"重装 + 重读"。 - * - *

连点开关会连排多次重装,每次都要把启用清单重跑一遍脚本,代价明显; - * 这里用一个排队标记把同一时刻的多余请求合并成一次。 - */ - private void scheduleReload() { - synchronized (this) { - if (mReloadQueued) return; - mReloadQueued = true; - } - mWorker.execute(() -> { - synchronized (PluginManagerActivity.this) { - mReloadQueued = false; - } - PluginBootstrap.installAndReloadQuietly(); - publishEntries(); - }); - } - - private void refreshEntries() { - mWorker.execute(this::publishEntries); - } - - private void publishEntries() { - Snapshot snapshot = readEntries(); - App.post(() -> applySnapshot(snapshot)); - } - - private void applySnapshot(Snapshot snapshot) { - if (!canTouchView()) return; - bindEntries(snapshot.entries); - if (snapshot.error != null) Notify.show(getString(R.string.plugin_manager_load_failed, snapshot.error)); - } - - private void bindEntries(List entries) { - mAdapter.submit(entries); - boolean empty = entries.isEmpty(); - mBinding.emptyHint.setVisibility(empty ? View.VISIBLE : View.GONE); - mBinding.pluginList.setVisibility(empty ? View.GONE : View.VISIBLE); - } - - private boolean beginBusy() { - if (mBusy) return false; - mBusy = true; - mBinding.importPlugin.setEnabled(false); - mBinding.remoteImportPlugin.setEnabled(false); - mBinding.reloadPlugins.setEnabled(false); - return true; - } - - private void endBusy() { - mBusy = false; - if (!canTouchView()) return; - mBinding.importPlugin.setEnabled(true); - mBinding.remoteImportPlugin.setEnabled(true); - mBinding.reloadPlugins.setEnabled(true); - } - - private boolean canTouchView() { - return !isFinishing() && !isDestroyed() && mBinding != null; - } - - /** - * 只删插件根目录下的条目目录。 - * - *

条目里的 directory 来自 SharedPreferences,被改坏时可能指向任意路径; - * 没有这道"必须在根目录之下"的判断,删一次插件就能删掉用户目录。 - * 根目录归属交给导入器判定:根目录名只有一处定义,它改名后这里不会悄悄失效。 - */ - private static boolean removePackageDir(String directory) { - if (!PluginPackageImporter.isOwnedDirectory(directory)) return false; - try { - File target = new File(directory).getCanonicalFile(); - Path.clear(target); - return !target.exists(); - } catch (Throwable error) { - return false; - } - } - - /** 后台线程里读一次配置;失败只记一句话,不抛到界面。 */ - private static Snapshot readEntries() { - try { - return new Snapshot(new ArrayList<>(PluginSourceConfig.get().entries()), null); - } catch (Throwable error) { - return new Snapshot(Collections.emptyList(), error.toString()); - } - } - - private static final class Snapshot { - - private final List entries; - private final String error; - - private Snapshot(List entries, String error) { - this.entries = entries; - this.error = error; - } - } - - private static final class EntryAdapter extends RecyclerView.Adapter { - - private final List items = new ArrayList<>(); - private final Listener listener; - - private EntryAdapter(Listener listener) { - this.listener = listener; - } - - private void submit(List entries) { - items.clear(); - items.addAll(entries); - notifyDataSetChanged(); - } - - @NonNull - @Override - public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new Holder(AdapterPluginEntryBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull Holder holder, int position) { - PluginSourceConfig.Entry entry = items.get(position); - Context context = holder.binding.getRoot().getContext(); - String name = entry.name() == null || entry.name().isEmpty() ? entry.id() : entry.name(); - String state = context.getString(entry.enabled() ? R.string.setting_enable : R.string.setting_disable); - holder.binding.entryName.setText(name); - holder.binding.entryMeta.setText(context.getString(R.string.plugin_manager_entry_meta, entry.version(), entry.runtimeApi(), state)); - // 先摘监听器再设状态:复用 ViewHolder 时旧的监听器会用上一条数据再切一次。 - holder.binding.entryEnabled.setOnCheckedChangeListener(null); - holder.binding.entryEnabled.setChecked(entry.enabled()); - holder.binding.entryEnabled.setOnCheckedChangeListener((button, checked) -> listener.onToggle(entry, checked)); - holder.binding.entryParams.setOnClickListener(view -> listener.onParams(entry)); - holder.binding.entryDelete.setOnClickListener(view -> listener.onDelete(entry)); - } - - @Override - public int getItemCount() { - return items.size(); - } - - private interface Listener { - - void onToggle(PluginSourceConfig.Entry entry, boolean enabled); - - void onParams(PluginSourceConfig.Entry entry); - - void onDelete(PluginSourceConfig.Entry entry); - } - - private static final class Holder extends RecyclerView.ViewHolder { - - private final AdapterPluginEntryBinding binding; - - private Holder(AdapterPluginEntryBinding binding) { - super(binding.getRoot()); - this.binding = binding; - } - } - } - - /** - * 候选插件的勾选适配器。 - * - *

勾选状态按 {@code entryPath} 记:它是 zip 内目录,天然唯一;用位置记的话,滚动回收后 - * 同一个 ViewHolder 会带着上一行的状态回来。默认全选——用户刚指了一个包,最常见的意思是 - * 把整包装上,但每条仍然可以单独取消。 - * - *

整行可聚焦、整行点击切换。勾选框本身 {@code focusable=false}:遥控器上如果它自己 - * 拿焦点,方向键就会在"行"与"框"之间反复跳,而按一次只翻一行——这正是项目里常见的 - * list-select 写法(行负责焦点,框只显示状态)。 - */ - private final class CandidateAdapter extends RecyclerView.Adapter { - - private final List items; - private final Set checked = new HashSet<>(); - private Runnable onCheckedChanged; - - private CandidateAdapter(List candidates) { - this.items = new ArrayList<>(candidates); - for (PluginBundleIndex.Candidate candidate : items) checked.add(candidate.entryPath()); - } - - private int checkedCount() { - return checked.size(); - } - - private boolean isAllChecked() { - return !items.isEmpty() && checked.size() == items.size(); - } - - private void setAllChecked(boolean value) { - checked.clear(); - if (value) for (PluginBundleIndex.Candidate candidate : items) checked.add(candidate.entryPath()); - } - - /** 已勾选的候选,顺序与列表一致:导入顺序稳定,用户看到的行序就是安装顺序。 */ - private List checkedCandidates() { - List selected = new ArrayList<>(checked.size()); - for (PluginBundleIndex.Candidate candidate : items) { - if (checked.contains(candidate.entryPath())) selected.add(candidate); - } - return selected; - } - - private void toggle(int position) { - String key = items.get(position).entryPath(); - if (!checked.remove(key)) checked.add(key); - notifyItemChanged(position); - if (onCheckedChanged != null) onCheckedChanged.run(); - } - - @NonNull - @Override - public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new Holder(ItemPluginCandidateBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull Holder holder, int position) { - PluginBundleIndex.Candidate candidate = items.get(position); - Context context = holder.binding.getRoot().getContext(); - // entryPath 只是 zip 内定位标识(如 repo-main/douban),不能当插件名展示。 - String name = candidate.name() == null || candidate.name().isEmpty() ? candidate.id() : candidate.name(); - boolean isChecked = checked.contains(candidate.entryPath()); - holder.binding.candidateName.setText(name); - holder.binding.candidateMeta.setText(context.getString( - R.string.plugin_manager_remote_candidate_meta, candidate.version(), candidate.runtimeApi())); - holder.binding.candidatePermissions.setText(permissionSpans(candidate.permissions())); - holder.binding.candidateCheck.setChecked(isChecked); - holder.binding.getRoot().setOnClickListener(view -> { - // 绑定位置在行被回收后会变成 NO_POSITION;按它取值会越界,所以先问一次。 - int current = holder.getBindingAdapterPosition(); - if (current == RecyclerView.NO_POSITION) return; - toggle(current); - }); - } - - @Override - public int getItemCount() { - return items.size(); - } - - private static final class Holder extends RecyclerView.ViewHolder { - - private final ItemPluginCandidateBinding binding; - - private Holder(ItemPluginCandidateBinding binding) { - super(binding.getRoot()); - this.binding = binding; - } - } - } -} 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 7af5e313f5..d4003b8551 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 @@ -27,7 +27,6 @@ import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; -import android.view.ViewTreeObserver; import android.view.Window; import android.view.WindowManager; import android.widget.FrameLayout; @@ -43,7 +42,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.core.widget.NestedScrollView; -import androidx.core.view.OneShotPreDrawListener; import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; @@ -69,9 +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.api.config.SubscriptionTmdbCredentialStore; -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; @@ -94,26 +94,12 @@ import com.fongmi.android.tv.bean.TmdbSeasonProgress; import com.fongmi.android.tv.bean.TmdbSeasonScope; import com.fongmi.android.tv.bean.TmdbSeasonSegment; import com.fongmi.android.tv.bean.TmdbPerson; -import com.fongmi.android.tv.bean.TmdbSourcePayload; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.databinding.ActivityTmdbDetailBinding; import com.fongmi.android.tv.databinding.DialogTmdbEpisodeBinding; import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingIdentity; -import com.fongmi.android.tv.following.FollowingMetadataSnapshot; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingSource; -import com.fongmi.android.tv.following.FollowingStore; -import com.fongmi.android.tv.following.FollowingUpdatePolicy; -import com.fongmi.android.tv.event.ConfigEvent; -import com.fongmi.android.tv.setting.DetailRuntimeModePolicy; -import com.fongmi.android.tv.setting.TmdbSourceState; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.detail.DetailModeHost; import com.fongmi.android.tv.ui.detail.EnhancedDetailController; import com.fongmi.android.tv.ui.detail.FusionDetailController; @@ -123,14 +109,10 @@ import com.fongmi.android.tv.ui.host.TmdbDetailHost; import com.fongmi.android.tv.playback.PlaybackEventCollector; import com.fongmi.android.tv.playback.HistoryResumePayload; import com.fongmi.android.tv.playback.PlaybackOrientation; -import com.fongmi.android.tv.playback.SubtitleRestoreCoordinator; -import com.fongmi.android.tv.player.IntroSkipKinds; import com.fongmi.android.tv.player.IntroSkipPlayback; -import com.fongmi.android.tv.player.PlaybackResourceClassifier; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.PlayerHelper; -import com.fongmi.android.tv.player.mpv.MpvConfigStore; -import com.fongmi.android.tv.service.AiAdDetectionService; +import com.fongmi.android.tv.player.exo.MediaSourceFactory; import com.fongmi.android.tv.service.AiEpisodeSeasonService; import com.fongmi.android.tv.service.AiRecommendationService; import com.fongmi.android.tv.service.PersonalRecommendationService; @@ -147,11 +129,9 @@ import com.fongmi.android.tv.setting.MultiThreadProxySetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.setting.TmdbSitePolicy; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.title.MediaTitleLearningExample; import com.fongmi.android.tv.title.MediaTitleLearningStore; import com.fongmi.android.tv.title.MediaTitleParser; -import com.fongmi.android.tv.utils.PushParser; import com.fongmi.android.tv.title.MediaTitleRequest; import com.fongmi.android.tv.title.MediaTitleResolution; import com.fongmi.android.tv.title.MediaTitleResolver; @@ -165,13 +145,11 @@ import com.fongmi.android.tv.ui.adapter.TmdbRailAdapter; import com.fongmi.android.tv.ui.adapter.TmdbVideoAdapter; import com.fongmi.android.tv.ui.controller.VodPlayerControlController; import com.fongmi.android.tv.ui.custom.CustomSeekView; -import com.fongmi.android.tv.ui.helper.TmdbDetailPrefetch; import com.fongmi.android.tv.ui.helper.TmdbVideoPlayback; import com.fongmi.android.tv.ui.custom.EpisodeTitlePopup; import com.fongmi.android.tv.ui.custom.PlayerGesture; import com.fongmi.android.tv.ui.custom.PlayerOsdController; import com.fongmi.android.tv.ui.dialog.AdRulePreviewDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.ui.dialog.CodecCapabilityDialog; import com.fongmi.android.tv.ui.dialog.DanmakuDialog; import com.fongmi.android.tv.ui.dialog.DisplayDialog; @@ -184,7 +162,6 @@ import com.fongmi.android.tv.ui.dialog.ChoiceDialog; import com.fongmi.android.tv.ui.dialog.TmdbSearchDialog; import com.fongmi.android.tv.ui.dialog.TrackDialog; import com.fongmi.android.tv.ui.novel.NovelRouter; -import com.fongmi.android.tv.ui.web.CatWebActivity; import com.fongmi.android.tv.ui.helper.DetailThemeVisibility; import com.fongmi.android.tv.ui.helper.EpisodeRangePolicy; import com.fongmi.android.tv.ui.helper.EpisodeCardImagePolicy; @@ -193,7 +170,6 @@ import com.fongmi.android.tv.ui.helper.EpisodeSeasonSnapshot; import com.fongmi.android.tv.ui.helper.PipExitDecision; import com.fongmi.android.tv.ui.helper.PlayerControlFocusHelper; import com.fongmi.android.tv.ui.helper.TmdbCinemaTheme; -import com.fongmi.android.tv.ui.helper.TmdbBundle; import com.fongmi.android.tv.ui.helper.TmdbDetailLabels; import com.fongmi.android.tv.ui.helper.TmdbEpisodeGridPolicy; import com.fongmi.android.tv.ui.helper.TmdbEpisodeInfo; @@ -203,11 +179,6 @@ import com.fongmi.android.tv.ui.helper.TmdbEpisodeMatcher; import com.fongmi.android.tv.ui.helper.TmdbMatchPolicy; import com.fongmi.android.tv.ui.helper.TmdbMatcher; import com.fongmi.android.tv.ui.helper.TmdbRecommendationRows; -import com.fongmi.android.tv.ui.helper.TmdbSourceAdapter; -import com.fongmi.android.tv.ui.helper.TmdbSourceAvailability; -import com.fongmi.android.tv.ui.helper.TmdbSourceCapabilityPlanner; -import com.fongmi.android.tv.ui.helper.TmdbSourceMerger; -import com.fongmi.android.tv.ui.helper.TmdbSourcePayloadParser; import com.fongmi.android.tv.ui.helper.TmdbUIAdapter; import com.fongmi.android.tv.ui.player.VodPlayerChrome; import com.fongmi.android.tv.ui.player.VodPlayerUiController; @@ -224,24 +195,21 @@ import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.ui.utils.TmdbDetailLayoutUtils; import com.fongmi.android.tv.utils.TmdbDetailCache; -import com.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.fongmi.android.tv.utils.TmdbEpisodeSorter; import com.fongmi.android.tv.utils.TmdbImageSelector; import com.fongmi.android.tv.utils.TmdbImageSaver; import com.fongmi.android.tv.utils.Traffic; import com.fongmi.android.tv.utils.Util; -import com.fongmi.android.tv.utils.WebViewUtil; import com.fongmi.android.tv.utils.Clock; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.player.lut.LutPreset; import com.fongmi.android.tv.player.lut.LutStore; -import com.fongmi.android.tv.plugin.PluginController; -import com.fongmi.android.tv.plugin.PluginHostEvents; -import com.fongmi.android.tv.plugin.PluginSurface; +import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.web.WebHomeInlineVodStore; import com.google.android.flexbox.FlexboxLayout; import com.google.android.material.button.MaterialButton; import com.google.android.material.card.MaterialCardView; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.bumptech.glide.Glide; import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.engine.GlideException; @@ -251,8 +219,6 @@ import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.target.Target; import com.bumptech.glide.request.transition.Transition; import com.github.catvod.crawler.SpiderDebug; -import com.github.catvod.utils.Trans; -import com.google.common.util.concurrent.ListenableFuture; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -261,7 +227,6 @@ import com.google.gson.JsonParser; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; -import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -284,36 +249,19 @@ import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; -public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.Listener, Clock.Callback, PlayerGesture.Listener, SubtitlePlaybackSession.Host, TmdbDetailHost, PluginSurface.ImmersiveAware { +public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.Listener, Clock.Callback, PlayerGesture.Listener, SubtitlePlaybackSession.Host, TmdbDetailHost { private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss", Locale.getDefault()); + private static final int FOCUS_STROKE = 0xFFFFD166; + private static final int FOCUS_STROKE_DP = 3; private static final int CHIP_STROKE_DP = 1; private static final int CHIP_MAX_WIDTH_DP = 240; - /** - * 芯片内边距,必须按 dp 换算后写入。 - * - * 这里曾经直接写 {@code setPadding(24, 12, 24, 12)}——那是像素而不是 dp,于是同一份 - * 代码在不同密度上得到不同内边距:320dpi(2x)上 24px=12dp 正常,280dpi(1.75x)上 - * 24px≈13.7dp 偏大,480dpi(3x)上 24px 只有 8dp,文字就贴到描边上——这正是用户反馈的 - * 「按钮边框包不住文本」,且只在部分密度设备上复现。 - * 取值与同类芯片({@code shape_video_item} 为画面按钮声明的 12dp/8dp 内边距)一致, - * 各密度下表现相同。本芯片背景来自 materialButtonOutlinedStyle 的 MaterialShapeDrawable, - * 与 {@code selector_video_item} 无关,12dp/8dp 只是取值对齐。 - */ - private static final int CHIP_PADDING_H_DP = 12; - private static final int CHIP_PADDING_V_DP = 8; private static final int PHOTO_PRELOAD_RADIUS = 2; - /** 自动匹配消歧时同时最多在途的候选详情请求数。 */ - private static final int DISAMBIGUATION_CONCURRENCY = 3; private static final long BACKDROP_SLIDE_DELAY_MS = 10_000L; private static final long BACKDROP_SLIDE_RETRY_MS = 2_500L; private static final int SHORT_DRAMA_SCALE = 0; private static final int SHORT_DRAMA_FRAME_WIDTH = 9; private static final int SHORT_DRAMA_FRAME_HEIGHT = 16; - /** 卡片行获得焦点时,分区标题上方保留的留白(标题需完整可见)。 */ - private static final int CARD_ROW_TOP_MARGIN_DP = 12; - /** 卡片行获得焦点时,行底与可视区底部之间保留的留白。 */ - private static final int CARD_ROW_BOTTOM_MARGIN_DP = 16; private static final int INLINE_SIDE_CONTROL_MARGIN_DP = 4; private static final int INLINE_SIDE_CONTROL_FULLSCREEN_MARGIN_DP = 48; private static final long INLINE_CONTROLS_HIDE_DELAY_MS = TimeUnit.SECONDS.toMillis(10); @@ -326,11 +274,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private final TmdbService tmdbService = new TmdbService(); private final Task.Scope detailTasks = new Task.Scope(Task.executor()); - /** identity 已知时与站点详情并行取回 TMDB 详情;单槽、按 tmdbId+mediaType 匹配。 */ - private final TmdbDetailPrefetch detailPrefetch = new TmdbDetailPrefetch(Task.largeExecutor()); private final IntroSkipPlayback introSkipPlayback = new IntroSkipPlayback(); - private androidx.appcompat.app.AlertDialog introSkipConfirmDialog; - private boolean introSkipListenersReady; private final SubtitlePlaybackSession subtitlePlaybackSession = new SubtitlePlaybackSession(this); private final List detailCastItems = new ArrayList<>(); private final List castItems = new ArrayList<>(); @@ -373,9 +317,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private ActivityTmdbDetailBinding binding; @androidx.annotation.Keep private ActivityTmdbDetailBinding mBinding; - private ViewTreeObserver.OnGlobalFocusChangeListener cardRowFocusMarginListener; - private final List cardRowOrder = new ArrayList<>(); - private final List cardRowTitleOrder = new ArrayList<>(); private TmdbDetailModeController modeController; private Vod vod; private String sourceVodName; @@ -384,9 +325,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private History mHistory; private TmdbConfig tmdbConfig; private TmdbBundle activeTmdbBundle; - private TmdbSourcePayload activeSourcePayload; - private TmdbItem activeSourceItem; - private DetailRuntimeModePolicy.Decision activeRuntimeDecision; private TmdbItem initialTmdbItem; private TmdbItem matchedTmdbItem; private JsonObject matchedTmdbDetail; @@ -400,7 +338,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private Episode inlinePlaybackEpisode; private String inlinePlaybackKey = ""; private String inlinePlaybackFlag = ""; - private String inlinePlaybackFlagKey = ""; private TmdbEpisodeAdapter episodeAdapter; private TmdbPersonAdapter castAdapter; private TmdbPersonAdapter creatorAdapter; @@ -417,45 +354,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private boolean overviewExpanded; private boolean useParse; private boolean inlineStarted; - private boolean inlinePlaybackPending; - /** 置位 {@link #inlinePlaybackPending} 的请求代际;0 表示当前没有待定请求。 */ - private int inlinePlaybackPendingGeneration; - private final Runnable inlineLoadingSpeedRefresh = new Runnable() { - @Override - public void run() { - if (binding == null || binding.playerLoading.getVisibility() != View.VISIBLE) return; - updateInlineLoadingSpeed(); - binding.playerLoading.postDelayed(this, 1000L); - } - }; private boolean inlineHttpRefreshAttempted; private boolean detailPlayerActive; - private boolean detailPlayerFullscreenPending; private boolean autoPlayed; private boolean defaultPlaybackLaunchPending; private boolean inlineFullscreen; - /** - * 本页当前的画中画状态。 - * - *

两个写点,各有分工:{@code onPictureInPictureModeChanged} 用形参写(回调是唯一能保证 - * 事件顺序的地方),{@code initView} 用 {@code isInPictureInPictureMode()} 写初值——进程在 - * 画中画中被回收后恢复不会再走一次回调,字段停在 {@code false} 会把恢复中的小窗当全屏。 - * - *

为什么消费侧读字段而不是现场问平台。不是为了绕开回调时序(平台在派发前已写好 - * 该状态,回调里问也是新值),而是为了让"这一次形态变更"只有一个快照:本页 PiP 回调里 - * 还要先翻转内嵌布局再重算入口,中途现场再问一次就会与已算过的值分叉。 - */ - private boolean inPip; private boolean inlineShortDramaMode; - /** - * 用户在内嵌全屏里显式选过的方向(旋转按钮)。非 {@code UNSPECIFIED} 表示用户已表态: - * 视频形态变化触发的自动方向重设不得再覆盖它,否则一次点击会被紧随其后的 - * VideoSize/STATE_READY 回调改回原方向,表现成「按两次才生效」。 - * 与 {@code VideoActivity.manualRotate} 同一语义。进/出内嵌全屏时清空,重建自动基线。 - */ - private int inlineManualOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; private boolean inlinePauseInfo; + private boolean inlinePlaybackLoading; private boolean inlinePlaybackReconnectPending; + private boolean inlinePlayerSwitchLoading; private boolean savingTmdbPhoto; private boolean pendingInlineLutImport; private PlayerGesture inlineGestureDetector; @@ -482,14 +390,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private boolean inlineKeySpeedChanging; private float inlineGestureSpeed = 1.0f; private boolean inlineStartPositionApplied; - /** 切集异步解析窗口期内为 false:此时播放器仍在旧集上,进度禁止写入已指向新集的 history。 */ - private boolean inlinePlaybackSettled = true; - /** 播放器当前媒体是否已就绪(STATE_READY)。stop 后到新集 READY 前,Exo getPosition() - * 仍返回旧集残留位置,这段加载期禁止把播放器读数写回 history。 */ - private boolean inlinePlayerMediaReady = true; private boolean inlineFirstReady; - private boolean inlinePlayHealthRecorded; - private String inlinePlayHealthKey = ""; private boolean inlineButtonsReordered; private View mNightModeOverlay; private int mNightModeLevel = PlayerSetting.NIGHT_MODE_OFF; @@ -497,8 +398,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private boolean inlinePiPSourceFrozen; private long inlineStartPosition = C.TIME_UNSET; private int selectedSeasonNumber = -1; - private int followingUiGeneration; - private boolean followingActionPending; private TmdbSeasonMatchCache.Entry tmdbSeasonBinding; private TmdbItem pendingTmdbSeasonChoice; private int lastEpisodeMediaSeason = Integer.MIN_VALUE; @@ -508,18 +407,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private int statusBarInsetTop; private int detailThemeMode; private int loadGeneration; - private SubscriptionTmdbCredentialStore.Scope tmdbCredentialScope; /** 本次取详情的起始时刻,用来认出「猫源开内嵌页」是不是自己这次导航触发的。volatile:加载在后台线程发起,事件在主线程读。 */ private volatile long detailLoadStart; private int inlinePlaybackGeneration; - /** 详情页结果应用的分帧队列:把一次主线程长任务切成每帧一片,避免加载圈停帧。 */ - private final ArrayDeque renderChunks = new ArrayDeque<>(); - private boolean renderChunkScheduled; - private int mAdFeedbackGeneration; + private AdFeedbackController mAdFeedback; + private AdFeedbackHostAdapter mAdFeedbackHost; + private AdFeedbackDialog mAdFeedbackDialog; + /** 标记模式下已打的起点,-1 表示未进入标记模式。 */ + private long mAdMarkStartMs = -1; private int tmdbDialogGeneration; private AiEpisodeSeasonService aiSeasonService; - private final List inlineCustomActionViews = new ArrayList<>(); - private boolean inlineCustomButtonsInitialized; private AlertDialog aiSeasonLoadingDialog; private int tmdbApplyGeneration; private int tmdbEpisodeDetailGeneration; @@ -543,10 +440,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private int renderedEpisodeRangeIndex = -1; private int pendingEpisodeRangeFocus = -1; private int lastDetailEpisodeFocusRowStart = RecyclerView.NO_POSITION; - // 长按连续选集:最近一次请求的目标卡片。焦点落地前按住方向的重复按键以它为基准继续推进, - // 同时充当待定对焦回调的失效令牌(换目标即作废旧回调)。 - private int pendingDetailEpisodeTarget = RecyclerView.NO_POSITION; - private static final int DETAIL_EPISODE_FOCUS_MAX_ATTEMPTS = 6; private boolean tmdbMediaLoading; private boolean lightTheme; private boolean backdropSlideLoading; @@ -651,10 +544,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Nullable TmdbItem tmdbItem, int detailMode, boolean autoPlay, @Nullable History resumeHistory, String playFlag, String playFlagKey, String playEpisodeName, String playEpisodeUrl, String searchKeyword) { - if (SiteApi.PUSH.equals(key) && PushParser.fromId(id).shouldSkipAutoTmdbMatch()) { - startDirectFromHistory(activity, key, id, name, pic, mark, playFlag, playFlagKey, playEpisodeName, playEpisodeUrl, resumeHistory); - return; - } if (!TextUtils.isEmpty(key) && !SiteApi.PUSH.equals(key) && AudioUtil.isAudioSiteEnabled(key)) { startDirectFromHistory(activity, key, id, name, pic, mark, playFlag, playFlagKey, playEpisodeName, playEpisodeUrl, resumeHistory); return; @@ -766,25 +655,20 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected boolean shouldBindPlaybackService() { - return modeController.shouldBindPlaybackService(); + return isFusionMode() || isPlayerMode(); } @Override protected void initView(Bundle savedInstanceState) { inflateMobileInlineControl(); - initModeController(); super.initView(savedInstanceState); - // 初始值必须来自平台真实状态:进程在画中画里被回收后重建时不会再走一次回调, - // 字段留在默认 false 就会把恢复中的 PiP 短暂当成全屏。 - inPip = isInPictureInPictureMode(); - tmdbConfig = TmdbConfig.effectiveCurrent(); - tmdbCredentialScope = SubscriptionTmdbCredentialStore.currentScope(); + tmdbConfig = TmdbConfig.objectFrom(Setting.getTmdbConfig()); initialTmdbItem = getIntentTmdbItem(); detailThemeMode = Setting.getTmdbDetailTheme(); applyDetailEdgeToEdge(); applySystemBarInsets(); initPage(); - bindModeController(); + initModeController(); setLoadingOnlyBeforeDefaultPlayback(shouldUseLoadingOnlyBeforeDefaultPlayback()); loadContent(null); } @@ -793,7 +677,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); - resetPlaybackOwnership(); brokenSources.clear(); resetDetailState(); loadContent(null); @@ -802,17 +685,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void resetDetailState() { cancelAiSeasonAnalysis(false); closeInlineSearch(); - tmdbConfig = TmdbConfig.effectiveCurrent(); - tmdbCredentialScope = SubscriptionTmdbCredentialStore.currentScope(); + tmdbConfig = TmdbConfig.objectFrom(Setting.getTmdbConfig()); initialTmdbItem = getIntentTmdbItem(); vod = null; matchedTmdbItem = null; matchedTmdbDetail = null; - // 换源/换片会在同一个 Activity 里清空身份:不跟着撤回,插件在这段空窗里点开会读到上一部剧。 - try { - com.fongmi.android.tv.plugin.PluginEpisodeContext.clear(getClass().getSimpleName()); - } catch (Throwable ignored) { - } cachedEpisodeInfo = null; cachedEpisodeInfoItem = null; cachedEpisodeInfoDetail = null; @@ -826,8 +703,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. clearEpisodeRenderCaches(); resetEpisodeRange(); inlineStarted = false; - inlinePlaybackPending = false; - detailPlayerFullscreenPending = false; detailPlayerActive = false; autoPlayed = false; inlinePlaybackGeneration++; @@ -835,14 +710,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. tmdbApplyGeneration++; tmdbEpisodeDetailGeneration++; sourceSearchGeneration++; + inlinePlaybackLoading = false; + inlinePlayerSwitchLoading = false; inlineStartPosition = C.TIME_UNSET; pendingInlineResult = null; currentInlineResult = null; activeTmdbBundle = null; - activeSourcePayload = null; - clearRenderChunks(); - activeSourceItem = null; - activeRuntimeDecision = null; useParse = false; detailTmdbPhotos.clear(); detailTmdbPosters.clear(); @@ -860,9 +733,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. player().stop(); player().clear(); } - updateNavigationKey(); binding.loading.setVisibility(View.VISIBLE); setLoadingOnlyBeforeDefaultPlayback(shouldUseLoadingOnlyBeforeDefaultPlayback()); + hideInlineLoading(); binding.playerError.setVisibility(View.GONE); binding.playerControls.setVisibility(View.GONE); binding.detailControlHost.setVisibility(View.GONE); @@ -894,9 +767,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.keep.setOnClickListener(view -> onKeep()); binding.keepTop.setOnClickListener(view -> onKeep()); binding.keepFusion.setOnClickListener(view -> onKeep()); - binding.following.setOnClickListener(view -> onFollowing()); - binding.followingTop.setOnClickListener(view -> onFollowing()); - binding.followingFusion.setOnClickListener(view -> onFollowing()); binding.rematch.setOnClickListener(view -> showManualTmdbMatchDialog()); binding.rematchTop.setOnClickListener(view -> showManualTmdbMatchDialog()); binding.rematchFusion.setOnClickListener(view -> showManualTmdbMatchDialog()); @@ -927,23 +797,20 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. setupOverviewInteraction(); if (Util.isMobile()) binding.headerTitle.setText(""); else binding.headerTitle.setText(detailModeTitle()); - binding.headerTitle.setVisibility(Util.isMobile() || !modeController.isCinemaStyle() ? View.VISIBLE : View.INVISIBLE); + binding.headerTitle.setVisibility(Util.isMobile() || !isCinemaMode() ? View.VISIBLE : View.INVISIBLE); binding.title.setText(getNameText()); binding.subtitle.setText(""); binding.sourceValue.setText(getString(R.string.detail_source_current, getKeyText())); binding.overviewToggle.setVisibility(View.GONE); binding.play.setText(R.string.detail_play_now); binding.keep.setText(R.string.keep); - binding.following.setText(R.string.following); lightTheme = resolveLightTheme(); updateThemeModeButtonLabels(); binding.keepTop.setVisibility(View.GONE); - binding.followingTop.setVisibility(View.GONE); binding.rematchTop.setVisibility(View.GONE); binding.headerBar.setVisibility(Util.isMobile() ? View.VISIBLE : View.GONE); updateDetailThemeButtonVisibility(); applyDetailTemplate(); - installCardRowFocusMargin(); initFusionPlayer(); binding.episodeEmpty.setText(R.string.detail_source_episode_empty); bindInitialArtwork(); @@ -954,9 +821,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } @Override - public void onItemLongClick(View anchor, Episode episode, int episodeNumber, TmdbEpisode tmdbEpisode) { + public void onItemLongClick(View anchor, Episode episode, int episodeNumber) { anchor.setPressed(false); - showTmdbEpisodeDetail(episode, episodeNumber, tmdbEpisode, binding.episodeContainer); + showTmdbEpisodeDetail(episode, episodeNumber, binding.episodeContainer); } }); episodeAdapter.setNativeEnhanced(true); @@ -977,12 +844,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. personalDoubanAdapter.setOnItemLongClickListener(item -> onRecommendationLongClick(item, "douban")); personalAiAdapter.setOnItemLongClickListener(item -> onRecommendationLongClick(item, "ai")); personalAiAdapter.setOnItemFocusListener(this::showAiRecommendationReason); - castAdapter.setCinema(modeController.isCinemaStyle()); - creatorAdapter.setCinema(modeController.isCinemaStyle()); - relatedAdapter.setCinema(modeController.isCinemaStyle()); - personalTmdbAdapter.setCinema(modeController.isCinemaStyle()); - personalDoubanAdapter.setCinema(modeController.isCinemaStyle()); - personalAiAdapter.setCinema(modeController.isCinemaStyle()); + castAdapter.setCinema(isCinemaMode()); + creatorAdapter.setCinema(isCinemaMode()); + relatedAdapter.setCinema(isCinemaMode()); + personalTmdbAdapter.setCinema(isCinemaMode()); + personalDoubanAdapter.setCinema(isCinemaMode()); + personalAiAdapter.setCinema(isCinemaMode()); setDetailAdaptersLight(resolveLightTheme()); updateEpisodeLayoutManager(); binding.episodeContainer.setItemAnimator(null); @@ -1006,11 +873,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.relatedList.setAdapter(relatedAdapter); binding.relatedVideoList.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)); binding.relatedVideoList.setNestedScrollingEnabled(false); - // 相关视频卡片本体 276x156dp,焦点放大 1.04 倍后每侧横向多出约 5.5dp、纵向约 3.1dp。 - // 与 posterList 相同的既有做法:关闭自身裁剪并按四周预留内边距,保证放大后描边完整。 - binding.relatedVideoList.setClipToOutline(false); - binding.relatedVideoList.setClipChildren(false); - binding.relatedVideoList.setPaddingRelative(ResUtil.dp2px(8), ResUtil.dp2px(6), ResUtil.dp2px(8), ResUtil.dp2px(6)); binding.relatedVideoList.setAdapter(relatedVideoAdapter); binding.personalTmdbList.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)); binding.personalTmdbList.setNestedScrollingEnabled(false); @@ -1022,10 +884,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.personalAiList.setNestedScrollingEnabled(false); binding.personalAiList.setAdapter(personalAiAdapter); applyDetailTheme(); - // BaseActivity applies the global Material theme after initView/initEvent. Profile - // detail owns translucent cards and backdrop surfaces, so reapply that contract - // after the global pass instead of letting tokens.surface() force opaque cards. - binding.root.post(this::applyDetailTheme); } private void initModeController() { @@ -1040,44 +898,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. public ViewBinding binding() { return binding; } - - @Override - public boolean isCinemaStyle() { - return rawCinemaMode(); - } - - @Override - public void closeDetailFullscreenPlayer() { - TmdbDetailActivity.this.closeDetailFullscreenPlayer(); - } - - @Override - public void playInline() { - TmdbDetailActivity.this.playInline(); - } - - @Override - public void playDetailFullscreen() { - TmdbDetailActivity.this.playDetailFullscreen(); - } - - @Override - public void playDefaultPlayback() { - TmdbDetailActivity.this.playDefaultPlayback(); - } }; - if (rawFusionMode()) { + if (isFusionMode()) { modeController = new FusionDetailController(host); - } else if (rawPlayerMode()) { + } else if (isPlayerMode()) { modeController = new PlayerDetailController(host); } else { modeController = new EnhancedDetailController(host); } - } - - private void bindModeController() { modeController.bind(); modeController.applyInitialLayout(); } @@ -1109,7 +939,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.headerBar.getPaddingRight(), binding.headerBar.getPaddingBottom() ); - if (!modeController.isCinemaStyle()) TmdbDetailLayoutUtils.setHeightDp(binding.heroSpacer, defaultHeroSpacerHeightDp()); + if (!isCinemaMode()) TmdbDetailLayoutUtils.setHeightDp(binding.heroSpacer, defaultHeroSpacerHeightDp()); return insets; }); ViewCompat.requestApplyInsets(binding.root); @@ -1126,7 +956,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. window.setNavigationBarContrastEnforced(false); } WindowInsetsControllerCompat insets = WindowCompat.getInsetsController(window, window.getDecorView()); - boolean lightBars = lightTheme && !modeController.shouldShowInlinePlayer(); + boolean lightBars = lightTheme && !isFusionMode(); insets.setAppearanceLightStatusBars(lightBars); insets.setAppearanceLightNavigationBars(lightBars); } @@ -1184,12 +1014,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return false; } - @Override - public boolean isFullscreen() { - // 详情页内嵌小窗只有在用户点了小窗全屏之后才算全屏;媒体信息徽标只在全屏出现 - return inlineFullscreen; - } - @Override public void showDanmakuDialog() { showInlineDanmaku(); @@ -1377,12 +1201,12 @@ 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()); inlinePlayerUi.bindInlineActions(); setupMobileInlineControl(); - setupInlineCustomButtons(); hideInlineControls(); updateInlineButtons(false); focusInlinePlayerPanel(); @@ -1436,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(); @@ -1451,92 +1276,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. parse.setAdapter(inlineParseAdapter = new InlineParseAdapter()); } - /** - * 影视原生模式和详情页使用不同的控制条。原生模式会在 VideoActivity - * 中创建 scripts 按钮,详情页此前只绑定了内置按钮,因此沉浸融合模式 - * 虽然 MPV 已加载脚本桥接文件,却没有任何入口发送 script-message。 - */ - private void setupInlineCustomButtons() { - if (inlineCustomButtonsInitialized) return; - inlineCustomButtonsInitialized = true; - List buttons = MpvConfigStore.customButtons(); - if (buttons.isEmpty()) return; - - ViewGroup playerActions = (ViewGroup) binding.playerActionRow.getChildAt(0); - ViewGroup mobileActions = Util.isMobile() && detailActionRoot != null - ? detailActionRoot.findViewById(R.id.container) : null; - for (MpvConfigStore.CustomButton button : buttons) { - if (button == null || !button.enabled) continue; - addInlineCustomButton(playerActions, button); - if (mobileActions != null) addInlineCustomButton(mobileActions, button); - } - setupInlineCustomButtonFocus(); - updateInlineCustomButtonVisibility(); - } - - private void addInlineCustomButton(ViewGroup container, MpvConfigStore.CustomButton button) { - TextView view = new TextView(this); - view.setId(View.generateViewId()); - view.setTextSize(13); - view.setTextColor(Color.WHITE); - view.setGravity(Gravity.CENTER); - view.setMinHeight(ResUtil.dp2px(40)); - view.setMinWidth(ResUtil.dp2px(56)); - view.setPadding(ResUtil.dp2px(8), ResUtil.dp2px(4), ResUtil.dp2px(8), ResUtil.dp2px(4)); - view.setBackgroundResource(R.drawable.selector_control_sheet_button); - view.setText(button.title); - view.setSingleLine(true); - view.setMaxWidth(ResUtil.dp2px(144)); - view.setEllipsize(TextUtils.TruncateAt.END); - view.setContentDescription(button.title); - view.setOnClickListener(item -> dispatchInlineCustomButton(item, button.id, false)); - view.setOnLongClickListener(item -> { - dispatchInlineCustomButton(item, button.id, true); - return true; - }); - view.setFocusable(true); - if (Util.isLeanback()) view.setFocusableInTouchMode(true); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, ResUtil.dp2px(40)); - params.setMargins(ResUtil.dp2px(4), ResUtil.dp2px(2), ResUtil.dp2px(4), ResUtil.dp2px(2)); - container.addView(view, params); - inlineCustomActionViews.add(view); - } - - private void dispatchInlineCustomButton(View view, String id, boolean longPress) { - if (service() == null || player() == null || !player().isMpv()) return; - if (player().sendMpvCustomButton(id, longPress)) view.setSelected(!view.isSelected()); - setInlineHideCallback(); - } - - private void updateInlineCustomButtonVisibility() { - boolean visible = service() != null && player() != null - && !player().isEmpty() && player().isMpv(); - for (View view : inlineCustomActionViews) view.setVisibility(visible ? View.VISIBLE : View.GONE); - } - - /** - * 自定义按钮不属于 PlayerButtonSetting 的固定按钮集合,不能依赖固定按钮的焦点链。 - * 否则 TV 端最后一个内置按钮的右焦点仍然是 NO_ID,动态追加的按钮无法用方向键到达。 - */ - private void setupInlineCustomButtonFocus() { - if (Util.isMobile()) return; - ViewGroup container = (ViewGroup) binding.playerActionRow.getChildAt(0); - List focusable = new ArrayList<>(); - for (int i = 0; i < container.getChildCount(); i++) { - View view = container.getChildAt(i); - if (view.getVisibility() == View.VISIBLE && view.isEnabled() && view.isFocusable()) focusable.add(view); - } - for (int i = 0; i < focusable.size(); i++) { - View current = focusable.get(i); - View previous = focusable.get(i == 0 ? focusable.size() - 1 : i - 1); - View next = focusable.get(i == focusable.size() - 1 ? 0 : i + 1); - current.setNextFocusLeftId(previous.getId()); - current.setNextFocusRightId(next.getId()); - current.setNextFocusUpId(current.getId()); - } - } - private void inflateMobileInlineControl() { if (!Util.isMobile() || detailControlRoot != null) return; detailControlRoot = getLayoutInflater().inflate(R.layout.view_control_vod_tmdb, binding.detailControlHost, false); @@ -1723,15 +1462,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (focused) inlineControlFocus = control; updatePlayerPanelFocus(); }); - // 遥控以外的输入(鼠标/触摸点击)不会移动焦点:只靠上面的焦点监听时, - // 用户点过的按钮不会被记住,再唤出控制栏就只能落到默认候选按钮上。 - // 返回 false 保证不吞事件,原有点击链路不受影响。 - if (!Util.isMobile()) { - view.setOnTouchListener((control, event) -> { - if (event.getActionMasked() == MotionEvent.ACTION_DOWN) inlineControlFocus = control; - return false; - }); - } } private boolean hasFocusedChild(View view) { @@ -2002,7 +1732,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. applyBackdropSurface(colors); binding.backdropFill.setAlpha(backdropSlideAlpha()); binding.backdrop.setAlpha(backdropSlideAlpha()); - binding.backdropShade.setBackground(modeController.isCinemaStyle() ? cinemaBackdropShade() : TmdbDetailLayoutUtils.colorDrawable(colors.backdropShade)); + binding.backdropShade.setBackground(isCinemaMode() ? cinemaBackdropShade() : TmdbDetailLayoutUtils.colorDrawable(colors.backdropShade)); setCard(binding.contentPanel, colors.panel, colors.line); setPlayerCard(colors); setCard(binding.tmdbPanel, colors.panel, colors.line); @@ -2012,9 +1742,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. setDetailActionButton(binding.keep, colors); setDetailActionButton(binding.keepTop, colors); setDetailActionButton(binding.keepFusion, colors); - setDetailActionButton(binding.following, colors); - setDetailActionButton(binding.followingTop, colors); - setDetailActionButton(binding.followingFusion, colors); setDetailActionButton(binding.rematch, colors); setDetailActionButton(binding.rematchTop, colors); setDetailActionButton(binding.rematchFusion, colors); @@ -2035,9 +1762,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.overviewToggle.setTextColor(colors.accent); binding.episodeEmpty.setTextColor(colors.secondary); binding.tmdbStatus.setTextColor(colors.secondary); - boolean lightCinemaText = modeController.isCinemaStyle() && !lightTheme; - binding.personalAiReason.setTextColor(lightCinemaText ? 0xE6FFFFFF : colors.secondary); - if (lightCinemaText) binding.personalAiReason.setShadowLayer(3f, 0f, 1.5f, 0xCC000000); + binding.personalAiReason.setTextColor(isCinemaMode() ? 0xE6FFFFFF : colors.secondary); + if (isCinemaMode()) binding.personalAiReason.setShadowLayer(3f, 0f, 1.5f, 0xCC000000); else binding.personalAiReason.setShadowLayer(0f, 0f, 0f, 0x00000000); tintTmdbSectionTitles(colors); styleSourceValue(); @@ -2048,6 +1774,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.playerError.setTextColor(0xFFFFFFFF); tintInlineControl(inlineControlsView()); tintInlineDisplay(); + tintInlineLoading(); } if (episodeAdapter != null) { episodeAdapter.setTheme(lightTheme, colors.accent); @@ -2055,7 +1782,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (episodePhotoAdapter != null) episodePhotoAdapter.setLight(lightTheme); if (posterAdapter != null) posterAdapter.setLight(lightTheme); setDetailAdaptersLight(lightTheme); - if (modeController.isCinemaStyle()) scheduleBackdropSlide(BACKDROP_SLIDE_DELAY_MS); + if (isCinemaMode()) scheduleBackdropSlide(BACKDROP_SLIDE_DELAY_MS); } private void styleSourceValue() { @@ -2091,12 +1818,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.personalAiTitle, binding.externalLinksTitle }; - // 光影剧幕的文字色跟随其调色板:深色剧幕是浅字(靠暗投影压住背景图),浅色剧幕是深字。 - // 此前不分深浅一律强制白字,浅色剧幕恢复白色中间层后白字压白层会直接看不清。 - boolean lightText = modeController.isCinemaStyle() && !lightTheme; + int color = isCinemaMode() ? 0xFFFFFFFF : colors.primary; for (TextView title : titles) { - title.setTextColor(colors.primary); - if (lightText) title.setShadowLayer(3f, 0f, 1.5f, 0xCC000000); + title.setTextColor(color); + if (isCinemaMode()) title.setShadowLayer(3f, 0f, 1.5f, 0xCC000000); else title.setShadowLayer(0f, 0f, 0f, 0x00000000); } updateTmdbSeasonActionVisibility(); @@ -2108,7 +1833,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean pictureInPicture = isInPictureInPictureMode(); boolean showMobileButton = DetailThemeVisibility.showMobileThemeButton(mobile, inlineFullscreen, inlinePiPLayout, pictureInPicture); boolean showLargeScreenButton = DetailThemeVisibility.showLargeScreenThemeButton(mobile, inlineFullscreen, inlinePiPLayout, pictureInPicture); - boolean fusionMode = modeController.isFusionMode(); + boolean fusionMode = isFusionMode(); boolean playbackPage = isAutoPlayMode() || detailPlayerActive; // 手机版也使用底部一排的主题按钮(themeModeDetail),不再使用右上角浮动按钮(themeModeTop) binding.themeModeTop.setVisibility(View.GONE); @@ -2118,7 +1843,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void applyTemplateCardChrome(ThemeColors colors) { - if (modeController.isCinemaStyle()) { + if (isCinemaMode()) { binding.contentPanel.setCardBackgroundColor(0x00000000); binding.contentPanel.setStrokeWidth(0); binding.tmdbPanel.setCardBackgroundColor(0x00000000); @@ -2134,7 +1859,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void applyDetailTemplate() { - if (modeController.isCinemaStyle()) applyCinemaDetailTemplate(); + if (isCinemaMode()) applyCinemaDetailTemplate(); else applyDefaultDetailTemplate(); } @@ -2144,7 +1869,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. TmdbDetailLayoutUtils.setWidthMatch(binding.contentPanel); TmdbDetailLayoutUtils.setWidthMatch(binding.tmdbSection); TmdbDetailLayoutUtils.setMarginsDp(binding.contentPanel, 16, 0, 16, 0); - TmdbDetailLayoutUtils.setMarginsDp(binding.playerPanel, 16, modeController.isFusionMode() ? 22 : 14, 16, modeController.isFusionMode() ? 20 : 16); + TmdbDetailLayoutUtils.setMarginsDp(binding.playerPanel, 16, isFusionMode() ? 22 : 14, 16, isFusionMode() ? 20 : 16); TmdbDetailLayoutUtils.setMarginsDp(binding.tmdbSection, 16, 16, 16, 0); binding.contentPanel.setRadius(ResUtil.dp2px(20)); binding.tmdbPanel.setRadius(ResUtil.dp2px(20)); @@ -2165,9 +1890,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.title.setTextSize(28f); binding.overview.setTextSize(13f); TmdbDetailLayoutUtils.setHeightDp(binding.episodePhotoList, 124); - TmdbDetailLayoutUtils.setHeightDp(binding.posterList, 238); - binding.posterList.setClipToOutline(false); - binding.posterList.setClipChildren(false); TmdbDetailLayoutUtils.setHeightDp(binding.castList, 180); TmdbDetailLayoutUtils.setHeightDp(binding.creatorList, 180); TmdbDetailLayoutUtils.setHeightDp(binding.relatedList, 262); @@ -2178,7 +1900,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private int defaultHeroSpacerHeightDp() { - if (modeController.isFusionMode()) return 0; + if (isFusionMode()) return 0; if (!Util.isMobile()) return 102; int insetDp = Math.round(statusBarInsetTop / getResources().getDisplayMetrics().density); return Math.max(72, 102 - Math.min(insetDp, 30)); @@ -2219,16 +1941,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.subtitle.setTextSize(compact ? 13f : 14f); binding.overview.setTextSize(compact ? 14.5f : 16f); binding.overview.setLineSpacing(ResUtil.dp2px(compact ? 4 : 3), 1f); - // 剧照卡片固定为 124dp 高;容器保持同高,避免大屏剧幕主题在剧照与下一栏之间留下额外空白。 - TmdbDetailLayoutUtils.setHeightDp(binding.episodePhotoList, 124); - // 海报卡片本体为 148x222dp,列表额外保留焦点放大空间,确保完整显示四角圆角。 - TmdbDetailLayoutUtils.setHeightDp(binding.posterList, 238); - binding.posterList.setClipToOutline(false); - binding.posterList.setClipChildren(false); + TmdbDetailLayoutUtils.setHeightDp(binding.episodePhotoList, compact ? 128 : 160); TmdbDetailLayoutUtils.setHeightDp(binding.castList, compact ? 90 : 90); TmdbDetailLayoutUtils.setHeightDp(binding.creatorList, compact ? 90 : 90); TmdbDetailLayoutUtils.setHeightDp(binding.relatedList, compact ? 160 : 160); - TmdbDetailLayoutUtils.setHeightDp(binding.relatedVideoList, compact ? 128 : 168); + TmdbDetailLayoutUtils.setHeightDp(binding.relatedVideoList, compact ? 128 : 160); TmdbDetailLayoutUtils.setHeightDp(binding.personalTmdbList, compact ? 160 : 160); TmdbDetailLayoutUtils.setHeightDp(binding.personalDoubanList, compact ? 160 : 160); TmdbDetailLayoutUtils.setHeightDp(binding.personalAiList, compact ? 160 : 160); @@ -2267,13 +1984,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return new LayerDrawable(new Drawable[]{horizontal, vertical}); } - /** - * 浅色光影剧幕的白色中间层。 - * - *

它与深色模式的黑幕同构:都是压在背景图之上、内容之下的全屏渐变层。 - * 深色剧幕一直是「暗幕 + 浅字」,浅色剧幕此前该层被改成全透明,内容直接浮在 - * 剧照原图上,文字与 chip 都没有稳定底板,所以既看不清也不像深色模式。 - */ private Drawable cinemaLightBackdropShade() { boolean compact = isCompactWidth(); GradientDrawable horizontal = new GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, compact ? new int[]{ @@ -2303,6 +2013,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.playerDisplayTraffic.setTextColor(0xFFFFFFFF); } + private void tintInlineLoading() { + binding.playerProgressTraffic.setTextColor(0xFFFFFFFF); + binding.playerProgressTraffic.setAlpha(1f); + } + private void tintInlineGestureOverlay() { tintInlineControl(binding.gestureSeek); tintInlineControl(binding.gestureBright); @@ -2355,12 +2070,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } boolean focused = binding.playerPanel.hasFocus() && !hasFocusedChild(inlineControlsView()); - binding.playerPanel.setStrokeColor(focused ? focusStroke() : colors.line); - binding.playerPanel.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(CHIP_STROKE_DP)); + binding.playerPanel.setStrokeColor(focused ? FOCUS_STROKE : colors.line); + binding.playerPanel.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : CHIP_STROKE_DP)); } private boolean isLeanbackInlinePlayerPanel() { - return Util.isLeanback() && (modeController.isFusionMode() || modeController.isPlayerMode()); + return Util.isLeanback() && (isFusionMode() || isPlayerMode()); } private void setupPlayerPanelFocusLayer() { @@ -2404,24 +2119,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void applyButtonFocus(MaterialButton button, int stroke, boolean focused) { - button.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(CHIP_STROKE_DP)); - button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : stroke)); - } - - /** - * 焦点环宽度(px):唯一来源 {@code @dimen/webhtv_focus_ring_width}。 - * - *

详情页有一批控件是代码挂环({@code setStrokeWidth}/{@code GradientDrawable.setStroke}), - * 不走 drawable 层。此前它们写死 {@code FOCUS_STROKE_DP = 3},于是前几轮统一 drawable - * 宽度时漏掉了这批控件(用户报告「个性推荐等卡片的边框粗细没有改小没有统一」)。 - */ - private int focusRingWidthPx() { - return getResources().getDimensionPixelSize(R.dimen.webhtv_focus_ring_width); - } - - /** TV 焦点环的唯一代码来源:与 {@code ?attr/tvFocusRing} 同一取值,跟随主题 FOCUS 槽。 */ - private int focusStroke() { - return ThemeController.focusRingColor(this); + button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : CHIP_STROKE_DP)); + button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : stroke)); } private void setEpisodeToolButton(MaterialButton button, ThemeColors colors) { @@ -2457,17 +2156,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void applyEpisodeTitleButtonFocus(MaterialButton button, ThemeColors colors) { boolean actionable = button.isFocusable() && button.isEnabled(); boolean focused = actionable && button.hasFocus(); - boolean lightCinemaPlate = lightTheme && isCinemaStyle(); - button.setCornerRadius(ResUtil.dp2px(18)); - button.setBackgroundTintList(ColorStateList.valueOf(focused ? colors.control : episodeTitleRestingColor(lightCinemaPlate, colors))); + button.setBackgroundTintList(ColorStateList.valueOf(focused ? colors.control : Color.TRANSPARENT)); button.setTextColor(colors.primary); button.setIconTint(ColorStateList.valueOf(colors.primary)); - button.setStrokeWidth(focused ? focusRingWidthPx() : (lightCinemaPlate ? ResUtil.dp2px(CHIP_STROKE_DP) : 0)); - button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : (lightCinemaPlate ? colors.line : Color.TRANSPARENT))); - } - - private int episodeTitleRestingColor(boolean lightCinemaPlate, ThemeColors colors) { - return lightCinemaPlate ? colors.chip : Color.TRANSPARENT; + button.setStrokeWidth(focused ? ResUtil.dp2px(FOCUS_STROKE_DP) : 0); + button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : Color.TRANSPARENT)); } private void applyEpisodeToolButtonFocus(MaterialButton button, ThemeColors colors) { @@ -2477,8 +2170,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. button.setBackgroundTintList(ColorStateList.valueOf(colors.control)); button.setTextColor(colors.primary); button.setIconTint(ColorStateList.valueOf(colors.primary)); - button.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(CHIP_STROKE_DP)); - button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : colors.lineStrong)); + button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : CHIP_STROKE_DP)); + button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : colors.lineStrong)); } private void tintTextTree(View view, ThemeColors colors) { @@ -2493,10 +2186,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void loadContent(@Nullable TmdbBundle reusableBundle) { int generation = ++loadGeneration; - tmdbCredentialScope = SubscriptionTmdbCredentialStore.currentScope(); detailTasks.cancelAll(); - detailPrefetch.cancel(); - startTmdbDetailPrefetch(initialTmdbItem, reusableBundle); int mode = getDetailMode(); String key = getKeyText(); String id = getIdText(); @@ -2507,6 +2197,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. detailTasks.submit(() -> { if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; boolean tmdbAllowed = isTmdbAllowedForCurrentSite(); + Future tmdbFuture = reusableBundle == null && tmdbConfig.isReady() && tmdbAllowed + ? detailTasks.submitCallable(Task.largeExecutor(), this::loadTmdbResult) + : null; + boolean singlePassStandaloneTmdb = shouldLoadInitialStandaloneTmdbDetailInSinglePass(reusableBundle, tmdbFuture); + SpiderDebug.log("tmdb-detail-flow", "load tasks mode=%d tmdbAllowed=%s singlePass=%s reusable=%s", mode, tmdbAllowed, singlePassStandaloneTmdb, reusableBundle != null); Vod loadedVod = null; String error = null; long sourceStart = System.currentTimeMillis(); @@ -2517,6 +2212,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (com.fongmi.android.tv.api.CatAction.shouldYieldDetail(key, loadStart, result)) { SpiderDebug.log("tmdb-detail-flow", "yield to cat webview key=%s id=%s", key, id); runOnAliveUi(this::finish); + if (tmdbFuture != null) tmdbFuture.cancel(true); return; } if (result != null && !result.getList().isEmpty()) { @@ -2530,169 +2226,80 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } SpiderDebug.log("tmdb-detail-flow", "source detail cost=%dms mode=%d key=%s id=%s hit=%s error=%s", System.currentTimeMillis() - sourceStart, mode, key, id, loadedVod != null, TextUtils.isEmpty(error) ? "" : error); - if (generation != loadGeneration || Thread.currentThread().isInterrupted() - || !SubscriptionTmdbCredentialStore.isCurrent(tmdbCredentialScope)) { + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) { + if (tmdbFuture != null) tmdbFuture.cancel(true); return; } - tmdbConfig = TmdbConfig.effectiveCurrent(); - tmdbCredentialScope = SubscriptionTmdbCredentialStore.currentScope(); Vod finalVod = loadedVod; String finalError = error; - - TmdbSourcePayload sourcePayload = finalVod == null ? null : TmdbSourcePayloadParser.parse(finalVod.getTmdb()); - TmdbBundle sourceBundle = sourcePayload == null ? null : TmdbSourceAdapter.toBundle(sourcePayload, finalVod, tmdbConfig); - TmdbSourceState sourceState = TmdbSourceAvailability.classify(finalVod, sourcePayload, sourceBundle); - boolean tmdbReady = tmdbConfig != null && tmdbConfig.isReady(); - DetailRuntimeModePolicy.Decision decision = DetailRuntimeModePolicy.resolve(new DetailRuntimeModePolicy.Input( - mode, - tmdbReady, - tmdbAllowed, - sourceState - )); - SpiderDebug.log("tmdb-detail-flow", "runtime policy mode=%d runtime=%d source=%s tmdbReady=%s siteAllowed=%s sourceOnly=%s network=%s", - mode, decision.runtimeMode(), sourceState, tmdbReady, tmdbAllowed, decision.sourceOnly(), decision.networkAllowed()); - if (decision.runtimeMode() == Setting.DETAIL_OPEN_DIRECT) { - detailPrefetch.cancel(); - String reason = !tmdbAllowed ? "site_tmdb_disabled" - : sourceState == TmdbSourceState.IDENTITY_ONLY ? "source_identity_only_without_key" - : "source_absent_or_invalid"; - runOnAliveUi(() -> { - if (generation != loadGeneration) return; - if (finalVod == null) applyLoaded(null, null, new ArrayList<>(), finalError, false); - else fallbackToOriginalDetail(finalVod, reason); - }); - return; - } - if (sourceBundle != null) { - // 源内嵌 payload 路径不消费预取的完整详情:结果丢弃,同时释放单槽与在途线程。 - detailPrefetch.cancel(); - if (reusableBundle != null) sourceBundle = TmdbSourceMerger.merge(sourceBundle, sourcePayload, reusableBundle, null, TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())).bundle(); - TmdbBundle initialBundle = sourceBundle; - runOnAliveUi(() -> { - if (generation != loadGeneration) return; - activeRuntimeDecision = decision; - activeSourcePayload = sourcePayload; - activeSourceItem = initialBundle.item(); - applyLoaded(finalVod, initialBundle, new ArrayList<>(), finalError, false); - }); - if (!decision.networkAllowed()) return; - TmdbSourceCapabilityPlanner.Plan plan = TmdbSourceCapabilityPlanner.plan(initialBundle, sourcePayload, TmdbSourceCapabilityPlanner.UiState.initialScreen(), TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); - SpiderDebug.log("tmdb-detail-flow", "source payload plan required=%s missing=%s total=%dms", plan.required(), plan.missing(), System.currentTimeMillis() - loadStart); - if (!plan.hasInitialNetworkGaps()) return; - try { - long sourceFillStart = System.currentTimeMillis(); - JsonObject detail = tmdbService.detailForSource(initialBundle.item(), sourcePayload.getSeasonNumber(), tmdbConfig, plan.missing()); - TmdbBundle networkBundle = TmdbSourceAdapter.fromNetwork(initialBundle.item(), detail, tmdbConfig); - TmdbBundle mergedBundle = TmdbSourceMerger.fillOnly(initialBundle, sourcePayload, networkBundle, TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); - SpiderDebug.log("tmdb-detail-flow", "source payload network fill cost=%dms groups=%s total=%dms", System.currentTimeMillis() - sourceFillStart, plan.missing(), System.currentTimeMillis() - loadStart); - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - runOnAliveUi(() -> { - if (generation != loadGeneration || vod == null || !isSameTmdbItem(mergedBundle.item(), matchedTmdbItem)) return; - applyTmdbResultNow(new TmdbLoadResult(mergedBundle, List.of())); - }); - } catch (Throwable ignored) { - SpiderDebug.log("tmdb-detail-flow", "source payload network fill failed mode=%d total=%dms", mode, System.currentTimeMillis() - loadStart); - } - return; - } - - if (reusableBundle != null) { - detailPrefetch.cancel(); - runOnAliveUi(() -> { - if (generation != loadGeneration) return; - activeRuntimeDecision = decision; - applyLoaded(finalVod, reusableBundle, new ArrayList<>(), finalError, false); - }); - return; - } - - Future tmdbFuture = decision.networkAllowed() - ? detailTasks.submitCallable(Task.largeExecutor(), this::loadTmdbResult) - : null; - boolean singlePassStandaloneTmdb = shouldLoadInitialStandaloneTmdbDetailInSinglePass(reusableBundle, tmdbFuture); - SpiderDebug.log("tmdb-detail-flow", "load tasks mode=%d tmdbAllowed=%s singlePass=%s reusable=%s", mode, tmdbAllowed, singlePassStandaloneTmdb, reusableBundle != null); if (!singlePassStandaloneTmdb || finalVod == null) { runOnAliveUi(() -> { if (generation != loadGeneration) return; - activeRuntimeDecision = decision; - applyLoaded(finalVod, null, new ArrayList<>(), finalError, false); + applyLoaded(finalVod, reusableBundle, new ArrayList<>(), finalError, false); }); } if (finalVod == null || tmdbFuture == null) { if (tmdbFuture != null) tmdbFuture.cancel(true); - detailPrefetch.cancel(); return; } - // 收尾阶段要阻塞等待 TMDB 结果,转投大线程池执行,释放共享 5 线程池的线程。 - detailTasks.submit(Task.largeExecutor(), - () -> completeTmdbLoad(generation, mode, finalVod, finalError, singlePassStandaloneTmdb, tmdbFuture, decision, loadStart)); - }); - } - - private void completeTmdbLoad(int generation, int mode, Vod loadedVod, String error, - boolean singlePassStandaloneTmdb, Future tmdbFuture, - DetailRuntimeModePolicy.Decision decision, long loadStart) { - final Vod finalVod = loadedVod; - final String finalError = error; - try { - long tmdbWaitStart = System.currentTimeMillis(); - TmdbLoadResult result = tmdbFuture.get(); - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - SpiderDebug.log("tmdb-detail-flow", "tmdb wait cost=%dms mode=%d bundle=%s search=%d total=%dms", System.currentTimeMillis() - tmdbWaitStart, mode, result != null && result.bundle() != null, result == null ? 0 : result.searchItems().size(), System.currentTimeMillis() - loadStart); - if (result != null && result.bundle() == null && finalVod != null) { - String detailTitle = finalVod.getName(); - String initialTitle = getTmdbRawTitle(); - if (!TextUtils.isEmpty(detailTitle) && !normalize(detailTitle).equals(normalize(initialTitle))) { - logTmdbMatch("基础匹配未命中,使用站源标题重试:初始标题=%s,站源标题=%s", initialTitle, detailTitle); - AutoTmdbMatch detailRetry = searchResolvedTmdbMatch(detailTitle, finalVod); - if (detailRetry.item() != null) { - result = new TmdbLoadResult(loadTmdbBundle(detailRetry.item()), detailRetry.items()); - } else if (!detailRetry.items().isEmpty()) { - result = new TmdbLoadResult(null, detailRetry.items()); + try { + long tmdbWaitStart = System.currentTimeMillis(); + TmdbLoadResult result = tmdbFuture.get(); + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; + SpiderDebug.log("tmdb-detail-flow", "tmdb wait cost=%dms mode=%d bundle=%s search=%d total=%dms", System.currentTimeMillis() - tmdbWaitStart, mode, result != null && result.bundle() != null, result == null ? 0 : result.searchItems().size(), System.currentTimeMillis() - loadStart); + if (result != null && result.bundle() == null && finalVod != null) { + String detailTitle = finalVod.getName(); + String initialTitle = getTmdbRawTitle(); + if (!TextUtils.isEmpty(detailTitle) && !normalize(detailTitle).equals(normalize(initialTitle))) { + logTmdbMatch("基础匹配未命中,使用站源标题重试:初始标题=%s,站源标题=%s", initialTitle, detailTitle); + AutoTmdbMatch detailRetry = searchResolvedTmdbMatch(detailTitle, finalVod); + if (detailRetry.item() != null) { + result = new TmdbLoadResult(loadTmdbBundle(detailRetry.item()), detailRetry.items()); + } else if (!detailRetry.items().isEmpty()) { + result = new TmdbLoadResult(null, detailRetry.items()); + } } } - } - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - if (result != null && result.bundle() == null && finalVod != null) { - String query = cleanTmdbSearchQuery(finalVod.getName()); - logTmdbMatch("基础匹配未命中,使用站源详情继续消歧:片名=%s,清洗后=%s,年份=%s,演员=%s,导演=%s,简介长度=%d", - finalVod.getName(), query, finalVod.getYear(), finalVod.getActor(), finalVod.getDirector(), finalVod.getContent().length()); - TmdbBundle bundle = chooseTmdbBundle(result.searchItems(), query, finalVod); - List matchedSearchItems = result.searchItems(); - if (bundle == null) { - SplitYearSearch split = searchSplitYearTmdbItems(query, finalVod); - if (split != null) { - bundle = chooseTmdbBundle(split.items(), split.query(), finalVod); - matchedSearchItems = split.items(); + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; + if (result != null && result.bundle() == null && finalVod != null) { + String query = cleanTmdbSearchQuery(finalVod.getName()); + logTmdbMatch("基础匹配未命中,使用站源详情继续消歧:片名=%s,清洗后=%s,年份=%s,演员=%s,导演=%s,简介长度=%d", + finalVod.getName(), query, finalVod.getYear(), finalVod.getActor(), finalVod.getDirector(), finalVod.getContent().length()); + TmdbBundle bundle = chooseTmdbBundle(result.searchItems(), query, finalVod); + List matchedSearchItems = result.searchItems(); + if (bundle == null) { + SplitYearSearch split = searchSplitYearTmdbItems(query, finalVod); + if (split != null) { + bundle = chooseTmdbBundle(split.items(), split.query(), finalVod); + matchedSearchItems = split.items(); + } } + result = new TmdbLoadResult(bundle, matchedSearchItems); } - result = new TmdbLoadResult(bundle, matchedSearchItems); - } - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - TmdbLoadResult loadedResult = result; - if (singlePassStandaloneTmdb) loadedResult = preloadInitialStandaloneSeason(loadedResult, finalVod); - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - TmdbLoadResult finalResult = loadedResult; - runOnAliveUi(() -> { - if (generation != loadGeneration || (!singlePassStandaloneTmdb && vod == null)) return; - activeRuntimeDecision = decision; - if (singlePassStandaloneTmdb) { - applyLoaded(finalVod, finalResult == null ? null : finalResult.bundle(), finalResult == null ? new ArrayList<>() : finalResult.searchItems(), finalError, true); - } else { - applyTmdbResult(finalResult); - } - }); - } catch (Throwable ignored) { - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - SpiderDebug.log("tmdb-detail-flow", "tmdb wait failed mode=%d total=%dms", mode, System.currentTimeMillis() - loadStart); - if (singlePassStandaloneTmdb) { + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; + TmdbLoadResult loadedResult = result; + if (singlePassStandaloneTmdb) loadedResult = preloadInitialStandaloneSeason(loadedResult, finalVod); + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; + TmdbLoadResult finalResult = loadedResult; runOnAliveUi(() -> { - if (generation != loadGeneration) return; - activeRuntimeDecision = decision; - applyLoaded(finalVod, null, new ArrayList<>(), finalError, false); + if (generation != loadGeneration || (!singlePassStandaloneTmdb && vod == null)) return; + if (singlePassStandaloneTmdb) { + applyLoaded(finalVod, finalResult == null ? null : finalResult.bundle(), finalResult == null ? new ArrayList<>() : finalResult.searchItems(), finalError, true); + } else { + applyTmdbResult(finalResult); + } }); + } catch (Throwable ignored) { + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; + SpiderDebug.log("tmdb-detail-flow", "tmdb wait failed mode=%d total=%dms", mode, System.currentTimeMillis() - loadStart); + if (singlePassStandaloneTmdb) { + runOnAliveUi(() -> { + if (generation != loadGeneration) return; + applyLoaded(finalVod, null, new ArrayList<>(), finalError, false); + }); + } } - } + }); } private boolean shouldLoadInitialStandaloneTmdbDetailInSinglePass(@Nullable TmdbBundle reusableBundle, @Nullable Future tmdbFuture) { @@ -2814,42 +2421,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. }); } - private void enqueueRenderChunk(Runnable chunk) { - if (chunk == null) return; - renderChunks.addLast(chunk); - scheduleRenderChunk(); - } - - private void scheduleRenderChunk() { - if (renderChunkScheduled || renderChunks.isEmpty()) return; - renderChunkScheduled = true; - View root = binding == null ? null : binding.getRoot(); - if (root != null && root.isAttachedToWindow()) root.postOnAnimation(this::runNextRenderChunk); - else App.post(this::runNextRenderChunk); - } - - private void runNextRenderChunk() { - renderChunkScheduled = false; - if (!canTouchUi()) { - renderChunks.clear(); - return; - } - Runnable chunk = renderChunks.pollFirst(); - if (chunk != null) { - try { - chunk.run(); - } catch (Throwable ignored) { - } - } - if (!renderChunks.isEmpty()) scheduleRenderChunk(); - else binding.loading.setVisibility(View.GONE); - } - - private void clearRenderChunks() { - renderChunks.clear(); - renderChunkScheduled = false; - } - private boolean isTmdbRequestCurrent(int generation, @Nullable TmdbItem item) { return generation == loadGeneration && isSameTmdbItem(item, matchedTmdbItem); } @@ -2860,23 +2431,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return first.getTmdbId() == second.getTmdbId() && TextUtils.equals(first.getMediaType(), second.getMediaType()); } - private boolean hasCompleteSourceSeason(int seasonNumber) { - return activeSourcePayload != null && activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.season(seasonNumber)); - } - - private boolean hasCompleteSourceEpisode(int seasonNumber, int episodeNumber) { - return activeSourcePayload != null && activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.episode(seasonNumber, episodeNumber)); - } - - private boolean hasCompleteSourceVideos(@Nullable TmdbItem item, int seasonNumber, int episodeNumber) { - if (activeSourcePayload == null || item == null) return false; - if (item.isMovie()) return activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.VIDEOS); - if (!item.isTv() || !activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.VIDEOS)) return false; - if (seasonNumber < 0) return true; - if (!activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.seasonVideos(seasonNumber))) return false; - return episodeNumber <= 0 || activeSourcePayload.hasCapability(TmdbSourceCapabilityPlanner.episodeVideos(seasonNumber, episodeNumber)); - } - private boolean isInlinePlaybackRequestCurrent(int generation, String key, String flag, String episodeUrl) { return generation == inlinePlaybackGeneration && TextUtils.equals(key, getKeyText()) @@ -2887,8 +2441,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void cancelPendingInlinePlayback() { + if (!inlinePlaybackLoading && !inlinePlayerSwitchLoading) return; inlinePlaybackGeneration++; - inlinePlaybackPending = false; + inlinePlaybackLoading = false; + inlinePlayerSwitchLoading = false; + hideInlineLoading(); updateInlineDisplayPanel(); } @@ -2898,9 +2455,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void applyLoaded(Vod loadedVod, TmdbBundle bundle, List searchItems, String error, boolean allowMatchDialog) { long start = System.currentTimeMillis(); - clearRenderChunks(); + binding.loading.setVisibility(View.GONE); if (loadedVod == null) { - binding.loading.setVisibility(View.GONE); SpiderDebug.log("tmdb-detail-flow", "apply loaded empty cost=%dms mode=%d error=%s", System.currentTimeMillis() - start, getDetailMode(), TextUtils.isEmpty(error) ? "" : error); if (!TextUtils.isEmpty(error)) Notify.show(error); tryAutoChangeSource(); @@ -2912,63 +2468,17 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. TmdbEpisodeSorter.sort(vod); clearEpisodeRenderCaches(); applyTmdbBundle(bundle); - if (bundle != null && isTmdbNetworkAllowed()) saveTmdbMatch(bundle.item()); + if (bundle != null) saveTmdbMatch(bundle.item()); enrichVod(); clearEpisodeRenderCaches(); initHistory(); - if (history == null) { - binding.loading.setVisibility(View.GONE); - return; - } - final int generation = loadGeneration; - final boolean allowDialog = allowMatchDialog; - enqueueRenderChunk(() -> { - if (generation != loadGeneration) return; - bindPage(); - applySourceOnlyActionVisibility(); - }); - enqueueRenderChunk(() -> { - if (generation != loadGeneration) return; - focusInlinePlayerPanel(); - maybeAutoPlayInline(); - }); - enqueueRenderChunk(() -> { - if (generation != loadGeneration) return; - if (bundle != null) { - if (isTmdbNetworkAllowed()) loadTmdbMediaBlocks(bundle); - else bindTmdbSection(); - } - }); - enqueueRenderChunk(() -> { - if (generation != loadGeneration) return; - if (allowDialog && shouldShowAutoTmdbMatchDialog(bundle)) showTmdbMatchDialog(searchItems); - SpiderDebug.log("tmdb-detail-flow", "apply loaded cost=%dms mode=%d bundle=%s name=%s flags=%d", System.currentTimeMillis() - start, getDetailMode(), bundle != null, loadedVod.getName(), loadedVod.getFlags().size()); - }); - } - - private void fallbackToOriginalDetail(Vod loadedVod, String reason) { - if (isFinishing() || isDestroyed()) return; - SpiderDebug.log("tmdb-detail-flow", "fallback direct reason=%s key=%s id=%s", reason, getKeyText(), getIdText()); - VideoActivity.startDirectResolved(this, loadedVod); - finish(); - } - - private boolean isTmdbNetworkAllowed() { - return activeRuntimeDecision == null || activeRuntimeDecision.networkAllowed(); - } - - private boolean isTmdbSourceOnly() { - return activeRuntimeDecision != null && activeRuntimeDecision.sourceOnly(); - } - - private void applySourceOnlyActionVisibility() { - if (!isTmdbSourceOnly()) return; - binding.rematch.setVisibility(View.GONE); - binding.rematchTop.setVisibility(View.GONE); - binding.rematchFusion.setVisibility(View.GONE); - binding.episodeTitle.setVisibility(View.GONE); - binding.episodeTitle.setClickable(false); - binding.episodeTitle.setFocusable(false); + if (history == null) return; + bindPage(); + focusInlinePlayerPanel(); + maybeAutoPlayInline(); + if (bundle != null) loadTmdbMediaBlocks(bundle); + if (allowMatchDialog && shouldShowAutoTmdbMatchDialog(bundle)) showTmdbMatchDialog(searchItems); + SpiderDebug.log("tmdb-detail-flow", "apply loaded cost=%dms mode=%d bundle=%s name=%s flags=%d", System.currentTimeMillis() - start, getDetailMode(), bundle != null, loadedVod.getName(), loadedVod.getFlags().size()); } private TmdbLoadResult loadTmdbResult() { @@ -2976,15 +2486,13 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. List searchItems = new ArrayList<>(); try { if (initialTmdbItem != null) { - tmdbBundle = loadPrefetchedTmdbBundle(initialTmdbItem); + tmdbBundle = loadTmdbBundle(initialTmdbItem); } else { - boolean manual = isManualTmdbMatch(); TmdbItem match = getCachedTmdbMatch(); if (match != null) { try { tmdbBundle = loadTmdbBundle(match); - // 手动选择由用户拍板,分季变体检查只用于过滤自动匹配的误命中。 - if (!manual && TmdbMatchPolicy.isUnwantedSplitSeasonVariant(getNameText(), tmdbBundle.detail())) { + if (TmdbMatchPolicy.isUnwantedSplitSeasonVariant(getNameText(), tmdbBundle.detail())) { logTmdbMatch("缓存匹配跳过:当前标题=%s,缓存标题=%s,TMDB=%d 是分季变体", getNameText(), match.getTitle(), match.getTmdbId()); match = null; tmdbBundle = null; @@ -3018,84 +2526,30 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void applyTmdbResultNow(TmdbLoadResult result) { long start = System.currentTimeMillis(); - clearRenderChunks(); TmdbBundle bundle = result == null ? null : result.bundle(); applyTmdbBundle(bundle); reloadHistoryAfterTmdbMatch(); applyReloadedHistorySelection(); if (bundle != null) saveTmdbMatch(bundle.item()); enrichVod(); - final int generation = loadGeneration; - enqueueRenderChunk(() -> { - if (generation != loadGeneration) return; - bindBackdrop(); - bindHeader(); - bindMeta(); - bindRatings(); - bindOverview(); - renderSeasonSelection(); - }); - enqueueRenderChunk(() -> { - if (generation != loadGeneration) return; - lastEpisodeMediaSeason = Integer.MIN_VALUE; - renderEpisodes(); - if (selectedFlag == null || selectedFlag.getEpisodes() == null || selectedFlag.getEpisodes().isEmpty()) bindTmdbSection(); - }); - enqueueRenderChunk(() -> { - if (generation != loadGeneration) return; - focusInlinePlayerPanel(); - if (bundle != null) binding.getRoot().post(() -> loadTmdbMediaBlocks(bundle)); - updateFollowingState(); - if (shouldShowAutoTmdbMatchDialog(bundle)) showTmdbMatchDialog(result == null ? List.of() : result.searchItems()); - SpiderDebug.log("tmdb-detail-flow", "apply tmdb result cost=%dms mode=%d bundle=%s search=%d", System.currentTimeMillis() - start, getDetailMode(), bundle != null, result == null ? 0 : result.searchItems().size()); - }); - } - - /** - * identity 已知时在站点详情请求发出前启动 TMDB 详情预取。 - *

- * 站点详情与 TMDB 详情互不依赖,并行可省掉一个网络往返。代价(用户已确认): - * 源内嵌完整 TMDB payload(T3/T4)的页面也会多发 1 次请求,但结果只被 - * {@link #loadPrefetchedTmdbBundle} 消费,源数据路径会直接丢弃,不改变渲染。 - * 预取只在 TMDB 可用且站点允许时启动;已复用 bundle 或自动匹配(identity 未知)不预取。 - */ - private void startTmdbDetailPrefetch(@Nullable TmdbItem item, @Nullable TmdbBundle reusableBundle) { - if (item == null || reusableBundle != null) return; - TmdbConfig config = tmdbConfig; - if (config == null || !config.isReady()) return; - if (!isTmdbAllowedForCurrentSite()) return; - detailPrefetch.start(item, () -> { - long start = System.currentTimeMillis(); - JsonObject detail = tmdbService.detail(item, config); - SpiderDebug.log("tmdb-detail-flow", "tmdb prefetch cost=%dms id=%d media=%s", System.currentTimeMillis() - start, item.getTmdbId(), item.getMediaType()); - return new TmdbDetailPrefetch.Result(item, detail, List.of()); - }); - } - - /** - * 消费站点详情期间并行取回的 TMDB 详情,避免第二次网络请求;预取缺失、被取消或失败时 - * 回落到常规 {@link #loadTmdbBundle}(此时文件缓存可能已被预取写入,通常只剩解析成本)。 - */ - private TmdbBundle loadPrefetchedTmdbBundle(TmdbItem item) throws Exception { - ListenableFuture future = detailPrefetch.take(item); - if (future == null) return loadTmdbBundle(item); - try { - TmdbDetailPrefetch.Result prefetched = future.get(); - if (prefetched == null || prefetched.getDetail() == null) return loadTmdbBundle(item); - SpiderDebug.log("tmdb-detail-flow", "tmdb prefetch hit id=%d media=%s", item.getTmdbId(), item.getMediaType()); - return toTmdbBundle(prefetched.getItem() == null ? item : prefetched.getItem(), prefetched.getDetail()); - } catch (Throwable e) { - SpiderDebug.log("tmdb-detail-flow", "tmdb prefetch unusable id=%d media=%s error=%s", item.getTmdbId(), item.getMediaType(), e.getMessage()); - return loadTmdbBundle(item); - } + bindBackdrop(); + bindHeader(); + bindMeta(); + bindRatings(); + bindOverview(); + renderSeasonSelection(); + lastEpisodeMediaSeason = Integer.MIN_VALUE; + renderEpisodes(); + if (selectedFlag == null || selectedFlag.getEpisodes() == null || selectedFlag.getEpisodes().isEmpty()) bindTmdbSection(); + focusInlinePlayerPanel(); + if (bundle != null) binding.getRoot().post(() -> loadTmdbMediaBlocks(bundle)); + if (shouldShowAutoTmdbMatchDialog(bundle)) showTmdbMatchDialog(result == null ? List.of() : result.searchItems()); + SpiderDebug.log("tmdb-detail-flow", "apply tmdb result cost=%dms mode=%d bundle=%s search=%d", System.currentTimeMillis() - start, getDetailMode(), bundle != null, result == null ? 0 : result.searchItems().size()); } private TmdbBundle loadTmdbBundle(TmdbItem item) throws Exception { - return toTmdbBundle(item, tmdbService.detail(item, tmdbConfig)); - } - - private TmdbBundle toTmdbBundle(TmdbItem item, JsonObject detail) { long start = System.currentTimeMillis(); + JsonObject detail = tmdbService.detail(item, tmdbConfig); item = normalizeTmdbItemTitle(item, detail); List seasons = new ArrayList<>(); Map seasonCounts = new HashMap<>(); @@ -3135,15 +2589,14 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private String tmdbDetailTitle(TmdbItem item, JsonObject detail) { - return tmdbService.preferredTitle(item, detail, tmdbConfig); + if (item == null || detail == null) return ""; + String primary = "movie".equalsIgnoreCase(item.getMediaType()) ? string(detail, "title") : string(detail, "name"); + if (!TextUtils.isEmpty(primary)) return primary; + return "movie".equalsIgnoreCase(item.getMediaType()) ? string(detail, "name") : string(detail, "title"); } private void loadTmdbMediaBlocks(TmdbBundle bundle) { if (bundle == null || bundle.item() == null || bundle.detail() == null) return; - if (!isTmdbNetworkAllowed()) { - bindTmdbSection(); - return; - } int generation = loadGeneration; Vod currentVod = vod; tmdbMediaLoading = true; @@ -3159,38 +2612,24 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. List personalTmdb = new ArrayList<>(); List personalDouban = new ArrayList<>(); PersonalRecommendationService recommendationService = new PersonalRecommendationService(tmdbService, tmdbConfig); - if (bundle.cast().isEmpty()) { - try { - cast = tmdbService.cast(bundle.detail(), tmdbConfig); - } catch (Throwable ignored) { - } - } else { - cast = new ArrayList<>(bundle.cast()); + try { + cast = tmdbService.cast(bundle.detail(), tmdbConfig); + } catch (Throwable ignored) { } - if (bundle.creators().isEmpty()) { - try { - creators = tmdbService.creators(bundle.detail(), tmdbConfig); - } catch (Throwable ignored) { - } - } else { - creators = new ArrayList<>(bundle.creators()); + try { + creators = tmdbService.creators(bundle.detail(), tmdbConfig); + } catch (Throwable ignored) { } try { backdrops = tmdbService.backdrops(bundle.detail(), tmdbConfig); posters = tmdbService.posters(bundle.detail(), tmdbConfig); - backgroundSlides = bundle.photos().isEmpty() - ? tmdbService.photos(bundle.detail(), tmdbConfig, preferLandscapeBackground()) - : new ArrayList<>(bundle.photos()); + backgroundSlides = tmdbService.photos(bundle.detail(), tmdbConfig, preferLandscapeBackground()); } catch (Throwable ignored) { } try { - if (bundle.related().isEmpty()) { - List recommendations = tmdbService.recommendations(bundle.detail(), tmdbConfig); - List similar = tmdbService.similar(bundle.detail(), tmdbConfig); - related = TmdbRecommendationRows.rankedRelated(bundle.detail(), recommendations, similar); - } else { - related = new ArrayList<>(bundle.related()); - } + List recommendations = tmdbService.recommendations(bundle.detail(), tmdbConfig); + List similar = tmdbService.similar(bundle.detail(), tmdbConfig); + related = TmdbRecommendationRows.rankedRelated(bundle.detail(), recommendations, similar); } catch (Throwable ignored) { } try { @@ -3222,8 +2661,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. personalTmdbItems.addAll(finalPersonalTmdb); personalDoubanItems.clear(); personalDoubanItems.addAll(finalPersonalDouban); - setRailPhotos(finalBackdrops); - setRailPosters(finalPosters); + detailTmdbPhotos.clear(); + detailTmdbPhotos.addAll(finalBackdrops); + detailTmdbPosters.clear(); + detailTmdbPosters.addAll(finalPosters); detailBackdropSlides.clear(); detailBackdropSlides.addAll(finalBackgroundSlides); refreshBackdropSlideshow(); @@ -3314,10 +2755,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void applyTmdbBundle(TmdbBundle bundle) { - if (bundle == null || !isSameTmdbItem(bundle.item(), activeSourceItem)) { - activeSourcePayload = null; - activeSourceItem = null; - } activeTmdbBundle = bundle; matchedTmdbItem = bundle == null ? null : bundle.item(); matchedTmdbDetail = bundle == null ? null : bundle.detail(); @@ -3331,8 +2768,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. detailTmdbPosters.clear(); detailBackdropSlides.clear(); if (bundle != null && bundle.detail() != null) { - setRailPhotos(tmdbService.backdrops(bundle.detail(), tmdbConfig)); - setRailPosters(tmdbService.posters(bundle.detail(), tmdbConfig)); + detailTmdbPhotos.addAll(tmdbService.backdrops(bundle.detail(), tmdbConfig)); + detailTmdbPosters.addAll(tmdbService.posters(bundle.detail(), tmdbConfig)); detailBackdropSlides.addAll(bundle.photos()); if (detailBackdropSlides.isEmpty()) { detailBackdropSlides.addAll(tmdbService.photos(bundle.detail(), tmdbConfig, preferLandscapeBackground())); @@ -3362,42 +2799,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. clearSeasonResolutionCache(); if (selectedFlag != null) loadTmdbSeasonBinding(); updateTmdbSeasonActionVisibility(); - publishPluginEpisodeContext(); - } - - /** - * 把当前详情页的剧集身份交给插件通道。 - * - *

详情页没有"当前集"概念,{@code episode} 恒为 {@code null};{@code bundle} 为空时 - * 载荷随之清空——插件读不到数据,好过让它读到上一部剧的数据。 - */ - private void publishPluginEpisodeContext() { - try { - com.fongmi.android.tv.plugin.PluginEpisodeContext.publish( - getClass().getSimpleName(), - com.fongmi.android.tv.plugin.PluginEpisodePayload.of(matchedTmdbItem, vod, null)); - } catch (Throwable ignored) { - // 插件上下文是可选增强:任何异常都不许影响详情页。 - } - } - - /** - * 回到本页时重新认领剧集上下文。 - * - *

{@code PluginEpisodeContext} 是全局单槽:播放页发布后本页的快照就被覆盖了,而 - * 返回本页不会触发任何重新装载。不在这里补发,用户走过一次「详情页 → 播放页 → 返回」 - * 之后,本页的插件 chip 读到的永远是 {@code null}(名字不匹配即 null)。 - * - *

身份已被 {@code resetDetailState()} 清空时直接返回,不发布:{@code publish(name, null)} - * 的语义是「清除」,一次没有数据的回归发布会把原本正确的快照擦掉,比不发布更糟。 - */ - private void republishPluginEpisodeContext() { - try { - if (matchedTmdbItem == null && vod == null) return; - publishPluginEpisodeContext(); - } catch (Throwable ignored) { - // 插件上下文是可选增强:任何异常都不许影响详情页回归。 - } } private void showTmdbMatchDialog(List items) { @@ -3433,7 +2834,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void showManualTmdbSeasonDialog() { - if (!isTmdbNetworkAllowed()) return; if (matchedTmdbItem == null || !matchedTmdbItem.isTv() || seasonNumbers.isEmpty()) { Notify.show(R.string.detail_tmdb_empty); return; @@ -3626,10 +3026,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void loadTmdbSeasonBinding() { - if (!isTmdbNetworkAllowed()) { - tmdbSeasonBinding = null; - return; - } synchronized (Setting.class) { tmdbSeasonBinding = null; if (matchedTmdbItem == null || vod == null) return; @@ -3710,14 +3106,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.episodeTitle.setContentDescription(visible ? getString(R.string.tmdb_season_match_current, binding.episodeTitle.getText()) : binding.episodeTitle.getText()); - setEpisodeTitleButton(binding.episodeTitle, currentThemeColors()); Drawable icon = visible ? getDrawable(R.drawable.ic_expand_more) : null; - if (icon != null) { - icon = icon.mutate(); - icon.setTint(binding.episodeTitle.getCurrentTextColor()); - } + if (icon != null) icon.setTint(binding.episodeTitle.getCurrentTextColor()); binding.episodeTitle.setCompoundDrawablePadding(visible ? ResUtil.dp2px(4) : 0); binding.episodeTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, icon, null); + setEpisodeTitleButton(binding.episodeTitle, currentThemeColors()); } private boolean canApplyValidatedFlatSeasonMapping(List episodes) { @@ -3734,7 +3127,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void saveTmdbSeasonBinding(Integer seasonNumber, TmdbSeasonMatchCache.Mode mode) { - if (!isTmdbNetworkAllowed()) return; if (matchedTmdbItem == null || vod == null) return; String sourceTitle = !TextUtils.isEmpty(sourceVodName) ? sourceVodName : vod.getName(); int sourceEpisodeCount = selectedFlag == null || selectedFlag.getEpisodes() == null ? 0 : selectedFlag.getEpisodes().size(); @@ -3753,7 +3145,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void clearTmdbSeasonBinding() { - if (!isTmdbNetworkAllowed()) return; if (matchedTmdbItem == null || vod == null) return; String sourceTitle = !TextUtils.isEmpty(sourceVodName) ? sourceVodName : vod.getName(); synchronized (Setting.class) { @@ -3798,7 +3189,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void showManualTmdbMatchDialog() { - if (!isTmdbNetworkAllowed()) return; if (!tmdbConfig.isReady()) { Notify.show(getString(R.string.detail_tmdb_need_key)); return; @@ -3838,11 +3228,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private TmdbItem getCachedTmdbMatch() { if (!isTmdbAllowedForCurrentSite()) return null; - TmdbMatchCache cache = Setting.getTmdbMatchCache(); - // 手动选择优先,且不做标题兼容性校验:用户之所以手动选,正是因为标题解析结果不对。 - TmdbItem manual = cache.findManual(getKeyText(), getIdText(), getTmdbRawTitle(), TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); - if (manual != null) return manual; - TmdbItem item = cache.find(getKeyText(), getIdText(), getTmdbRawTitle(), TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); + TmdbItem item = Setting.getTmdbMatchCache().find(getKeyText(), getIdText(), getTmdbRawTitle()); if (!isCachedTmdbMatchCompatible(item)) return null; return item; } @@ -3857,7 +3243,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean canMatchTmdb() { - return isTmdbNetworkAllowed() && tmdbConfig != null && tmdbConfig.isReady() && isTmdbAllowedForCurrentSite(); + return tmdbConfig != null && tmdbConfig.isReady() && isTmdbAllowedForCurrentSite(); } private boolean isTmdbAllowedForCurrentSite() { @@ -3868,52 +3254,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return TmdbSitePolicy.isEnabled(tmdbConfig, getKeyText(), getIdText()); } - private boolean isManualTmdbMatch() { - return isTmdbAllowedForCurrentSite() - && Setting.getTmdbMatchCache().isManual(getKeyText(), getIdText(), getTmdbRawTitle()); - } - private void saveTmdbMatch(TmdbItem item) { if (item == null || item.getTmdbId() <= 0) return; - // 读-改-写要整体互斥:自动匹配在后台线程写,手动选择在主线程写, - // 不加锁会让后到的自动结果基于旧快照覆盖掉刚落盘的手动选择。 - synchronized (Setting.class) { - TmdbMatchCache cache = Setting.getTmdbMatchCache(); - cache.put(getKeyText(), getIdText(), getTmdbRawTitle(), item, TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); - Setting.putTmdbMatchCache(cache); - } - } - - /** 记录用户手动选定的 TMDB 条目,覆盖此前的自动匹配并锁定后续自动写入。 */ - private void saveManualTmdbMatch(TmdbItem item) { - if (item == null || item.getTmdbId() <= 0) return; - List aliases = tmdbSourceTitleAliases(); - synchronized (Setting.class) { - TmdbMatchCache cache = Setting.getTmdbMatchCache(); - cache.putManual(getKeyText(), getIdText(), aliases, item, TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); - Setting.putTmdbMatchCache(cache); - } - } - - /** - * 站源标题在富集后会被改写,手动绑定要覆盖所有可能作为读取键的别名。 - * 别名只取站源侧信号,且与 getTmdbRawTitle() 的优先级一一对应: - * vod.getName() 在富集后已是"上一次"的 TMDB 标题,把它当别名写进去会留下一条 - * 指向旧条目的精确键(A→B→C 连续切换后 key(标题A) 仍指向 B),历史记录里存的 - * 正是那个旧标题,History.resolveTmdbIdentity 反查就会读回旧选择。 - * 因此只在 sourceVodName 为空、即它确实是读取键时才纳入。 - */ - private List tmdbSourceTitleAliases() { - List aliases = new ArrayList<>(); - addTmdbSourceTitleAlias(aliases, sourceVodName); - if (aliases.isEmpty()) addTmdbSourceTitleAlias(aliases, vod == null ? "" : vod.getName()); - addTmdbSourceTitleAlias(aliases, getNameText()); - return aliases; - } - - private void addTmdbSourceTitleAlias(List aliases, String title) { - if (TextUtils.isEmpty(title) || aliases.contains(title)) return; - aliases.add(title); + TmdbMatchCache cache = Setting.getTmdbMatchCache(); + cache.put(getKeyText(), getIdText(), getTmdbRawTitle(), item); + Setting.putTmdbMatchCache(cache); } private void saveManualTmdbLearning(TmdbItem item) { @@ -4110,9 +3455,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } resetManualTmdbPresentation(); - // 必须先落盘:applyTmdbResultNow 里的 enrichVod 会把 vod.getName() - // 改写成 TMDB 标题,之后再取别名就会把 TMDB 标题当成站源标题记进去。 - saveManualTmdbMatch(bundle.item()); applyTmdbResultNow(new TmdbLoadResult(bundle, List.of())); scheduleManualTmdbEpisodeRebind(applyGeneration, bundle.item()); saveManualTmdbLearning(bundle.item()); @@ -4195,7 +3537,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. loadRelatedVideosForCurrentContext(); bindTmdbSection(); updateKeepState(); - updateFollowingState(); requestDetailPlayFocus(); } @@ -4208,32 +3549,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void bindInitialArtwork() { String initialTmdbPoster = getTmdbPosterText(); - ImgUtil.load(getNameText(), TextUtils.isEmpty(initialTmdbPoster) ? getPicText() : TmdbImageSelector.heroPosterTier(initialTmdbPoster), binding.poster); + ImgUtil.load(getNameText(), TextUtils.isEmpty(initialTmdbPoster) ? getPicText() : TmdbImageSelector.originalUrl(initialTmdbPoster), binding.poster); bindBackdropImage(getNameText(), getBackdropText(), getPicText()); } - /** - * 图集/剧照 rail 统一降到官方 backdrop 档位 {@code w780}。 - *

- * rail 只显示缩略图,原图会白下 8 倍以上字节(实测 backdrop original 982,744B / w780 48,585B)。 - * 全屏查看与保存图片走 {@link #highResTmdbImage} 升级回 original,观感不变。 - */ - private void setRailPhotos(@Nullable List photos) { - detailTmdbPhotos.clear(); - if (photos == null) return; - for (String url : photos) detailTmdbPhotos.add(TmdbImageSelector.photoTier(url)); - } - - /** - * 海报 rail 统一降到官方 poster 档位 {@code w500}(实测 original 1,187,694B / w500 85,680B), - * 全屏查看与保存仍升级回 original。 - */ - private void setRailPosters(@Nullable List posters) { - detailTmdbPosters.clear(); - if (posters == null) return; - for (String url : posters) detailTmdbPosters.add(TmdbImageSelector.posterTier(url)); - } - private void bindBackdrop() { boolean wallpaperBackdrop = useAppWallpaperBackdrop(); bindBackdropImage(vod.getName(), wallpaperBackdrop ? "" : tmdbBackdropUrl(), wallpaperBackdrop ? "" : vod.getPic()); @@ -4247,7 +3566,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. getTmdbPosterText(), vod == null ? "" : vod.getPic(), getPicText()); - return TmdbImageSelector.heroPosterTier(TmdbImageSelector.poster(matchedTmdbDetail, tmdbConfig == null ? "" : tmdbConfig.getImageBase(), fallback)); + return TmdbImageSelector.poster(matchedTmdbDetail, tmdbConfig == null ? "" : tmdbConfig.getImageBase(), fallback); } private void bindBackdropImage(String title, String backdrop, String fallback) { @@ -4422,8 +3741,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private float backdropSlideAlpha() { - if (modeController.isCinemaStyle()) return lightTheme ? 1f : 0.9f; - return lightTheme ? 1f : 0.5f; + return isCinemaMode() && !lightTheme ? 0.9f : 1f; } private int nextBackdropSlideIndex() { @@ -4486,17 +3804,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return true; } - private boolean isCinemaStyle() { - return modeController != null ? modeController.isCinemaStyle() : rawCinemaMode(); - } - - private boolean rawCinemaMode() { - return getIntent().getIntExtra("detail_mode", Setting.getDetailOpenMode()) == Setting.DETAIL_OPEN_CINEMA || Setting.isTmdbCinemaStyle(); - } - private ThemeColors currentThemeColors() { ThemeColors colors = lightTheme ? ThemeColors.light() : ThemeColors.dark(); - return isCinemaStyle() ? ThemeColors.cinema(lightTheme) : colors; + return isCinemaMode() ? ThemeColors.cinema(lightTheme) : colors; } private void refreshBackdropSurface() { @@ -4516,7 +3826,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private int backdropFallbackBackground(ThemeColors colors) { - return modeController.isPlayerMode() ? 0xFF0F141A : colors.background; + return isPlayerMode() ? 0xFF0F141A : colors.background; } private String episodeFallbackStillUrl() { @@ -4604,10 +3914,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. String tmdb = tmdbRatingValue(); if (!TextUtils.isEmpty(tmdb)) addRatingChip(key, "TMDB", tmdb + "/10", 0xFF21D07A); addBoxOfficeChip(key); - if (!isTmdbSourceOnly()) { - fetchDoubanRating(key); - fetchOmdbRating(key); - } + fetchDoubanRating(key); + fetchOmdbRating(key); bindExternalLinks(); } @@ -4694,9 +4002,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (!(view instanceof LinearLayout row)) return; boolean focused = row.hasFocus(); GradientDrawable background = new GradientDrawable(); - background.setColor(modeController.isCinemaStyle() ? TmdbCinemaTheme.palette(lightTheme).ratingChip() : colors.chip); + background.setColor(isCinemaMode() ? TmdbCinemaTheme.palette(lightTheme).ratingChip() : colors.chip); background.setCornerRadius(ResUtil.dp2px(10)); - background.setStroke(focused ? focusRingWidthPx() : ResUtil.dp2px(CHIP_STROKE_DP), focused ? focusStroke() : colors.line); + background.setStroke(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : CHIP_STROKE_DP), focused ? FOCUS_STROKE : colors.line); row.setBackground(background); for (int i = 0; i < row.getChildCount(); i++) { View child = row.getChildAt(i); @@ -4706,39 +4014,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void openExternalLink(String url) { - if (TextUtils.isEmpty(url)) return; - if (Util.isLeanback()) { - ChoiceDialog.showSingle(this, R.string.detail_external_open_title, - new CharSequence[]{getString(R.string.detail_external_open_builtin), getString(R.string.detail_external_open_external)}, - -1, which -> { - if (which == 0) openExternalLinkBuiltIn(url); - else openExternalLinkExternal(url); - }); - return; - } - openExternalLinkExternal(url); - } - - private void openExternalLinkBuiltIn(String url) { - if (!WebViewUtil.support()) { - openExternalLinkExternal(url); - return; - } try { - startActivity(CatWebActivity.browserIntent(this, url, - getString(R.string.tmdb_external_links_label), - getString(R.string.detail_external_opening))); - } catch (Throwable ignored) { - // WebView 容器临时起不来时仍回退到系统浏览器,避免链接完全不可达。 - openExternalLinkExternal(url); - } - } - - private void openExternalLinkExternal(String url) { - try { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + startActivity(new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url))); } catch (Throwable e) { - Notify.show(R.string.detail_external_open_failed); + Notify.show("无法打开链接"); } } @@ -4788,7 +4067,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private int ratingChipBackground(ThemeColors colors) { - return modeController.isCinemaStyle() ? TmdbCinemaTheme.palette(true).ratingChip() : colors.chip; + return isCinemaMode() ? TmdbCinemaTheme.palette(true).ratingChip() : colors.chip; } private int readableDetailRatingColor(int color) { @@ -4811,7 +4090,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void fetchDoubanRating(String key) { - if (isTmdbSourceOnly()) return; String title = ratingTitle(); if (TextUtils.isEmpty(title)) return; String mediaType = matchedTmdbItem == null ? "" : matchedTmdbItem.getMediaType(); @@ -4828,7 +4106,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void fetchOmdbRating(String key) { - if (isTmdbSourceOnly()) return; String imdb = string(object(matchedTmdbDetail, "external_ids"), "imdb_id"); String omdbApiKey = tmdbConfig == null ? "" : tmdbConfig.getOmdbApiKey(); if (TextUtils.isEmpty(imdb) || TextUtils.isEmpty(omdbApiKey)) return; @@ -5025,7 +4302,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. renderFlagSelection(); renderEpisodes(); refreshSeasonSourceRoutes(); - if (modeController.isFusionMode()) onPlay(); + if (isFusionMode()) onPlay(); }); binding.flagContainer.addView(button); } @@ -5123,16 +4400,19 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. String remarks = history != null ? history.getVodRemarks() : ""; if (selectedEpisode == null) selectedEpisode = findEpisodeByUrl(history == null ? "" : history.getEpisodeUrl(), selectedFlag.getEpisodes()); if (selectedEpisode == null) selectedEpisode = selectedFlag.find(remarks, getMarkText().isEmpty()); - // 三条身份通道(intent URL / 历史 URL / 历史备注)全失败时才退到第一集,这是最后兜底。 if (selectedEpisode == null) selectedEpisode = episodes.get(0); } List availableSeasons = availableSeasonNumbers(episodes); - // seasonForEpisode 在可选季非空时必返回其中之一(其唯一返回 -1 的路径是可选季为空), - // 因此这里不需要再判断季号是否落在可选季内,也不存在"选不到集"的分支: - // selectedEpisode 已由上一段四通道兜到首集,恒非空。 - selectedSeasonNumber = availableSeasons.isEmpty() ? -1 : seasonForEpisode(selectedEpisode, episodes); + if (availableSeasons.isEmpty()) { + selectedSeasonNumber = -1; + } else if (selectedSeasonNumber < 0 || !availableSeasons.contains(selectedSeasonNumber)) { + selectedSeasonNumber = seasonForEpisode(selectedEpisode, episodes); + } refreshSeasonContext(); List visibleEpisodes = visibleEpisodes(episodes); + if (!visibleEpisodes.isEmpty() && !visibleEpisodes.contains(selectedEpisode)) { + selectedEpisode = visibleEpisodes.get(0); + } renderSeasonSelection(); bindSeasonEpisodes(episodes); refreshCurrentHistoryEpisodeTitle(); @@ -5239,10 +4519,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (rv.getVisibility() != View.VISIBLE) return; RecyclerView.Adapter adapter = rv.getAdapter(); if (adapter == null || adapter.getItemCount() == 0) return; - // 主选集卡片仍可见时,未消费的刷新也会使其 adapter position 持续为 NO_POSITION。 - // 其它 TMDB 列表保持原有的无子 View 恢复条件。 - boolean pendingEpisodeLayout = binding != null && rv == binding.episodeContainer && rv.hasPendingAdapterUpdates(); - if (rv.getChildCount() > 0 && !pendingEpisodeLayout) return; + if (rv.getChildCount() > 0) return; // 已有子 View,未处于错位态 if (rv.isComputingLayout()) { rv.post(() -> recoverRecyclerViewIfDetached(rv)); return; @@ -5436,12 +4713,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. clearDetailEpisodeFocusRowIfNeeded(view); return; } + if (!episodeGridMode) return; int position = binding.episodeContainer.getChildAdapterPosition(view); if (position == RecyclerView.NO_POSITION) return; - if (!episodeGridMode) { - alignDetailEpisodeFocusedRow(view, position); - return; - } int rowStart = detailEpisodeRowStart(position); boolean sameFocusedRow = rowStart == lastDetailEpisodeFocusRowStart; lastDetailEpisodeFocusRowStart = rowStart; @@ -5506,9 +4780,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean down = KeyUtil.isDownKey(event); int target = TmdbEpisodeGridPolicy.verticalFocusTarget(position, span, episodeAdapter.getItemCount(), down); if (target == TmdbEpisodeGridPolicy.NO_FOCUS_TARGET) { - // 长按撞到首行/末行时停在原地。把焦点甩出网格会让"按住方向浏览选集"在边界处 - // 突然跳到 TMDB 行并继续横向跑;松手后再按一次才按原规则离开网格。 - if (event.getRepeatCount() > 0) return true; if (!down) { if (focusDetailEpisodeRangeButton()) return true; if (focusDetailEpisodeToolButton(View.FOCUS_UP)) return true; @@ -5516,9 +4787,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } return focusFirstVisibleTmdbRow(); } - // 首次按键继续交给 RecyclerView 原生焦点搜索;只有长按重复事件才显式接管, - // 因为此时焦点尚未落到新卡片,FocusFinder 会按旧焦点再算一次,表现为按住上下只走一格。 - if (event.getRepeatCount() > 0) return focusDetailEpisode(target); return false; } @@ -5546,13 +4814,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. || focusTmdbRecycler(binding.relatedList) || focusTmdbRecycler(binding.creatorList) || focusTmdbRecycler(binding.castList) - || focusTmdbRecycler(binding.relatedVideoList) || focusTmdbRecycler(binding.episodePhotoList); } private boolean focusFirstVisibleTmdbRow() { return focusTmdbRecycler(binding.episodePhotoList) - || focusTmdbRecycler(binding.relatedVideoList) || focusTmdbRecycler(binding.castList) || focusTmdbRecycler(binding.creatorList) || focusTmdbRecycler(binding.relatedList) @@ -5585,187 +4851,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return true; } - /** - * 安装焦点滚动校正。 - * - * 系统默认的焦点滚动会把内容贴到可视区边缘:卡片行会把行上方的分区标题切掉一半、 - * 让下一个分区标题在底部露出一小条;按钮则整块贴住屏幕顶端,缺少呼吸空间。 - * 这里在每次焦点变化后统一校正一次。 - */ - private void installCardRowFocusMargin() { - if (binding == null) return; - buildCardRowIndex(); - cardRowFocusMarginListener = (previousFocus, newFocus) -> { - if (binding == null || newFocus == null) return; - // 系统默认的焦点滚动可能在下一帧完成;使用动画帧而非固定延时,避免视觉闪烁。 - binding.scroll.postOnAnimation(() -> ensureFocusVisibleWithMargin(newFocus)); - binding.scroll.postOnAnimationDelayed(() -> ensureFocusVisibleWithMargin(newFocus), 260); - }; - binding.scroll.getViewTreeObserver().addOnGlobalFocusChangeListener(cardRowFocusMarginListener); - } - - private void removeCardRowFocusMargin() { - if (binding == null || cardRowFocusMarginListener == null) return; - ViewTreeObserver observer = binding.scroll.getViewTreeObserver(); - if (observer.isAlive()) observer.removeOnGlobalFocusChangeListener(cardRowFocusMarginListener); - cardRowFocusMarginListener = null; - } - - /** 按页面自上而下的顺序登记卡片行及其分区标题。 */ - private void buildCardRowIndex() { - cardRowOrder.clear(); - cardRowTitleOrder.clear(); - addCardRow(binding.episodePhotoList, binding.episodePhotoTitle); - addCardRow(binding.posterList, binding.posterTitle); - addCardRow(binding.relatedVideoList, binding.relatedVideoTitle); - addCardRow(binding.castList, binding.castTitle); - addCardRow(binding.creatorList, binding.creatorTitle); - addCardRow(binding.relatedList, binding.relatedTitle); - addCardRow(binding.personalTmdbList, binding.personalTmdbTitle); - addCardRow(binding.personalDoubanList, binding.personalDoubanTitle); - addCardRow(binding.personalAiList, binding.personalAiTitle); - } - - private void addCardRow(View row, View title) { - if (row == null) return; - cardRowOrder.add(row); - cardRowTitleOrder.add(title); - } - - private boolean isLaidOutVisible(View view) { - return view != null && view.getVisibility() == View.VISIBLE && view.getHeight() > 0 && view.isShown(); - } - - /** 返回焦点所在卡片行在 {@link #cardRowOrder} 中的下标,找不到返回 -1。 */ - private int focusedCardRowIndex(View focused) { - for (View current = focused; current != null; ) { - int index = cardRowOrder.indexOf(current); - if (index >= 0) return index; - Object parent = current.getParent(); - current = parent instanceof View ? (View) parent : null; - } - return -1; - } - - /** 该行之后第一个可见的分区标题(用于避免其只露出一部分)。 */ - private View nextVisibleSectionTitle(int rowIndex) { - for (int i = rowIndex + 1; i < cardRowTitleOrder.size(); i++) { - View title = cardRowTitleOrder.get(i); - if (isLaidOutVisible(title)) return title; - } - return isLaidOutVisible(binding.externalLinksTitle) ? binding.externalLinksTitle : null; - } - - /** 焦点变化后的统一校正入口:卡片行走专用逻辑,其余按钮/控件走通用留白。 */ - private void ensureFocusVisibleWithMargin(View focused) { - if (binding == null || focused == null || !focused.isShown()) return; - if (focusedCardRowIndex(focused) >= 0) { - ensureCardRowVisibleWithMargin(focused); - return; - } - ensureButtonVisibleWithMargin(focused); - } - - /** 该 View 是否位于 binding.scroll 之内。 */ - private boolean isInsideDetailScroll(View view) { - for (View current = view; current != null; ) { - if (current == binding.scroll) return true; - Object parent = current.getParent(); - current = parent instanceof View ? (View) parent : null; - } - return false; - } - - /** - * 该 View 是否是横向列表(RecyclerView)里的条目。 - * 这类条目有各自的滚动/对齐逻辑,这里不再叠加通用留白,避免相互打架。 - */ - private boolean isInsideRecyclerRow(View view) { - for (View current = view; current != null; ) { - if (current == binding.scroll) return false; - if (current instanceof RecyclerView) return true; - Object parent = current.getParent(); - current = parent instanceof View ? (View) parent : null; - } - return false; - } - - /** - * 按钮/控件获得焦点时保留上下间距。 - * - * 与卡片行同理:系统默认的焦点滚动会把按钮贴到屏幕顶端(实测「继续播放」的 y=0), - * 视觉上过于拥挤。这里保证按钮至少离可视区上下边缘各留一段间距。 - */ - private void ensureButtonVisibleWithMargin(View focused) { - if (binding == null || focused == null || !focused.isShown()) return; - if (focused == binding.scroll) return; - if (!isInsideDetailScroll(focused) || isInsideRecyclerRow(focused)) return; - if (focused.getHeight() == 0 || binding.scroll.getHeight() == 0) return; - int[] loc = new int[2]; - binding.scroll.getLocationOnScreen(loc); - int viewTop = loc[1]; - int viewBottom = viewTop + binding.scroll.getHeight(); - focused.getLocationOnScreen(loc); - int top = loc[1]; - int bottom = top + focused.getHeight(); - int topMargin = ResUtil.dp2px(CARD_ROW_TOP_MARGIN_DP); - int bottomMargin = ResUtil.dp2px(CARD_ROW_BOTTOM_MARGIN_DP); - if (top < viewTop + topMargin) binding.scroll.scrollBy(0, top - (viewTop + topMargin)); - else if (bottom > viewBottom - bottomMargin) binding.scroll.scrollBy(0, bottom - (viewBottom - bottomMargin)); - } - - /** - * 让获得焦点的卡片行连同它的分区标题一起舒服地落在可视区内。 - * - * 1) 顶部:锚定到分区标题(而不是行本身),标题完整显示,不再被切掉一半; - * 同时兼容卡片焦点放大 1.04 倍后描边外溢的情况。 - * 2) 底部:行底留出间距,并且不让下一个分区标题只露出一小条—— - * 要么完整显示,要么完全移出可视区。 - */ - private void ensureCardRowVisibleWithMargin(View focused) { - if (binding == null || focused == null || !focused.isShown()) return; - if (cardRowOrder.isEmpty()) buildCardRowIndex(); - int index = focusedCardRowIndex(focused); - if (index < 0) return; - View row = cardRowOrder.get(index); - if (row.getHeight() == 0 || binding.scroll.getHeight() == 0) return; - - int[] loc = new int[2]; - binding.scroll.getLocationOnScreen(loc); - int viewTop = loc[1]; - int viewBottom = viewTop + binding.scroll.getHeight(); - - row.getLocationOnScreen(loc); - int rowTop = loc[1]; - int rowBottom = rowTop + row.getHeight(); - - int topMargin = ResUtil.dp2px(CARD_ROW_TOP_MARGIN_DP); - int bottomMargin = ResUtil.dp2px(CARD_ROW_BOTTOM_MARGIN_DP); - - int anchorTop = rowTop; - View title = index < cardRowTitleOrder.size() ? cardRowTitleOrder.get(index) : null; - if (isLaidOutVisible(title)) { - title.getLocationOnScreen(loc); - anchorTop = Math.min(anchorTop, loc[1]); - } - - if (anchorTop < viewTop + topMargin) { - binding.scroll.scrollBy(0, anchorTop - (viewTop + topMargin)); - } else if (rowBottom > viewBottom - bottomMargin) { - binding.scroll.scrollBy(0, rowBottom - (viewBottom - bottomMargin)); - } - - View next = nextVisibleSectionTitle(index); - if (!isLaidOutVisible(next)) return; - next.getLocationOnScreen(loc); - int nextTop = loc[1]; - if (nextTop >= viewBottom || nextTop + next.getHeight() <= viewBottom) return; - // 下一个标题正卡在底边:把内容整体下移,让它完全移出可视区; - // 但保证本行标题不会被顶出屏幕顶部。 - int delta = Math.min(nextTop - viewBottom, anchorTop - (viewTop + topMargin)); - binding.scroll.scrollBy(0, delta); - } - private void scrollDetailChildIntoViewNow(View child, int topPaddingDp) { if (binding == null || child == null) return; Rect rect = new Rect(); @@ -5928,38 +5013,29 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean focusDetailEpisode(int position) { - return focusDetailEpisode(position, 0); - } - - /** - * 把焦点送到选集网格的某一集,直到真正落地为止。 - * - *

原来未命中可见卡片时排一次 {@code post} + {@code postDelayed(80ms)}:按住方向键时每个重复 - * 事件都会再排一条,焦点还没落到上一集就又被下一个 80ms 回调按过期坐标 {@code scrollToPosition} - * 拽回去,表现为长按只走一格、松手后选集列表闪回原处(用户报告)。 - * 这里改成「按帧重试 + 目标即令牌」:目标换了作废旧回调,成功落地即停。 - */ - private boolean focusDetailEpisode(int position, int attempt) { if (binding == null || episodeAdapter == null || episodeAdapter.getItemCount() == 0) return false; int target = Math.max(0, Math.min(position, episodeAdapter.getItemCount() - 1)); int span = detailEpisodeSpanCount(); int rowStart = Math.max(0, target - target % span); - pendingDetailEpisodeTarget = target; RecyclerView.ViewHolder visibleHolder = binding.episodeContainer.findViewHolderForAdapterPosition(target); if (visibleHolder != null) { binding.episodeContainer.stopScroll(); - if (visibleHolder.itemView.requestFocus()) { - if (episodeGridMode) alignDetailEpisodeFocusedRow(visibleHolder.itemView, target); - return true; - } - } else if (attempt == 0) { + visibleHolder.itemView.requestFocus(); + alignDetailEpisodeFocusedRow(visibleHolder.itemView, target); + return true; + } + binding.episodeContainer.post(() -> { binding.episodeContainer.stopScroll(); scrollEpisodeToPosition(rowStart, ResUtil.dp2px(8)); - } - if (attempt >= DETAIL_EPISODE_FOCUS_MAX_ATTEMPTS) return true; - binding.episodeContainer.postOnAnimation(() -> { - if (binding == null || pendingDetailEpisodeTarget != target) return; - focusDetailEpisode(target, attempt + 1); + binding.episodeContainer.postDelayed(() -> { + RecyclerView.ViewHolder holder = binding.episodeContainer.findViewHolderForAdapterPosition(target); + if (holder == null) { + binding.episodeContainer.requestFocus(); + return; + } + holder.itemView.requestFocus(); + alignDetailEpisodeFocusedRow(holder.itemView, target); + }, 80); }); return true; } @@ -5979,15 +5055,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void alignDetailEpisodeFocusedRow(View focusedView, int position) { if (binding == null || focusedView == null) return; RecyclerView.LayoutManager layoutManager = binding.episodeContainer.getLayoutManager(); - if (layoutManager instanceof LinearLayoutManager linearLayoutManager - && linearLayoutManager.getOrientation() == LinearLayoutManager.HORIZONTAL) { - focusedView.post(() -> { - if (binding == null || episodeGridMode || getCurrentFocus() != focusedView) return; - if (binding.episodeContainer.getChildAdapterPosition(focusedView) != position) return; - alignDetailEpisodeFocusedCardHorizontallyNow(focusedView); - }); - return; - } if (!(layoutManager instanceof GridLayoutManager)) return; focusedView.post(() -> { if (binding == null || getCurrentFocus() != focusedView) return; @@ -5996,17 +5063,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. }); } - private void alignDetailEpisodeFocusedCardHorizontallyNow(View focusedView) { - if (binding == null || binding.episodeContainer.getWidth() <= 0) return; - int contentLeft = binding.episodeContainer.getPaddingLeft(); - int contentRight = binding.episodeContainer.getWidth() - binding.episodeContainer.getPaddingRight(); - int viewportCenter = contentLeft + (contentRight - contentLeft) / 2; - int cardCenter = focusedView.getLeft() + focusedView.getWidth() / 2; - int delta = cardCenter - viewportCenter; - if (Math.abs(delta) <= ResUtil.dp2px(2)) return; - binding.episodeContainer.smoothScrollBy(delta, 0); - } - private void alignDetailEpisodeFocusedCardNow(View focusedView) { if (binding == null || binding.scroll.getHeight() <= 0) return; Rect rect = new Rect(); @@ -6302,7 +5358,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void fetchSeasonIfNeeded(int seasonNumber, boolean refresh) { if (seasonNumber < 0 || (!refresh && tmdbSeasonEpisodes.containsKey(seasonNumber)) || loadingSeasons.contains(seasonNumber) || matchedTmdbItem == null || !"tv".equalsIgnoreCase(matchedTmdbItem.getMediaType()) || !canMatchTmdb()) return; - if (!refresh && hasCompleteSourceSeason(seasonNumber)) return; int generation = loadGeneration; TmdbItem item = matchedTmdbItem; JsonObject detail = matchedTmdbDetail; @@ -6345,7 +5400,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void refreshFirstSeasonIfStaleSplit(List sourceEpisodes) { if (sourceEpisodes == null || sourceEpisodes.isEmpty() || hasExplicitSeasonNumbers(sourceEpisodes)) return; int firstSeason = firstSeasonNumber(matchedTmdbDetail); - if (firstSeason < 0 || refreshedSingleSeasonProbes.contains(firstSeason) || loadingSeasons.contains(firstSeason) || hasCompleteSourceSeason(firstSeason)) return; + if (firstSeason < 0 || refreshedSingleSeasonProbes.contains(firstSeason) || loadingSeasons.contains(firstSeason)) return; int expectedCount = Math.max(0, seasonEpisodeCounts.getOrDefault(firstSeason, 0)); List cachedEpisodes = tmdbSeasonEpisodes.get(firstSeason); int cachedCount = cachedEpisodes == null ? 0 : cachedEpisodes.size(); @@ -6368,11 +5423,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. tmdbEpisodePhotos.clear(); List seasonPhotos = tmdbSeasonPhotos.get(seasonNumber); - if (seasonPhotos == null || seasonPhotos.isEmpty()) { - tmdbEpisodePhotos.addAll(detailTmdbPhotos); - } else { - for (String url : seasonPhotos) tmdbEpisodePhotos.add(TmdbImageSelector.photoTier(url)); - } + tmdbEpisodePhotos.addAll(seasonPhotos == null || seasonPhotos.isEmpty() ? detailTmdbPhotos : seasonPhotos); } private boolean hasExplicitSeasonNumbers(List episodes) { @@ -6627,7 +5678,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void loadRelatedVideosForCurrentContext() { - if (matchedTmdbItem == null || tmdbConfig == null || !isTmdbAllowedForCurrentSite()) return; + if (matchedTmdbItem == null || tmdbConfig == null || !tmdbConfig.isReady() || !isTmdbAllowedForCurrentSite()) return; int seasonNumber = -1; int episodeNumber = -1; if (matchedTmdbItem.isTv()) { @@ -6636,39 +5687,20 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. episodeNumber = tmdbEpisode != null && tmdbEpisode.getNumber() > 0 ? tmdbEpisode.getNumber() : sourceEpisodeNumber(selectedEpisode); } String contextKey = matchedTmdbItem.getMediaType() + ":" + matchedTmdbItem.getTmdbId() + ":" + seasonNumber + ":" + episodeNumber + ":" + tmdbConfig.getLanguage(); - if (isTmdbSourceOnly()) { - relatedVideoGeneration++; - relatedVideoLoading = false; - relatedVideoContextKey = contextKey; - relatedVideoItems.clear(); - relatedVideoItems.addAll(TmdbSourceAdapter.videos( - matchedTmdbDetail, - matchedTmdbItem.getMediaType(), - seasonNumber, - episodeNumber, - tmdbConfig.getLanguage())); - bindTmdbSection(); - return; - } - if (!isTmdbNetworkAllowed() || !tmdbConfig.isReady()) return; if (contextKey.equals(relatedVideoContextKey)) return; int generation = loadGeneration; int videoGeneration = ++relatedVideoGeneration; TmdbItem item = matchedTmdbItem; - JsonObject sourceDetail = matchedTmdbDetail; final int requestSeasonNumber = seasonNumber; final int requestEpisodeNumber = episodeNumber; relatedVideoContextKey = contextKey; relatedVideoLoading = true; relatedVideoItems.clear(); bindTmdbSection(); - boolean sourceComplete = hasCompleteSourceVideos(item, requestSeasonNumber, requestEpisodeNumber); detailTasks.submit(() -> { List loaded = new ArrayList<>(); try { - loaded = sourceComplete - ? TmdbSourceAdapter.videos(sourceDetail, item.getMediaType(), requestSeasonNumber, requestEpisodeNumber, tmdbConfig.getLanguage()) - : tmdbService.relatedVideos(item, requestSeasonNumber, requestEpisodeNumber, tmdbConfig); + loaded = tmdbService.relatedVideos(item, requestSeasonNumber, requestEpisodeNumber, tmdbConfig); } catch (Throwable e) { SpiderDebug.log("tmdb-video", "standalone related failed media=%s id=%d season=%d episode=%d error=%s", item.getMediaType(), item.getTmdbId(), requestSeasonNumber, requestEpisodeNumber, e.getMessage()); } @@ -6699,58 +5731,56 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean hasPersonalDouban = !personalDoubanItems.isEmpty(); boolean hasPersonalAi = !personalAiItems.isEmpty(); boolean hasExternalLinks = binding.externalLinksContainer.getChildCount() > 0; - boolean tmdbSectionData = hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb || hasPersonalDouban || hasPersonalAi || hasExternalLinks; - binding.tmdbSection.setVisibility(tmdbSectionData || canMatchTmdb() || (!isTmdbSourceOnly() && matchedTmdbDetail != null) ? View.VISIBLE : View.GONE); + binding.tmdbSection.setVisibility(hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb || hasPersonalDouban || hasPersonalAi || hasExternalLinks || matchedTmdbDetail != null || canMatchTmdb() ? View.VISIBLE : View.GONE); binding.episodePhotoTitle.setVisibility(hasPhotos ? View.VISIBLE : View.GONE); binding.episodePhotoList.setVisibility(hasPhotos ? View.VISIBLE : View.GONE); episodePhotoAdapter.setItems(tmdbEpisodePhotos); episodePhotoAdapter.rebindAttached(binding.episodePhotoList); - int sectionGapDp = modeController.isPlayerMode() && !modeController.isCinemaStyle() ? 12 : 20; - setTopMargin(binding.posterTitle, hasPhotos ? sectionGapDp : 0); + setTopMargin(binding.posterTitle, hasPhotos ? 20 : 0); binding.posterTitle.setVisibility(hasPosters ? View.VISIBLE : View.GONE); binding.posterList.setVisibility(hasPosters ? View.VISIBLE : View.GONE); posterAdapter.setItems(detailTmdbPosters); posterAdapter.rebindAttached(binding.posterList); - setTopMargin(binding.relatedVideoTitle, hasPhotos || hasPosters ? sectionGapDp : 0); + setTopMargin(binding.relatedVideoTitle, hasPhotos || hasPosters ? 20 : 0); binding.relatedVideoTitle.setVisibility(hasRelatedVideos ? View.VISIBLE : View.GONE); binding.relatedVideoList.setVisibility(hasRelatedVideos ? View.VISIBLE : View.GONE); relatedVideoAdapter.setItems(relatedVideoItems); relatedVideoAdapter.rebindAttached(binding.relatedVideoList); - setTopMargin(binding.castTitle, hasPhotos || hasPosters || hasRelatedVideos ? sectionGapDp : 0); + setTopMargin(binding.castTitle, hasPhotos || hasPosters || hasRelatedVideos ? 20 : 0); binding.castTitle.setVisibility(hasCast ? View.VISIBLE : View.GONE); binding.castList.setVisibility(hasCast ? View.VISIBLE : View.GONE); castAdapter.setItems(castItems); castAdapter.rebindAttached(binding.castList); - setTopMargin(binding.creatorTitle, hasPhotos || hasPosters || hasRelatedVideos || hasCast ? sectionGapDp : 0); + setTopMargin(binding.creatorTitle, hasPhotos || hasPosters || hasRelatedVideos || hasCast ? 20 : 0); binding.creatorTitle.setVisibility(hasCreators ? View.VISIBLE : View.GONE); binding.creatorList.setVisibility(hasCreators ? View.VISIBLE : View.GONE); creatorAdapter.setItems(creatorItems); creatorAdapter.rebindAttached(binding.creatorList); - setTopMargin(binding.relatedTitle, hasPhotos || hasPosters || hasRelatedVideos || hasCast || hasCreators ? sectionGapDp : 0); + setTopMargin(binding.relatedTitle, hasPhotos || hasPosters || hasRelatedVideos || hasCast || hasCreators ? 20 : 0); binding.relatedTitle.setVisibility(hasRelated ? View.VISIBLE : View.GONE); binding.relatedList.setVisibility(hasRelated ? View.VISIBLE : View.GONE); relatedAdapter.setItems(relatedItems); relatedAdapter.rebindAttached(binding.relatedList); - setTopMargin(binding.personalTmdbTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos ? sectionGapDp : 0); + setTopMargin(binding.personalTmdbTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos ? 20 : 0); binding.personalTmdbTitle.setVisibility(hasPersonalTmdb ? View.VISIBLE : View.GONE); binding.personalTmdbList.setVisibility(hasPersonalTmdb ? View.VISIBLE : View.GONE); personalTmdbAdapter.setItems(personalTmdbItems); personalTmdbAdapter.rebindAttached(binding.personalTmdbList); - setTopMargin(binding.personalDoubanTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb ? sectionGapDp : 0); + setTopMargin(binding.personalDoubanTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb ? 20 : 0); binding.personalDoubanTitle.setVisibility(hasPersonalDouban ? View.VISIBLE : View.GONE); binding.personalDoubanList.setVisibility(hasPersonalDouban ? View.VISIBLE : View.GONE); personalDoubanAdapter.setItems(personalDoubanItems); personalDoubanAdapter.rebindAttached(binding.personalDoubanList); - setTopMargin(binding.personalAiTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb || hasPersonalDouban ? sectionGapDp : 0); + setTopMargin(binding.personalAiTitle, hasPhotos || hasPosters || hasCast || hasCreators || hasRelated || hasRelatedVideos || hasPersonalTmdb || hasPersonalDouban ? 20 : 0); binding.personalAiTitle.setVisibility(hasPersonalAi ? View.VISIBLE : View.GONE); binding.personalAiList.setVisibility(hasPersonalAi ? View.VISIBLE : View.GONE); personalAiAdapter.setItems(personalAiItems); @@ -6766,9 +5796,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.externalLinksTitle.setVisibility(hasExternalLinks ? View.VISIBLE : View.GONE); binding.externalLinksContainer.setVisibility(hasExternalLinks ? View.VISIBLE : View.GONE); - if (isTmdbSourceOnly()) { - binding.tmdbStatus.setVisibility(View.GONE); - } else if (!tmdbConfig.isReady()) { + if (!tmdbConfig.isReady()) { binding.tmdbStatus.setVisibility(View.VISIBLE); binding.tmdbStatus.setText(R.string.detail_tmdb_need_key); } else if (!isTmdbAllowedForCurrentSite()) { @@ -6847,7 +5875,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. selectedSeasonNumber = getIntentPlaybackSeasonNumber(); resetEpisodeRange(); renderFlagSelection(); - renderEpisodes(); } private float getInlinePlaybackSpeed() { @@ -6869,7 +5896,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (enterInlineFullscreenIfCurrentInlinePlayback(selectedEpisode)) return; saveInlineHistory(); updateInlineHistory(selectedEpisode); - modeController.play(); + if (isFusionMode()) playInline(); + else if (isPlayerMode()) playDetailFullscreen(); + else playDefaultPlayback(); } private void playDefaultPlayback() { @@ -6881,26 +5910,15 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. long start = System.currentTimeMillis(); logTmdbMatch("原生增强播放标题:raw=%s,缓存标题=%s,详情标题=%s,播放标题=%s", getTmdbRawTitle(), matchedTmdbItem == null ? "" : matchedTmdbItem.getTitle(), tmdbDetailTitle(matchedTmdbItem, matchedTmdbDetail), playbackHistoryName()); TmdbItem item = playbackTmdbItem(); - EpisodePosition position = historyEpisodePosition(playbackEpisode()); - String tmdbDetailCacheKey = TmdbDetailCache.put(item, matchedTmdbDetail, detailCastItems, TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage()), tmdbSeasonEpisodes); + EpisodePosition position = historyEpisodePosition(selectedEpisode); + String tmdbDetailCacheKey = TmdbDetailCache.put(item, matchedTmdbDetail, detailCastItems); SpiderDebug.log("tmdb-tv", "play launch prep cost=%dms title=%s", System.currentTimeMillis() - start, playbackHistoryName()); VideoActivity.startDirectTmdb(this, getKeyText(), getIdText(), playbackHistoryName(), playbackHistoryPic(), playbackMark(), fastPlaybackEpisodeTitles(), item, playbackTmdbVod(), vod, tmdbDetailCacheKey, playbackFlag(), selectedSeasonFlagKey(), playbackEpisodeName(), playbackEpisodeUrl(), position.season(), position.number(), isResumeFromHistory() ? getIntentResumeHistory() : null); }); } - private Episode playbackEpisode() { - if (selectedEpisode != null) return selectedEpisode; - List episodes = selectedFlag == null ? null : selectedFlag.getEpisodes(); - Episode requested = findEpisodeByUrl(getIntentPlaybackEpisodeUrl(), episodes); - if (requested != null) return requested; - Episode saved = history == null ? null : history.getEpisode(); - if (saved == null || episodes == null || episodes.isEmpty()) return null; - return selectedFlag.find(saved, true); - } - private String playbackMark() { - Episode episode = playbackEpisode(); - if (episode != null && !TextUtils.isEmpty(episode.getName())) return episode.getName(); + if (selectedEpisode != null && !TextUtils.isEmpty(selectedEpisode.getName())) return selectedEpisode.getName(); return getMarkText(); } @@ -6909,20 +5927,17 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private String playbackEpisodeName() { - Episode episode = playbackEpisode(); - return episode == null ? "" : Objects.toString(episode.getName(), ""); + return selectedEpisode == null ? playbackMark() : selectedEpisode.getName(); } private String playbackEpisodeUrl() { - Episode episode = playbackEpisode(); - return episode == null ? "" : Objects.toString(episode.getUrl(), ""); + return selectedEpisode == null ? "" : selectedEpisode.getUrl(); } private ArrayList fastPlaybackEpisodeTitles() { ArrayList result = new ArrayList<>(1); - Episode episode = playbackEpisode(); - if (episode == null) return result; - int number = episodeNumberForHistory(episode); + if (selectedEpisode == null) return result; + int number = episodeNumberForHistory(selectedEpisode); String title = tmdbEpisodeTitle(number); if (number > 0 && !TextUtils.isEmpty(title)) result.add(number + "\t" + title); return result; @@ -6935,23 +5950,15 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. view.setLayoutParams(marginParams); } - private void showTmdbEpisodeDetail(Episode episode, int episodeNumber, TmdbEpisode boundTmdbEpisode, RecyclerView returnRecycler) { + private void showTmdbEpisodeDetail(Episode episode, int episodeNumber, RecyclerView returnRecycler) { + // 对话框关闭后完整重渲染剧集列表,防止焦点状态紊乱导致按钮失效 android.content.DialogInterface.OnDismissListener dismissListener = d -> { - if (binding == null || returnRecycler == null) return; - returnRecycler.post(() -> { - if (binding == null || isFinishing() || isDestroyed() || !returnRecycler.isAttachedToWindow()) return; + if (binding == null || binding.episodeContainer == null || returnRecycler == null) return; + // RecyclerView 可能仍在恢复布局,下一帧再完整重渲染(参考原生增强的 render 机制) + binding.episodeContainer.post(() -> { + // 完整重建列表 + 分组按钮(类似原生增强 render[0].run()) rerenderEpisodeViewportOnly(false, true, true); - if (returnRecycler != binding.episodeContainer) { - // 独立选集面板保留原有恢复路径。 - returnRecycler.post(() -> restoreEpisodeDetailFocus(returnRecycler, episode)); - return; - } - // post/postOnAnimation 不保证刷新已布局;在真实布局后的 pre-draw 恢复精确卡片。 - OneShotPreDrawListener.add(returnRecycler, () -> { - if (binding == null || isFinishing() || isDestroyed() || !returnRecycler.isShown()) return; - restoreEpisodeDetailFocus(returnRecycler, episode); - }); - recoverEpisodeViewportIfDetached(); + returnRecycler.post(() -> restoreEpisodeDetailFocus(returnRecycler, episode)); }); }; @@ -6961,27 +5968,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, null, null, null, dismissListener); return; } - // 剧集场景:详情请求优先使用长按卡片绑定且已通过匹配校验的 TMDB 集。 - // 手动选择季度时,同一线路可能是扁平集列表,卡片绑定对象才是可靠的请求上下文。 - List detailEpisodes = selectedFlag == null ? null : selectedFlag.getEpisodes(); - int detailSeasonNumber = tmdbEpisodeDataSeason(detailEpisodes); - int detailEpisodeNumber = episodeNumber; - if (boundTmdbEpisode != null) { - if (boundTmdbEpisode.getSeasonNumber() >= 0) detailSeasonNumber = boundTmdbEpisode.getSeasonNumber(); - if (boundTmdbEpisode.getNumber() > 0) detailEpisodeNumber = boundTmdbEpisode.getNumber(); - } - // 卡片没有有效 TMDB 映射时也必须有反馈;EpisodeDetailDialog 会展示源集名称, - // 不能只弹 Notify 后结束,否则手动选季下超范围/不匹配的卡片看起来像长按失效。 - if (boundTmdbEpisode == null) { - com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, getSite(), null, null, dismissListener); - return; - } - if (matchedTmdbItem == null || !"tv".equalsIgnoreCase(matchedTmdbItem.getMediaType()) || detailSeasonNumber < 0 || detailEpisodeNumber <= 0 || !canMatchTmdb()) { - com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, boundTmdbEpisode, getSite(), null, null, dismissListener); - return; - } - if (hasCompleteSourceEpisode(detailSeasonNumber, detailEpisodeNumber)) { - com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, boundTmdbEpisode, getSite(), null, null, dismissListener); + // 剧集场景:原有逻辑 + int detailSeasonNumber = tmdbEpisodeDataSeason(selectedFlag == null ? null : selectedFlag.getEpisodes()); + if (matchedTmdbItem == null || !"tv".equalsIgnoreCase(matchedTmdbItem.getMediaType()) || detailSeasonNumber < 0 || episodeNumber <= 0 || !canMatchTmdb()) { + Notify.show(R.string.detail_tmdb_empty); return; } binding.loading.setVisibility(View.VISIBLE); @@ -6989,13 +5979,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. int detailGeneration = ++tmdbEpisodeDetailGeneration; int displaySeasonNumber = detailSeasonNumber; int seasonNumber = detailSeasonNumber; - int requestEpisodeNumber = detailEpisodeNumber; TmdbItem item = matchedTmdbItem; JsonObject baseDetail = matchedTmdbDetail; TmdbConfig config = tmdbConfig; detailTasks.submit(Task.largeExecutor(), () -> { try { - JsonObject detail = tmdbService.episode(item, seasonNumber, requestEpisodeNumber, config, baseDetail); + JsonObject detail = tmdbService.episode(item, seasonNumber, episodeNumber, config, baseDetail); List photos = tmdbService.episodePhotos(detail, config); List guests = tmdbService.episodeGuests(detail, config); runOnAliveUi(() -> { @@ -7005,9 +5994,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } binding.loading.setVisibility(View.GONE); - if (!isTmdbEpisodeDetailSeasonCurrent(displaySeasonNumber)) return; + if (displaySeasonNumber != selectedSeasonNumber) return; // 复用 EpisodeDetailDialog,传入已拉取的 photos/guests 避免重复 API 请求 - com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, boundTmdbEpisode, getSite(), photos, guests, dismissListener); + com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, getSite(), photos, guests, dismissListener); }); } catch (Throwable e) { runOnAliveUi(() -> { @@ -7017,21 +6006,13 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } binding.loading.setVisibility(View.GONE); - if (!isTmdbEpisodeDetailSeasonCurrent(displaySeasonNumber)) return; - // API 失败也保留详情弹窗,至少让用户看到源集名称,而不是把长按吞掉。 - com.fongmi.android.tv.ui.dialog.EpisodeDetailDialog.show(this, episode, boundTmdbEpisode, getSite(), null, null, dismissListener); + if (displaySeasonNumber != selectedSeasonNumber) return; + Notify.show(TextUtils.isEmpty(e.getMessage()) ? getString(R.string.detail_tmdb_empty) : e.getMessage()); }); } }); } - private boolean isTmdbEpisodeDetailSeasonCurrent(int seasonNumber) { - if (seasonNumber < 0) return false; - if (seasonNumber == selectedSeasonNumber) return true; - List episodes = selectedFlag == null ? null : selectedFlag.getEpisodes(); - return seasonNumber == tmdbEpisodeDataSeason(episodes); - } - private void restoreEpisodeDetailFocus(RecyclerView recycler, Episode episode) { if (recycler == null || episode == null) return; RecyclerView.Adapter adapter = recycler.getAdapter(); @@ -7046,7 +6027,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. */ private void showMovieDialog(Episode episode) { DialogTmdbEpisodeBinding dialogBinding = DialogTmdbEpisodeBinding.inflate(getLayoutInflater()); - AlertDialog dialog = new WebHtvAlertDialogBuilder(this).setView(dialogBinding.getRoot()).create(); + AlertDialog dialog = new MaterialAlertDialogBuilder(this).setView(dialogBinding.getRoot()).create(); ThemeColors colors = lightTheme ? ThemeColors.light() : ThemeColors.dark(); dialogBinding.panel.setCardBackgroundColor(colors.panel); dialogBinding.panel.setStrokeColor(colors.line); @@ -7161,7 +6142,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void showTmdbEpisodeDialog(Episode episode, int episodeNumber, JsonObject detail, List photos, List guests) { DialogTmdbEpisodeBinding dialogBinding = DialogTmdbEpisodeBinding.inflate(getLayoutInflater()); - AlertDialog dialog = new WebHtvAlertDialogBuilder(this).setView(dialogBinding.getRoot()).create(); + AlertDialog dialog = new MaterialAlertDialogBuilder(this).setView(dialogBinding.getRoot()).create(); ThemeColors colors = lightTheme ? ThemeColors.light() : ThemeColors.dark(); dialogBinding.panel.setCardBackgroundColor(colors.panel); dialogBinding.panel.setStrokeColor(colors.line); @@ -7178,7 +6159,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } dialogBinding.meta.setText(episodeMeta(detail)); dialogBinding.meta.setVisibility(TextUtils.isEmpty(dialogBinding.meta.getText()) ? View.GONE : View.VISIBLE); - String overview = tmdbService.translatedOverview(detail, tmdbConfig); + String overview = string(detail, "overview"); if (TextUtils.isEmpty(overview)) overview = episode == null ? "" : episode.getDesc(); dialogBinding.overview.setText(TextUtils.isEmpty(overview) ? getString(R.string.detail_tmdb_empty) : overview); String crew = episodeCrew(detail); @@ -7449,12 +6430,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void applyPhotoButtonFocus(MaterialButton button, boolean focused) { button.setBackgroundTintList(ColorStateList.valueOf(focused ? 0x33FFFFFF : 0x18FFFFFF)); - button.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(CHIP_STROKE_DP)); - button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : 0x4DFFFFFF)); + button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : CHIP_STROKE_DP)); + button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : 0x4DFFFFFF)); } private void showPhotoActionDialog(String url) { - new WebHtvAlertDialogBuilder(this) + new MaterialAlertDialogBuilder(this) .setItems(new CharSequence[]{getString(R.string.detail_image_save)}, (dialog, which) -> savePhoto(url, null)) .show(); } @@ -7562,7 +6543,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private String episodeDetailTitle(Episode episode, int episodeNumber, JsonObject detail) { - String name = TmdbLanguagePolicy.bestDisplayValue(string(detail, "name"), array(detail, "translations", "translations"), "name", TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); + String name = string(detail, "name"); if (TextUtils.isEmpty(name)) { TmdbEpisode tmdbEpisode = tmdbEpisodes.get(episodeNumber); name = tmdbEpisode == null ? "" : tmdbEpisode.getTitle(); @@ -7652,12 +6633,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private boolean isHistoryEpisode(Episode episode, History item) { if (episode == null || item == null) return false; Episode saved = item.getEpisode(); - // 历史集 URL 仍能定位到当前线路条目(同集多版本并存)时启用版本消歧, - // 避免“点第二版本却被当作第一版本”;换线路/换源/源站刷新后 URL 必然失配, - // 保留集号容错,保住跨线路续播与刷新后的“继续播放”。 - boolean versionAware = selectedFlag != null && selectedFlag.containsEpisodeUrl(saved); if (item.getTmdbEpisodeNumber() > 0 && episode.getTmdbEpisode() != null && episode.getTmdbEpisode().getNumber() > 0) { - return episode.matchesPlayback(saved, versionAware); + return episode.matchesPlayback(saved); } if (!TextUtils.isEmpty(item.getEpisodeUrl()) && item.getEpisodeUrl().equals(episode.getUrl())) return true; return episode.matchesName(saved) || episode.getDisplayName().equals(item.getVodRemarks()) || historyEpisodeTitle(episode).equals(item.getVodRemarks()); @@ -7788,25 +6765,27 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return coalesce(matchedTmdbItem == null ? "" : matchedTmdbItem.getPosterUrl(), matchedTmdbItem == null ? "" : matchedTmdbItem.getBackdropUrl(), vod == null ? "" : vod.getPic(), getPicText()); } - private boolean rawFusionMode() { + private boolean isFusionMode() { return getDetailMode() == Setting.DETAIL_OPEN_FUSION || getIntent().getBooleanExtra("fusion", false); } - private boolean rawPlayerMode() { + private boolean isPlayerMode() { return getDetailMode() == Setting.DETAIL_OPEN_PLAYER; } + private boolean isCinemaMode() { + return getIntent().getIntExtra("detail_mode", Setting.getDetailOpenMode()) == Setting.DETAIL_OPEN_CINEMA || Setting.isTmdbCinemaStyle(); + } + private int getDetailMode() { - // 返回原始模式,不做 normalize,否则 modeController.isPlayerMode() 永远返回 false + // 返回原始模式,不做 normalize,否则 isPlayerMode() 永远返回 false if (getIntent().hasExtra("detail_mode")) return getIntent().getIntExtra("detail_mode", Setting.DETAIL_OPEN_ENHANCED); - // 详情直放没有内嵌播放界面;若既无 detail_mode 也无 fusion 标记,只能按当前设置还原, - // 不能把无标记默认成炫彩详情,否则点击播放会误走融合内嵌播放。 - return Setting.getDetailOpenMode(); + return getIntent().getBooleanExtra("fusion", false) ? Setting.DETAIL_OPEN_FUSION : Setting.DETAIL_OPEN_ENHANCED; } private int detailModeTitle() { - if (modeController.isFusionMode()) return R.string.setting_detail_open_fusion; - if (modeController.isPlayerMode()) return R.string.setting_detail_open_player; + if (isFusionMode()) return R.string.setting_detail_open_fusion; + if (isPlayerMode()) return R.string.setting_detail_open_player; return R.string.setting_detail_open_enhanced; } @@ -7815,7 +6794,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean shouldUseLoadingOnlyBeforeDefaultPlayback() { - return isAutoPlayMode() && !modeController.isFusionMode() && !modeController.isPlayerMode(); + return isAutoPlayMode() && !isFusionMode() && !isPlayerMode(); } private void setLoadingOnlyBeforeDefaultPlayback(boolean loadingOnly) { @@ -7828,7 +6807,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean isInlinePlayerMode() { - return modeController.isFusionMode() || detailPlayerActive; + return isFusionMode() || detailPlayerActive; } private boolean isCurrentInlinePlayback(Episode episode) { @@ -7838,8 +6817,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. && episode != null && episode.equals(inlinePlaybackEpisode) && TextUtils.equals(getKeyText(), inlinePlaybackKey) - && TextUtils.equals(selectedFlag.getFlag(), inlinePlaybackFlag) - && TextUtils.equals(selectedSeasonFlagKey(), inlinePlaybackFlagKey); + && TextUtils.equals(selectedFlag.getFlag(), inlinePlaybackFlag); } private boolean enterInlineFullscreenIfCurrentInlinePlayback(Episode episode) { @@ -7849,7 +6827,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void maybeAutoPlayInline() { - if (!modeController.shouldAutoPlay() || autoPlayed) return; + if ((!isFusionMode() && !isAutoPlayMode()) || autoPlayed) return; autoPlayed = true; binding.playerPanel.post(this::onPlay); } @@ -7864,16 +6842,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. ensureInlineDanmakuController(); binding.playerPanel.setVisibility(View.VISIBLE); binding.playerPanelSpacer.setVisibility(View.VISIBLE); // spacer 作为焦点桥梁需要可见 - detailPlayerFullscreenPending = false; enterInlineFullscreen(); if (!current) playInline(); } - private void revealDetailPlayerFullscreen() { - detailPlayerFullscreenPending = false; - if (!inlineFullscreen) enterInlineFullscreen(); - } - private void playInline() { inlineHttpRefreshAttempted = false; playInline(C.TIME_UNSET, "", ""); @@ -7887,28 +6859,23 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void playInline(long resumePosition, String failedUrl, String failureMessage) { if (selectedFlag == null || selectedEpisode == null) return; - inlinePlaybackPending = true; - updateInlineLoading(); + inlinePlaybackLoading = true; int generation = ++inlinePlaybackGeneration; - inlinePlaybackPendingGeneration = generation; + inlinePlayerSwitchLoading = false; String key = getKeyText(); String flag = selectedFlag.getFlag(); String episodeUrl = selectedEpisode.getUrl(); - int playerKernel = inlineHistoryPlayerKernel(); + stopInlinePlayerForReload(); + showInlineLoading(); updateInlineDisplayPanel(); detailTasks.submit(() -> { try { - Result result = SiteApi.playerContent(key, flag, episodeUrl, playerKernel); + Result result = SiteApi.playerContent(key, flag, episodeUrl); runOnAliveUi(() -> { - if (!isInlinePlaybackRequestCurrent(generation, key, flag, episodeUrl)) { - // 请求已被作废(换集/换源/详情重载/外部播放等):不会再有结果来收圈, - // 由置位它的这个代际负责释放。 - releaseInlinePlaybackPending(generation); - return; - } - inlinePlaybackPending = false; + if (!isInlinePlaybackRequestCurrent(generation, key, flag, episodeUrl)) return; String resolvedUrl = result.getUrl() == null ? "" : result.getUrl().v(); if (!TextUtils.isEmpty(failedUrl) && TextUtils.equals(failedUrl, resolvedUrl)) { + inlinePlaybackLoading = false; SpiderDebug.log("webhome-inline", "http refresh rejected unchanged url key=%s id=%s", key, episodeUrl); showInlineError(failureMessage); return; @@ -7918,11 +6885,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } catch (Throwable e) { String message = e.getMessage(); runOnAliveUi(() -> { - if (!isInlinePlaybackRequestCurrent(generation, key, flag, episodeUrl)) { - releaseInlinePlaybackPending(generation); - return; - } - inlinePlaybackPending = false; + if (!isInlinePlaybackRequestCurrent(generation, key, flag, episodeUrl)) return; String fallback = TextUtils.isEmpty(failureMessage) ? getString(R.string.error_play_url) : failureMessage; showInlineError(TextUtils.isEmpty(message) ? fallback : message); }); @@ -7931,19 +6894,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void stopInlinePlayerForReload() { - mAdFeedbackGeneration++; + // 换源换集:作废在途归因,避免用上一集的证据弹窗并写错规则 + resetAdFeedback(); subtitlePlaybackSession.stop(this); inlineStartPosition = C.TIME_UNSET; inlineStartPositionApplied = false; - // 播放器已清空:窗口态回安全默认,避免残留的 false 永久禁用后续同集进度更新。 - inlinePlaybackSettled = true; - inlinePlayerMediaReady = true; pendingInlineResult = null; currentInlineResult = null; inlinePlaybackEpisode = null; inlinePlaybackKey = ""; inlinePlaybackFlag = ""; - inlinePlaybackFlagKey = ""; introSkipPlayback.reset(); if (service() == null || player() == null || player().isEmpty()) { updateInlineButtons(false); @@ -7959,11 +6919,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void startInlinePlayer(Result result, long resumePosition) { - inlinePlaybackPending = false; - // 切集窗口在此关闭:本次解析结果就属于当前选中集,此后实时进度重新允许写回 history。 - // 放在 NovelRouter 拦截之前——阅读器接管后播放器会被清空,进度写入自然早退; - // 若放在拦截之后,窗口态会泄漏到阅读器返回后的同集播放,永远禁用进度更新。 - inlinePlaybackSettled = true; // 决定性拦截:play_url 协议为 novel:// / pics:// / manga:// → 直启阅读器,不再喂给内联 player // (选集 / quality 切换 / 重连 / resume 全走此汇聚点。) if (NovelRouter.guardInlinePlay(this, result, @@ -7988,46 +6943,30 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. pendingInlineResult = result; return; } + inlinePlaybackLoading = false; inlineStarted = true; - inlinePlayHealthKey = getKeyText(); - SiteHealthStore.recordPlayAttempt(inlinePlayHealthKey); - inlinePlayHealthRecorded = false; inlineFirstReady = false; // 重置标志,允许新播放首次 READY 时显示控制栏 inlineButtonsReordered = false; // 重置标志,允许新播放重新排序按钮 inlinePlaybackEpisode = selectedEpisode; inlinePlaybackKey = getKeyText(); inlinePlaybackFlag = selectedFlag == null ? "" : selectedFlag.getFlag(); - inlinePlaybackFlagKey = selectedFlag == null ? "" : selectedSeasonFlagKey(); pendingInlineResult = null; hideInlineControls(); resetInlineShortDramaMode(); updateInlineButtons(false); - // 媒体就绪态在此失效:stop 之后、新集 READY 之前,getPosition() 仍返回旧集的残留 - // 位置(Exo stop 不归零,直到新 media prepare 完成)。这段加载期里每秒 tick 若把 - // 播放器读数写回 history,旧集的末尾位置就会记到新集名下——新集 READY 后 - // applyInlineStartPosition 会顺着它 seekTo 到上一集看过的位置,表现为「下一集从 - // 上一集的进度开始」。短剧快切尤其必现(解析快,tick 大概率落在加载窗口内)。 - inlinePlayerMediaReady = false; player().stop(); player().clear(); if (resumePosition == C.TIME_UNSET) resetInlineHistoryIfNearEnding(); inlineStartPosition = resumePosition == C.TIME_UNSET ? getInlineResumePosition() : Math.max(0, resumePosition); inlineStartPositionApplied = false; - player().preparePlayer(inlineHistoryPlayerKernel()); - // 内核不一致时 preparePlayer() 会在 PlaySpec 建立之前就重建引擎,那一刻 getKey() 还是 - // null、归属判定必为假,PlaybackActivity.onPlayerRebuild() 会整段跳过——其中就包含 - // setProgressPlayer()。进度视图于是继续读已被 release 的旧引擎,读到时长 0、位置 0, - // 进度条恒为 00:00。这里在重建刚发生后显式跟上新引擎,对齐 leanback VideoActivity 的同一处理。 - getSeekView().setProgressPlayer(player().getPlayer()); + player().switchPlayer(PlayerSetting.getPlayer()); + updateInlineHistoryPlayer(); setInlineSpeed(getInlinePlaybackSpeed()); updateInlineButtons(false); Site site = getCurrentSite(); ensureInlineDanmakuController(); - if (SubtitleRestoreCoordinator.restore(history, player(), result)) persistHistorySubtitleSource(); startPlayer(getHistoryKey(), result, useParse, site == null ? 0 : site.getTimeout(), buildMetadata()); - updateNavigationKey(); subtitlePlaybackSession.onPlaybackStarted(this, result); - modeController.onPlaybackStarted(); searchInlineDanmaku(result); binding.playerPanel.requestFocus(); } @@ -8040,8 +6979,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. .rawTitle(playbackHistoryName()) .rawRemarks(history.getVodRemarks()) .episodeName(historyEpisodeTitle(selectedEpisode)) - .tmdbId(danmakuTmdbId()) - .tmdbSeasonNumber(danmakuTmdbSeasonNumber()) .source(MediaTitleLearningExample.SOURCE_DANMAKU_AUTO) .allowAi(true) .build(), danmaku -> applyInlineDanmaku(result, danmaku)); @@ -8054,34 +6991,44 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. refreshInlineDanmakuButtons(); } - private int danmakuTmdbId() { - TmdbItem item = matchedTmdbItem; - return item == null ? 0 : item.getTmdbId(); - } - - private int danmakuTmdbSeasonNumber() { - TmdbItem item = matchedTmdbItem; - if (item == null || !item.isTv()) return 0; - TmdbEpisode tmdbEpisode = selectedEpisode == null ? null : selectedEpisode.getTmdbEpisode(); - return tmdbEpisode == null ? 0 : tmdbEpisode.getSeasonNumber(); - } - private void refreshInlineDanmakuButtons() { if (!isInlinePlayerMode() || service() == null || player() == null || player().isEmpty()) return; updateInlineButtons(player().isPlaying()); } private void showInlineError(String text) { + inlinePlaybackLoading = false; + hideInlineLoading(); binding.playerError.setText(text); binding.playerError.setVisibility(View.VISIBLE); updateInlineDisplayPanel(); } + private void showInlineLoading() { + binding.playerError.setVisibility(View.GONE); + binding.playerProgress.setVisibility(View.VISIBLE); + tintInlineLoading(); + updateInlineLoadingTraffic(); + hideInlineControls(); + } + + private void hideInlineLoading() { + binding.playerProgress.setVisibility(View.GONE); + binding.playerProgressTraffic.setVisibility(View.GONE); + Traffic.reset(binding.playerProgressTraffic); + } + + private void updateInlineLoadingTraffic() { + if (binding == null || binding.playerProgress.getVisibility() != View.VISIBLE) return; + Traffic.setSpeed(binding.playerProgressTraffic, service() == null ? null : player()); + tintInlineLoading(); + } + private void toggleInlinePlayback() { if (!isInlinePlayerMode()) return; if (inlinePlaybackReconnectPending) return; - if (isSamePendingInlinePlayback(selectedEpisode)) return; if (controller() == null || service() == null || player().isEmpty()) { + if (isSamePendingInlinePlayback(selectedEpisode)) return; onPlay(); return; } @@ -8150,7 +7097,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean suppressInlineDisplayForLeanbackFusionExit() { - if (!Util.isLeanback() || !modeController.isFusionMode()) return false; + if (!Util.isLeanback() || !isFusionMode()) return false; inlineDisplaySuppressUntil = Math.max(inlineDisplaySuppressUntil, SystemClock.uptimeMillis() + LEANBACK_FUSION_EXIT_DISPLAY_SUPPRESS_MS); hideInlineDisplayPanel(); return true; @@ -8190,28 +7137,13 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. }); } - /** - * 遥控端唤出控制栏时,只有「用户真实选中过的普通控件」才算有效记忆焦点。 - * 片头/片尾是区间快捷入口,一旦被记成「上次焦点」,每次唤出控制栏都会被还原 - * 到片头/片尾,覆盖用户真正选中的那个按钮。与影视原生模式 hasRememberedFocus() - * 保持同一判定。 - */ - private boolean hasRememberedInlineControlFocus() { - return inlineControlFocus != null - && isVisibleInHierarchy(inlineControlFocus) - && inlineControlFocus.isEnabled() - && inlineControlFocus != binding.playerOpening - && inlineControlFocus != binding.playerEnding; - } - private View getInlineControlFocus() { if (Util.isMobile()) { - // 手机端以触控导航为主,保持原有「记住上次控件」语义,不套用遥控端的片头/片尾排除。 if (inlineControlFocus != null && isVisibleInHierarchy(inlineControlFocus) && inlineControlFocus.isEnabled()) return inlineControlFocus; return detailControlView(R.id.play, View.class); } // TV模式:按顺序查找第一个可见且启用的按钮 - if (hasRememberedInlineControlFocus()) return inlineControlFocus; + if (inlineControlFocus != null && isVisibleInHierarchy(inlineControlFocus) && inlineControlFocus.isEnabled()) return inlineControlFocus; View[] candidates = { binding.playerNext, binding.playerPrev, binding.playerEpisodes, binding.playerRefresh, binding.playerChangeSource, binding.playerFullscreenAction @@ -8254,11 +7186,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void updateInlineButtons(boolean playing) { if (!isInlinePlayerMode() || inlineControlController == null) { setInlineDecodeText(getString(R.string.play_decode_idle)); - updateInlineCustomButtonVisibility(); return; } boolean hasPlayer = service() != null && !player().isEmpty(); - updateInlineCustomButtonVisibility(); setInlineSpeedText(service() == null || player().isEmpty() ? getString(R.string.play_speed) : player().getSpeedText()); setInlineDecodeText(inlineDecodeText(hasPlayer)); binding.playerExternal.setText(service() == null ? getString(R.string.play_exo) : player().getPlayerText()); @@ -8317,7 +7247,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. setInlineFullscreenIcon(); updateMobileInlineButtons(playing, hasPlayer, episodeCount, hasTitle); applyInlinePlayerButtonSettings(); - setupInlineCustomButtonFocus(); updateInlineDisplayPanel(); // 更新按钮颜色 updateInlineButtonColors(); @@ -8659,27 +7588,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. // 已废弃:统一使用 PlayerOsdController(OSD)系统 // 原逻辑已迁移到 PlayerOsdController.render() // 保留此方法避免删除所有调用点时遗漏 - updateInlineLoading(); - } - - private void updateInlineLoading() { - boolean loading = inlinePlaybackPending || (player() != null && !isPaused() && !player().isPlaying() && player().isLoading()); - binding.playerLoading.removeCallbacks(inlineLoadingSpeedRefresh); - binding.playerLoading.setVisibility(loading ? View.VISIBLE : View.GONE); - if (loading && inlinePauseInfo) hideInlinePauseInfo(); - if (!loading) { - binding.playerLoadingTraffic.setText(""); - binding.playerLoadingTraffic.setVisibility(View.GONE); - return; - } - updateInlineLoadingSpeed(); - binding.playerLoading.postDelayed(inlineLoadingSpeedRefresh, 1000L); - } - - private void updateInlineLoadingSpeed() { - String traffic = inlineOsd == null ? "" : inlineOsd.sampleSpeedText(); - binding.playerLoadingTraffic.setText(traffic); - binding.playerLoadingTraffic.setVisibility(TextUtils.isEmpty(traffic) ? View.GONE : View.VISIBLE); } private void setButtonEnabled(View button, boolean enabled) { @@ -8733,7 +7641,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. protected void onInlineInfo() { if (!hasInlineInfo()) return; - new WebHtvAlertDialogBuilder(this) + new MaterialAlertDialogBuilder(this) .setTitle(inlineTitleText()) .setMessage(buildInlineInfoText()) .setPositiveButton(R.string.dialog_positive, null) @@ -8861,7 +7769,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. int count = currentInlineResult.getUrl().getValues().size(); String[] labels = new String[count]; for (int i = 0; i < count; i++) labels[i] = inlineQualityName(i); - new WebHtvAlertDialogBuilder(this) + new MaterialAlertDialogBuilder(this) .setTitle(R.string.detail_quality) .setSingleChoiceItems(labels, currentInlineResult.getUrl().getPosition(), (dialog, which) -> { dialog.dismiss(); @@ -8878,6 +7786,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } saveInlineHistory(); + // 换画质同集换 URL:切片结构与在途归因都属于上一画质。 + // 放在这里而不是 startInlinePlayer,因为后者也承担断连恢复, + // 那条路径不该关掉用户正在看的反馈对话框。 + resetAdFeedback(); currentInlineResult.getUrl().set(position); updateInlineButtons(service() != null && player() != null && !player().isEmpty() && player().isPlaying()); startInlinePlayer(currentInlineResult); @@ -8936,11 +7848,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void changeInlineSpeed() { if (service() == null || player().isEmpty()) return; - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || player().isEmpty()) return; - setInlineSpeed(speed); - if (history != null) history.setUserSpeed(player().getSpeed()); - }); + setInlineSpeedText(player().addSpeed()); + if (history != null) history.setUserSpeed(player().getSpeed()); } private void setInlineSpeed(float speed) { @@ -8967,8 +7876,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void refreshInlinePlayback() { if (selectedFlag == null || selectedEpisode == null) return; if (history != null) history.setPosition(C.TIME_UNSET); - // 与 onReplay 同理:清位后若不开窗,重播前的实时进度会立刻回填,重播失效。 - inlinePlaybackSettled = false; playInline(); } @@ -9019,9 +7926,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private String getInlineOsdTitle() { if (selectedEpisode == null) return ""; String name = playbackHistoryName(); - String episodeTitle = historyEpisodeTitle(selectedEpisode); - return TextUtils.isEmpty(episodeTitle) || TextUtils.equals(name, episodeTitle) - ? name : getString(R.string.detail_title, name, episodeTitle); + String episode = selectedEpisode.getName(); + String title = TextUtils.isEmpty(episode) ? name : name + " " + episode; + String progress = tmdbEpisodeInfo().compactText(this); + return TextUtils.isEmpty(progress) ? title : title + " · " + progress; } private void onInlineLut() { @@ -9285,7 +8193,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. String[] kernels = PlayerKernelDialog.kernels(getResources()); String[] items = Arrays.copyOf(kernels, kernels.length + 1); items[kernels.length] = getString(R.string.player_kernel_external); - new WebHtvAlertDialogBuilder(this).setItems(items, (dialog, which) -> onInlinePlayerChoice(kernels, which)).show(); + new MaterialAlertDialogBuilder(this).setItems(items, (dialog, which) -> onInlinePlayerChoice(kernels, which)).show(); return true; } @@ -9310,11 +8218,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. String episodeUrl = selectedEpisode.getUrl(); if (TextUtils.isEmpty(flag) || TextUtils.isEmpty(episodeUrl)) return false; int generation = ++inlinePlaybackGeneration; - // 只自增代际、不接替请求:在途的取址回调会因“不再是最新请求”直接返回, - // 它留下的加载标记必须在这里释放,否则圈永久留在屏上,且同一集会因 - // isSamePendingInlinePlayback 恒真而无法再次起播。切内核自身的缓冲显示由 - // updateInlineLoading 的引擎条件兜住,不依赖这个标记。 - releaseInlinePlaybackPending(); + inlinePlayerSwitchLoading = true; + showInlineLoading(); long position = player().getPosition(); float speed = player().getSpeed(); boolean repeat = player().isRepeatOne(); @@ -9326,21 +8231,23 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. runOnAliveUi(() -> { if (!isInlinePlayerSwitchRequestCurrent(generation, key, flag, episodeUrl)) return; if (result == null || result.hasMsg() || result.getRealUrl().isEmpty()) { - updateInlineDisplayPanel(); + finishInlinePlayerSwitchRequest(); Notify.show(result != null && result.hasMsg() ? result.getMsg() : getString(R.string.error_play_url)); } else { + // 换内核重新解析出可能不同的 URL,与换画质同理 + resetAdFeedback(); currentInlineResult = result; inlineHttpRefreshAttempted = false; useParse = result.shouldUseParse(); - player().switchPlayer(playerType, result, activePlaybackKey(), metadata, useParse, position, speed, repeat); - rememberInlinePlayerKernel(playerType); + inlinePlayerSwitchLoading = false; + player().switchPlayer(playerType, result, getHistoryKey(), metadata, useParse, position, speed, repeat); } finishInlinePlayerSwitch(); }); } catch (Throwable e) { runOnAliveUi(() -> { if (!isInlinePlayerSwitchRequestCurrent(generation, key, flag, episodeUrl)) return; - updateInlineDisplayPanel(); + finishInlinePlayerSwitchRequest(); finishInlinePlayerSwitch(); Notify.show(TextUtils.isEmpty(e.getMessage()) ? getString(R.string.error_play_url) : e.getMessage()); }); @@ -9350,13 +8257,20 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void cancelPendingInlinePlayerSwitch() { + if (!inlinePlayerSwitchLoading) return; inlinePlaybackGeneration++; - // 同 refreshAndSwitchInlinePlayer:作废在途取址请求时必须一并释放它留下的加载标记。 - releaseInlinePlaybackPending(); + finishInlinePlayerSwitchRequest(); + } + + private void finishInlinePlayerSwitchRequest() { + inlinePlayerSwitchLoading = false; + hideInlineLoading(); + updateInlineDisplayPanel(); } private boolean isInlinePlayerSwitchRequestCurrent(int generation, String key, String flag, String episodeUrl) { - return inlineStarted + return inlinePlayerSwitchLoading + && inlineStarted && isInlinePlayerMode() && isOwner() && service() != null @@ -9366,6 +8280,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void finishInlinePlayerSwitch() { + updateInlineHistoryPlayer(); syncInlineHistory(); binding.playerExternal.setText(player().getPlayerText()); setInlineDecodeText(inlineDecodeText(true)); @@ -9389,7 +8304,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(); @@ -9397,82 +8312,182 @@ 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()) - && PlaybackResourceClassifier.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 resetAdFeedback() { + mAdMarkStartMs = -1; + if (mAdFeedback != null) mAdFeedback.invalidate(); + if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence(); + // 作废后归因回调不再刷新界面,对话框会永久停在「分析中」 + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); + mAdFeedbackDialog = null; + } + + /** + * 起播后记录本站域名,供域名信誉通道建立基线。 + * + *

必须强制初始化适配器:它原本只在用户首次按下「有广告」时懒创建, + * 若这里跳过未初始化的情况,首次反馈时基线仍为空,域名通道会因 + * 「无基线不断言」而弃权 —— 整条通道等于没接。 + */ + private void recordAdFeedbackHost() { + // 关掉去广告或播直播时不建基线:这些域名会挤掉 LRU 里真正有用的条目 + if (!isInlineAdFeedbackEnabled()) return; + adFeedback(); + mAdFeedbackHost.recordPlaybackHost(); + } + + /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */ private void submitInlineAdFeedback() { - AdDetectionRequest request = buildInlineAdDetectionRequest(); - if (request == null) { - Notify.show(R.string.ad_feedback_no_url); + AdFeedbackController controller = adFeedback(); + 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, end); + } else { + session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence()); + } + if (session == null) { + Notify.show(R.string.ad_interval_invalid); return; } - 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); - }); - }); + // 统计放在成交之后,未成立的反馈不计入 + AdBlockStatsStore.recordFeedback(getKeyText()); } - 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)).tmdb(danmakuTmdbId(), danmakuTmdbSeasonNumber()).show(this); + DanmakuDialog.create().player(player()).identity(getKeyText(), getIdText(), playbackHistoryName(), selectedEpisode == null ? "" : historyEpisodeTitle(selectedEpisode)).show(this); } private void showInlineTitle() { @@ -9509,8 +8524,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean resetInlineOpening() { - // 长按清空是「这里不要有值」,别紧接着又把探测值渲染上去,看起来像没清掉 - introSkipPlayback.suppressDetected(true); setInlineOpening(0); setInlineHideCallback(); return true; @@ -9532,7 +8545,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean resetInlineEnding() { - introSkipPlayback.suppressDetected(false); setInlineEnding(0); setInlineHideCallback(); return true; @@ -9546,7 +8558,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void updateInlineOpeningEndingText() { - if (binding == null) return; binding.playerOpening.setText(inlineOpeningLabel()); binding.playerEnding.setText(inlineEndingLabel()); if (!Util.isMobile() || detailActionRoot == null) return; @@ -9555,41 +8566,30 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private String inlineOpeningLabel() { - if (history != null && history.getOpening() > 0) return Util.timeMs(history.getOpening()); - long detected = detectedIntroSkipValue(true); - return detected > 0 ? getString(R.string.intro_skip_detected_value, Util.timeMs(detected)) : getString(R.string.play_op); + return history != null && history.getOpening() > 0 ? Util.timeMs(history.getOpening()) : getString(R.string.play_op); } private String inlineEndingLabel() { - if (history != null && history.getEnding() > 0) return Util.timeMs(history.getEnding()); - long detected = detectedIntroSkipValue(false); - return detected > 0 ? getString(R.string.intro_skip_detected_value, Util.timeMs(detected)) : getString(R.string.play_ed); - } - - /** 关掉自动跳过时不显示探测值——那种情况下这个数字不会导致任何动作,显示出来是误导。 */ - private long detectedIntroSkipValue(boolean opening) { - // isReleased 必查:服务已 release 但 Activity 还握着 mService 的窗口里, - // PlayerManager.getDuration() 会直接对空的 player 取值抛 NPE - if (!Setting.isIntroSkipEnabled() || player() == null || player().isReleased()) return -1; - return opening ? introSkipPlayback.getDetectedOpeningMs() : introSkipPlayback.getDetectedEndingMs(player().getDuration()); + return history != null && history.getEnding() > 0 ? Util.timeMs(history.getEnding()) : getString(R.string.play_ed); } private void onInlineBack() { if (inlineFullscreen) backFromInlineFullscreen(); else { prepareInlinePlayerTransition(); - saveInlineHistory(); - stopInlinePlaybackSync(); - if (inlineStarted && isOwner()) stopPlayback(); - inlineStarted = false; finish(); } } private void backFromInlineFullscreen() { + // 详情直放模式(含手机版)返回时应关闭内嵌播放器回到纯详情页, + // 否则手机版只退出全屏、播放器面板仍可见,看起来跟沉浸融合模式一样 + if (isPlayerMode()) { + exitInlineFullscreen(); + closeDetailFullscreenPlayer(); + return; + } exitInlineFullscreen(); - modeController.onExitFullscreen(); - return; } private void finishPlaybackToHome() { @@ -9737,7 +8737,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. showPage.accept(pageIndex[0], true); }); - AlertDialog dialog = new WebHtvAlertDialogBuilder(this) + AlertDialog dialog = new MaterialAlertDialogBuilder(this) .setView(content) .create(); holder[0] = dialog; @@ -9808,9 +8808,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } @Override - public void onItemLongClick(View anchor, Episode episode, int episodeNumber, TmdbEpisode tmdbEpisode) { + public void onItemLongClick(View anchor, Episode episode, int episodeNumber) { anchor.setPressed(false); - showTmdbEpisodeDetail(episode, episodeNumber, tmdbEpisode, recycler); + showTmdbEpisodeDetail(episode, episodeNumber, recycler); } }); adapter.setLight(lightTheme); @@ -9919,7 +8919,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. showPage.accept(selectedPage, true); }; - AlertDialog dialog = new WebHtvAlertDialogBuilder(this).setView(scroll).create(); + AlertDialog dialog = new MaterialAlertDialogBuilder(this).setView(scroll).create(); panel.setTag(dialog); dialog.setOnShowListener(value -> render[0].run()); if (!canTouchUi()) return; @@ -9968,7 +8968,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. GradientDrawable background = new GradientDrawable(); background.setCornerRadius(ResUtil.dp2px(4)); background.setColor(focused ? colors.control : selected ? colors.chipActive : colors.chip); - background.setStroke(focused ? focusRingWidthPx() : ResUtil.dp2px(selected ? 2 : CHIP_STROKE_DP), focused ? focusStroke() : selected ? colors.accent : colors.line); + background.setStroke(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : selected ? 2 : CHIP_STROKE_DP), focused ? FOCUS_STROKE : selected ? colors.accent : colors.line); button.setSelected(selected); button.setActivated(selected); button.setTextColor(colors.primary); @@ -10150,8 +9150,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. GradientDrawable background = new GradientDrawable(); background.setCornerRadius(ResUtil.dp2px(6)); if (focused) { - background.setColor(ThemeController.focusRingColor(this, lightTheme ? 0.10f : 0.33f)); - background.setStroke(focusRingWidthPx(), focusStroke()); + background.setColor(lightTheme ? 0x1AFFD166 : 0x55FFD166); + background.setStroke(ResUtil.dp2px(FOCUS_STROKE_DP), FOCUS_STROKE); button.setTextColor(lightTheme ? colors.primary : 0xFFFFFFFF); } else if (selected) { background.setColor(lightTheme ? 0x1F20B866 : 0x332CC56F); @@ -10169,8 +9169,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. GradientDrawable background = new GradientDrawable(); background.setCornerRadius(ResUtil.dp2px(6)); if (focused) { - background.setColor(ThemeController.focusRingColor(this, 0.33f)); - background.setStroke(focusRingWidthPx(), focusStroke()); + background.setColor(0x55FFD166); + background.setStroke(ResUtil.dp2px(FOCUS_STROKE_DP), FOCUS_STROKE); } else { background.setColor(0x00000000); } @@ -10213,9 +9213,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. int text = focused ? (lightTheme ? colors.primary : 0xFFFFFFFF) : colors.primary; button.setTextColor(text); button.setIconTint(ColorStateList.valueOf(text)); - button.setBackgroundTintList(ColorStateList.valueOf(focused ? ThemeController.focusRingColor(this, lightTheme ? 0.10f : 0.33f) : colors.control)); - button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : colors.lineStrong)); - button.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(1)); + button.setBackgroundTintList(ColorStateList.valueOf(focused ? (lightTheme ? 0x1AFFD166 : 0x55FFD166) : colors.control)); + button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : colors.lineStrong)); + button.setStrokeWidth(ResUtil.dp2px(focused ? 2 : 1)); } private void updateInlineEpisodeModeIcon(ImageView icon) { @@ -10304,37 +9304,17 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return 3; } - /** - * 释放被作废的取流请求留下的加载标记。 - * - *

没有结果回调会再来收圈(回调要么随任务被取消而不执行,要么因请求失效直接返回), - * 标记残留会让加载圈永久留在屏上,并让 {@link #isSamePendingInlinePlayback} 恒真、 - * 同一集无法再次起播。 - */ - private void releaseInlinePlaybackPending() { - if (!inlinePlaybackPending) return; - inlinePlaybackPending = false; - inlinePlaybackPendingGeneration = 0; - updateInlineDisplayPanel(); - } - - /** 只有置位该标记的那个请求才有权释放它,避免旧回调清掉新请求的标记。 */ - private void releaseInlinePlaybackPending(int generation) { - if (inlinePlaybackPendingGeneration != generation) return; - releaseInlinePlaybackPending(); - } - private boolean isSamePendingInlinePlayback(Episode episode) { - return inlinePlaybackPending && selectedEpisode != null && selectedEpisode.equals(episode); + return inlinePlaybackLoading && selectedEpisode != null && selectedEpisode.equals(episode); } private void selectInlineEpisode(Episode episode) { - if (isSamePendingInlinePlayback(episode)) return; if (NovelRouter.route(this, getKeyText(), episode, vod, selectedFlag, () -> inlinePlay(episode))) return; inlinePlay(episode); } private void inlinePlay(Episode episode) { + if (isSamePendingInlinePlayback(episode)) return; cancelPendingInlinePlayback(); playbackSelectionTouched = true; selectedEpisode = episode; @@ -10387,9 +9367,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (isLock()) { setRequestedOrientation(PlaybackOrientation.getScreenOrientation(this)); hideInlineGestureOverlays(); - } else if (inlineManualOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { - // 解锁后回到用户选过的方向,而不是按视频形态重算,否则旋转按钮的效果会被解锁动作抹掉。 - setRequestedOrientation(inlineManualOrientation); } else { setInlineFullscreenOrientation(); } @@ -10399,63 +9376,15 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void rotateInlineFullscreen() { if (!Util.isMobile() || !inlineFullscreen || isLock()) return; touchInlineControls(); - // 方向由用户这次点击决定:先记下期望方向,再请求。随后到来的 VideoSize/STATE_READY - // 会走 applyInlineShortDramaMode 按视频形态重算方向,没有这个标记就会把刚转过去的方向 - // 改回原方向,用户看到的就是「第一次点击完全没反应,第二次才转」。 - inlineManualOrientation = ResUtil.isLand(this) ? ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT : ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; - setRequestedOrientation(inlineManualOrientation); + setRequestedOrientation(ResUtil.isLand(this) ? ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT : ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE); scheduleMobileInlineSideControlMarginUpdate(); } - /** - * 页面自报沉浸态:本页是否处于全屏播放。 - * - *

只认本页自己的 {@code inlineFullscreen} 字段——{@code Util.isFullscreen} 在电视端 - * 恒为 true,拿它当判据会让 TV 上的插件入口永久消失。 - * - *

电视端一律不算沉浸。本页手机与电视共用,而电视上"播放页"本身就是全屏常态, - * 把内嵌播放器算成沉浸态会让插件入口在电视上永远不可用——那是把功能拿掉,不是沉浸。 - * 手机才有"内嵌小窗 ⇄ 全屏"这个切换,隐藏的逻辑只对它成立。 - * - *

画中画不算沉浸:{@code inlinePiPLayout} 与系统 PiP 下本页仍在内嵌形态与用户 - * 交互,入口该留着。PiP 判断读 {@link #inPip} 这个快照——本页 PiP 回调要先把内嵌布局 - * 翻转再重算入口,判据若现场问平台,两次取值可能落在翻转前后不同的时点。 - */ - @Override - public boolean isImmersive() { - if (Util.isLeanback()) return false; - return inlineFullscreen && !inlinePiPLayout && !inPip; - } - - /** - * 页面形态变化后让插件入口重算一次,并把新形态广播给订阅者。 - * - *

广播值取 {@link #isImmersive()} 而不是裸 {@code inlineFullscreen}:入口隐藏判据就是它, - * 两处取不同值会让画中画期间事件报「全屏」而入口仍可见。 - */ - private void refreshPluginEntrances() { - try { - PluginController.get().refreshRender(this); - } catch (Throwable ignored) { - // 插件入口是可选增强:重算失败不该影响播放页。 - } - if (Util.isLeanback()) return; - try { - PluginHostEvents.playbackFullscreen(this, isImmersive()); - } catch (Throwable ignored) { - // 事件是可选增强:广播失败同样不该影响播放页。 - } - } - private void enterInlineFullscreen() { if (inlineFullscreen || inlinePiPLayout || isInPictureInPictureMode()) return; inlineFullscreen = true; - // 进全屏后插件入口要整批消失:先重算再继续铺布局,避免芯片在全屏布局上闪一帧。 - refreshPluginEntrances(); updateDetailThemeButtonVisibility(); requestedOrientation = getRequestedOrientation(); - // 每次进全屏都重建自动方向基线,手动旋转的覆盖到此为止。 - inlineManualOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; // playerPanel 已提升到 root 层,全屏只需铺满整个 root,不做 reparent(避免 SurfaceView 销毁重建导致黑屏)。 applyInlinePlayerFullscreenLayout(); binding.playerPanel.setTranslationY(0f); @@ -10472,15 +9401,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.playerPanel.requestFocus(); Util.toggleFullscreen(this, true); setInlineFullscreenOrientation(); - // 手动点全屏按钮不走 applyInlineShortDramaMode(那条只在 STATE_READY 触发), - // 这里也要按新形态重算手势,否则短剧进全屏后仍是长视频那套轴向。 - syncInlineShortDramaGesture(); scheduleMobileInlineSideControlMarginUpdate(); } private boolean shouldShowDetailFullscreenControlsOnReady() { // 详情直放模式:只在首次准备完成时显示控制栏,快进/后退导致的 STATE_READY 不显示 - return detailPlayerActive && !modeController.isFusionMode() && inlineFullscreen && !isLock() && !inlineFirstReady; + return detailPlayerActive && !isFusionMode() && inlineFullscreen && !isLock() && !inlineFirstReady; } private void applyInlineShortDramaMode() { @@ -10496,37 +9422,21 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } inlineShortDramaMode = true; - syncInlineShortDramaGesture(); setInlineFullscreenOrientation(); setInlineShortDramaVideoFrame(!shouldUseShortDramaPortrait()); setInlinePreviewScale(SHORT_DRAMA_SCALE); hideInlineControls(); } - /** - * 手势轴向跟着呈现形态走:短剧内嵌全屏时整屏上下滑切集、长按后上下滑调亮度/音量。 - *

- * 判据不用 {@code inlineShortDramaMode}:切集时 startInlinePlayback 会先 - * resetInlineShortDramaMode 再等 STATE_READY 重新 apply,那段缓冲窗口里形态并没变, - * 手势却会退回长视频那套,连滑两集时第二次落在侧边就被当成调亮度。 - * shouldUseInlineShortDramaMode 在尺寸未知时返回 true,正好覆盖这段窗口; - * 横屏短剧不走竖屏铺满形态,也就不换手势。 - */ - private void syncInlineShortDramaGesture() { - if (inlineGestureDetector != null) inlineGestureDetector.setShortDrama(inlineFullscreen && shouldUseInlineShortDramaMode()); - } - private void resetInlineShortDramaMode() { boolean restoreScale = inlineShortDramaMode; inlineShortDramaMode = false; - syncInlineShortDramaGesture(); setInlineShortDramaVideoFrame(false); if (restoreScale && inlineStarted) setInlineScale(getInlineScale()); } private void setInlineFullscreenOrientation() { if (!inlineFullscreen) return; - if (inlineManualOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) return; int orientation = getInlineFullscreenOrientation(); if (getRequestedOrientation() != orientation) setRequestedOrientation(orientation); } @@ -10606,8 +9516,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void applyInlinePlayerEmbeddedLayout() { if (binding == null) return; // 融合模式上下 margin (22dp/20dp) 比普通模式 (14dp/16dp) 略大 - int topMarginDp = modeController.isFusionMode() ? 22 : 14; - int bottomMarginDp = modeController.isFusionMode() ? 20 : 16; + int topMarginDp = isFusionMode() ? 22 : 14; + int bottomMarginDp = isFusionMode() ? 20 : 16; // TV 版左右贴边(margin=0),mobile 版左右留白 16dp int horizontalMarginDp = Util.isLeanback() ? 0 : 16; FrameLayout.LayoutParams params; @@ -10649,8 +9559,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. ViewGroup.LayoutParams sp = binding.playerPanelSpacer.getLayoutParams(); if (sp == null) return; int target = ResUtil.dp2px(252); - int topMargin = ResUtil.dp2px(modeController.isFusionMode() ? 22 : 14); - int bottomMargin = ResUtil.dp2px(modeController.isFusionMode() ? 20 : 16); + int topMargin = ResUtil.dp2px(isFusionMode() ? 22 : 14); + int bottomMargin = ResUtil.dp2px(isFusionMode() ? 20 : 16); boolean changed = sp.height != target; if (sp instanceof ViewGroup.MarginLayoutParams marginParams) { if (marginParams.topMargin != topMargin || marginParams.bottomMargin != bottomMargin) { @@ -10731,9 +9641,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean suppressDisplay = suppressInlineDisplayForLeanbackFusionExit(); prepareInlinePlayerTransition(); inlineFullscreen = false; - // 退全屏后把插件入口装回来。必须在 inlineFullscreen 置回 false 之后调,否则 - // attach 仍会认为处于沉浸态而继续不挂。 - refreshPluginEntrances(); setInlineLock(false); // 恢复 root 层内嵌尺寸,不再 reparent applyInlinePlayerEmbeddedLayout(); @@ -10748,7 +9655,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. setRequestedOrientation(requestedOrientation); focusInlinePlayerPanel(); requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; - inlineManualOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; updateDetailThemeButtonVisibility(); } @@ -10824,7 +9730,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. saveInlineHistory(); stopInlinePlaybackSync(); inlinePlaybackGeneration++; - inlinePlaybackSettled = true; + inlinePlaybackLoading = false; + inlinePlayerSwitchLoading = false; introSkipPlayback.reset(); subtitlePlaybackSession.stop(this); hideInlineControls(); @@ -10832,20 +9739,18 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. player().stop(); player().clear(); } + hideInlineLoading(); binding.playerError.setVisibility(View.GONE); updateInlineDisplayPanel(); binding.playerPanel.setVisibility(View.GONE); binding.playerPanelSpacer.setVisibility(View.GONE); // 同步隐藏 spacer inlineStarted = false; - inlinePlaybackPending = false; - detailPlayerFullscreenPending = false; detailPlayerActive = false; pendingInlineResult = null; currentInlineResult = null; inlinePlaybackEpisode = null; inlinePlaybackKey = ""; inlinePlaybackFlag = ""; - inlinePlaybackFlagKey = ""; useParse = false; updateInlineButtons(false); DanmakuApi.cancel(); @@ -10889,12 +9794,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void checkInlineNext(boolean notify) { - advanceInlineEpisode(notify); - } - - /** @return 是否真的切走了。末集/倒序首集切不动,调用方据此决定要不要提示「进入下一集」。 */ - private boolean advanceInlineEpisode(boolean notify) { - return history != null && history.isRevPlay() ? onInlinePrev(notify) : onInlineNext(notify); + if (history != null && history.isRevPlay()) onInlinePrev(notify); + else onInlineNext(notify); } private void checkInlinePrev() { @@ -10902,16 +9803,14 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. else onInlinePrev(true); } - private boolean onInlineNext(boolean notify) { - if (playAdjacentEpisode(1, false)) return true; + private void onInlineNext(boolean notify) { + if (playAdjacentEpisode(1, false)) return; if (notify) Notify.show(history != null && history.isRevPlay() ? R.string.error_play_prev : R.string.error_play_next); - return false; } - private boolean onInlinePrev(boolean notify) { - if (playAdjacentEpisode(-1, false)) return true; + private void onInlinePrev(boolean notify) { + if (playAdjacentEpisode(-1, false)) return; if (notify) Notify.show(history != null && history.isRevPlay() ? R.string.error_play_next : R.string.error_play_prev); - return false; } private void checkInlineEnded(boolean notify) { @@ -11104,47 +10003,28 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. RecyclerView.Adapter adapter = recycler.getAdapter(); if (adapter == null || adapter.getItemCount() == 0) return false; int target = Math.max(0, Math.min(position, adapter.getItemCount() - 1)); - return focusTmdbRecyclerItem(recycler, target, 0); - } - - private boolean focusTmdbRecyclerItem(RecyclerView recycler, int target, int attempt) { - if (binding == null || recycler == null || recycler.getVisibility() != View.VISIBLE) return false; - RecyclerView.Adapter adapter = recycler.getAdapter(); - if (adapter == null || adapter.getItemCount() == 0) return false; - int boundedTarget = Math.max(0, Math.min(target, adapter.getItemCount() - 1)); - if (recycler.isComputingLayout()) { - if (attempt >= 6) return true; - recycler.postOnAnimation(() -> focusTmdbRecyclerItem(recycler, boundedTarget, attempt + 1)); - return true; - } recycler.stopScroll(); - RecyclerView.ViewHolder visibleHolder = recycler.findViewHolderForAdapterPosition(boundedTarget); + RecyclerView.ViewHolder visibleHolder = recycler.findViewHolderForAdapterPosition(target); if (visibleHolder != null) { - boolean requested = visibleHolder.itemView.requestFocus(); - if (!requested) requested = visibleHolder.itemView.requestFocusFromTouch(); - if (requested && getCurrentFocus() == visibleHolder.itemView) return true; - if (attempt >= 6) return true; - recycler.postOnAnimation(() -> focusTmdbRecyclerItem(recycler, boundedTarget, attempt + 1)); + visibleHolder.itemView.requestFocus(); return true; } - if (attempt == 0) recycler.scrollToPosition(boundedTarget); - if (attempt >= 6) return true; - recycler.postOnAnimation(() -> focusTmdbRecyclerItem(recycler, boundedTarget, attempt + 1)); + recycler.scrollToPosition(target); + recycler.post(() -> { + RecyclerView.ViewHolder holder = recycler.findViewHolderForAdapterPosition(target); + if (holder != null) holder.itemView.requestFocus(); + }); return true; } private boolean onDetailEpisodeContainerKey(View focus, KeyEvent event) { if (!KeyUtil.isUpKey(event) && !KeyUtil.isDownKey(event) && !KeyUtil.isLeftKey(event) && !KeyUtil.isRightKey(event)) return false; - if (!KeyUtil.isActionDown(event)) { - pendingDetailEpisodeTarget = RecyclerView.NO_POSITION; - return true; - } + if (!KeyUtil.isActionDown(event)) return true; RecyclerView.ViewHolder holder = binding.episodeContainer.findContainingViewHolder(focus); if (holder == null) return true; int position = holder.getBindingAdapterPosition(); if (position == RecyclerView.NO_POSITION) return true; - if (event.getRepeatCount() == 0) pendingDetailEpisodeTarget = RecyclerView.NO_POSITION; - return moveDetailEpisodeFocus(TmdbEpisodeGridPolicy.holdBasePosition(position, pendingDetailEpisodeTarget), event); + return moveDetailEpisodeFocus(position, event); } private boolean isFocusInside(View focus, View parent) { @@ -11181,10 +10061,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } if (handleInlineSeekKey(event)) return true; if (KeyUtil.isMenuKey(event) && (inlineFullscreen || isInlineControlsVisible())) { - int menuKey = Setting.getFullscreenMenuKey(); if (isInlineControlsVisible()) hideInlineControls(); - else if (menuKey == Setting.FULLSCREEN_MENU_KEY_EPISODES) showInlineEpisodes(); - else if (menuKey == Setting.FULLSCREEN_MENU_KEY_NEXT_EPISODE) checkInlineNext(); + else if (Setting.getFullscreenMenuKey() == 1) showInlineEpisodes(); else showInlineControls(true); return true; } @@ -11347,15 +10225,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (position > 0 && service() != null && player() != null && !player().isEmpty()) player().seekTo(position); } - /** @return 本次是否真的下发了续播 seek——落点还没生效前不能让自动跳过介入。 */ - private boolean applyInlineStartPosition() { - if (inlineStartPositionApplied || history == null || controller() == null) return false; + private void applyInlineStartPosition() { + if (inlineStartPositionApplied || history == null || controller() == null) return; long position = getInlineStartPosition(); inlineStartPositionApplied = true; - if (position <= 0) return false; - introSkipPlayback.setResumePosition(position); - controller().seekTo(position); - return true; + if (position > 0) introSkipPlayback.setResumePosition(position); + if (position > 0) controller().seekTo(position); } @Override @@ -11399,19 +10274,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. inlineControlController.applyDanmakuSetting(); } - private void recordInlinePlayHealth(boolean success, String error) { - if (inlinePlayHealthRecorded) return; - inlinePlayHealthRecorded = true; - SiteHealthStore.recordPlay(TextUtils.isEmpty(inlinePlayHealthKey) ? getKeyText() : inlinePlayHealthKey, success, error); - } - - @Override - protected void onFirstFrameRendered() { - recordInlinePlayHealth(true, ""); - if (!detailPlayerFullscreenPending || !modeController.isPlayerMode() || !inlineStarted || !isOwner()) return; - revealDetailPlayerFullscreen(); - } - @Override protected void onPlayingChanged(boolean isPlaying) { if (!isInlinePlayerMode() || !inlineStarted || !isOwner()) return; @@ -11426,7 +10288,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. super.onPlayWhenReadyChanged(playWhenReady, reason); if (!isInlinePlayerMode() || !inlineStarted || !isOwner()) return; syncInlinePauseInfo(playWhenReady); - updateInlineDisplayPanel(); } @Override @@ -11441,16 +10302,17 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected void onStateChanged(int state) { if (!isInlinePlayerMode()) return; + if (state == Player.STATE_BUFFERING) showInlineLoading(); if (state == Player.STATE_READY) { - // 新集媒体真正就绪:此后播放器读数才属于本集,进度写入重新放行。 - inlinePlayerMediaReady = true; + hideInlineLoading(); hideInlineControls(); player().reset(); - boolean pendingResumeSeekApplied = applyInlineStartPosition(); + applyInlineStartPosition(); updateInlineButtons(player().isPlaying()); + recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线 applyInlineShortDramaMode(); requestIntroSkipPlan(); - if (!pendingResumeSeekApplied) applyAutoIntroSkip(); + applyAutoIntroSkip(); if (shouldShowDetailFullscreenControlsOnReady()) { inlineFirstReady = true; // 标记已显示过控制栏 showInlineControls(true, false); @@ -11488,26 +10350,17 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, @NonNull Configuration newConfig) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); - inPip = isInPictureInPictureMode; updateDetailThemeButtonVisibility(); - if (!isInlinePlayerMode()) { - // 非内嵌形态没有布局翻转,判据只随 inPip 变:这里算一次即可。 - refreshPluginEntrances(); - return; - } + if (!isInlinePlayerMode()) return; if (isInPictureInPictureMode) { hideInlineControls(); hideInlineGestureOverlays(); // 内嵌卡片状态进入 PiP(含 Android 12+ autoEnter / 手势回桌面)必须铺满窗口, // 否则小窗里显示的是整页缩放(卡片圆角、描边、背景图都露出来)。全屏时布局已铺满,enterInlinePiPLayout 内部自带 guard。 enterInlinePiPLayout(); - // 布局翻转之后才重算:isImmersive() 读 inlinePiPLayout,早算会拿到旧形态。 - refreshPluginEntrances(); return; } exitInlinePiPLayout(); - // 同上:先把内嵌布局还回去,再让插件入口按终态重算。 - refreshPluginEntrances(); updateInlineButtons(service() != null && player() != null && !player().isEmpty() && player().isPlaying()); updateInlineDisplayPanel(); updateDetailThemeButtonVisibility(); @@ -11541,7 +10394,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected void onError(String msg) { - recordInlinePlayHealth(false, msg); if (!isInlinePlayerMode() || !inlineStarted || !isOwner()) return; showInlineError(msg); } @@ -11586,14 +10438,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } } - @Subscribe(threadMode = ThreadMode.MAIN) - public void onConfigEvent(ConfigEvent event) { - if (!event.isVod() || isFinishing() || isDestroyed()) return; - tmdbConfig = TmdbConfig.effectiveCurrent(); - tmdbCredentialScope = SubscriptionTmdbCredentialStore.currentScope(); - loadContent(null); - } - @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); @@ -11603,31 +10447,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected void onResume() { super.onResume(); - // PluginEpisodeContext 是全局单槽:播放页发布后本页的快照已被覆盖,而返回本页 - // 不触发任何重新装载。不在这里认领,用户走过一次「详情页 → 播放页 → 返回详情页」, - // 本页的插件 chip 此后永远读不到剧集上下文。 - republishPluginEpisodeContext(); - refreshSelectionAfterExternalPlayback(); if (vod != null && binding.loading.getVisibility() != View.VISIBLE) revealDefaultPlaybackLoadingPage(); scheduleBackdropSlide(BACKDROP_SLIDE_DELAY_MS); } - private void refreshSelectionAfterExternalPlayback() { - if (playbackSelectionTouched || vod == null || vod.getFlags() == null || vod.getFlags().isEmpty()) return; - try { - history = History.findPlayback(getHistoryKey(), List.of(vod.getName(), getNameText()), - vod.getFlags(), null, sourceTitleSeasonNumber()); - if (history == null) return; - selectedFlag = TmdbUIAdapter.selectPlaybackFlag(vod.getFlags(), - history.getSourceBindingKey(), history.getEpisodeUrl(), history.getVodFlag()); - if (selectedFlag == null) return; - selectedEpisode = findEpisodeByUrl(history.getEpisodeUrl(), selectedFlag.getEpisodes()); - renderFlagSelection(); - renderEpisodes(); - } catch (Throwable ignored) { - } - } - @Override protected void onPause() { cancelBackdropSlideRequest(); @@ -11647,11 +10470,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected void onDestroy() { - clearRenderChunks(); - introSkipPlayback.reset(); + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); loadGeneration++; cancelAiSeasonAnalysis(false); - detailPrefetch.cancel(); detailTasks.close(); subtitlePlaybackSession.stop(this); if (inlinePiPLayout) exitInlinePiPLayout(); @@ -11660,7 +10481,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. App.removeCallbacks(inlineHideControls); App.removeCallbacks(inlineKeySeekEnd); EpisodeTitlePopup.dismiss(); - removeCardRowFocusMargin(); saveInlineHistory(); stopInlinePlaybackSync(); // 确保内嵌播放退出时停止播放,避免声音继续(与 VideoActivity 保持一致) @@ -11742,11 +10562,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (!isInlinePlayerMode() || !inlineStarted || !isOwner() || history == null) return; // 保存当前集的播放位置到缓存 - if (!isInlinePlayerSettledOnSelection()) { - // 切集解析窗口期:history 已被 updateInlineHistory 重指向新集,而播放器仍停留在 - // 旧集。此刻写入会把旧集的实时位置记到新集名下,起播时直接跳到上一集看过的位置。 - // onPlay() 在窗口开始前已按旧集完整保存过一次,这里跳过不会丢任何进度。 - } else if (!TextUtils.isEmpty(history.getVodRemarks()) && !skipEpisodePositionCache() && service() != null && player() != null && !player().isReleased()) { + if (!TextUtils.isEmpty(history.getVodRemarks()) && !skipEpisodePositionCache() && service() != null && player() != null && !player().isReleased()) { EpisodePositionCache.get().put( getKeyText(), getIdText(), @@ -11777,167 +10593,39 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (history != null && !Setting.isIncognito()) Task.execute(() -> history.save()); } - @Override - protected void onSubtitleSelected(Sub sub) { - if (SubtitleRestoreCoordinator.remember(history, sub)) persistHistorySubtitleSource(); - } - - /** - * 落盘字幕来源,不重新采样播放器进度。 - * - *

不能用 {@link #syncInlineHistory()}:那个方法会先跑 - * {@code updateInlineHistoryProgress()} 去读播放器当前位置,而恢复发生在起播之前—— - * 那时播放器还停在上一集,读到的进度会被记到新集头上。这里只写 history 对象上 - * 已有的字段快照(进度已由 {@code updateInlineHistory} 按新集重置好)。 - */ - private void persistHistorySubtitleSource() { - if (history == null || Setting.isIncognito()) return; - History snapshot = history.copy(); - Task.execute(snapshot::save); - } - private void updateInlineHistoryProgress() { - if (history == null || service() == null || player() == null || player().isReleased() || !isOwner()) return; + if (history == null || service() == null || player() == null || player().isReleased() || !isOwner()) { + updateInlineHistoryPlayer(); + return; + } updateInlineHistoryProgress(System.currentTimeMillis(), player().getPosition(), player().getDuration()); } private void updateInlineHistoryProgress(long time, long position, long duration) { if (history == null) return; - if (!inlinePlaybackSettled || !isInlinePlayerCurrentMediaReady()) { - // 切集解析窗口期:position/duration 仍来自上一集,写进已指向新集的 history - // 就是「下一集从上一集的位置开播」的直接来源。加载期同理:stop 后到新集 READY - // 前,播放器读数仍是旧集残留。 - history.setCreateTime(time); - return; - } history.setCreateTime(time); if (position > 0) history.setPosition(position); if (duration > 0) history.setDuration(duration); + updateInlineHistoryPlayer(); } - /** 播放器已实际承载本集媒体且进入 READY;期间任何进度写入都可能是旧集残留。 */ - private boolean isInlinePlayerCurrentMediaReady() { - return inlinePlayerMediaReady; - } - - /** - * 内联播放器当前实际承载的媒体是否就是选中的那一集。 - *

- * 沉浸融合/详情直放切集走异步解析:{@code updateInlineHistory} 已把 history 重指向新集, - * 但在解析结果返回并 {@code startInlinePlayer} 之前,播放器仍停留在旧集上继续走秒。 - * 这个窗口内任何把播放器实时进度写回 history 的操作都会把旧集位置记到新集名下, - * 新集起播时会顺着 getInlineResumePosition() 续播到上一集看过的位置。 - *

- * 与 {@link #isCurrentInlinePlayback(Episode)} 的区别:那个还要求「已起播」 - * (inlineStarted 且 currentInlineResult 就绪),本守卫只关心「播放器身份与选中集 - * 一致」,因此同样覆盖 startInlinePlayer 之后、首个 READY 之前的加载段。 - */ - private boolean isInlinePlayerSettledOnSelection() { - return inlinePlaybackSettled && isInlinePlayerCurrentMediaReady(); - } - - /** - * 用户显式换内核后记住选定值。 - * 记的是用户选的值而不是引擎状态:引擎会被播放失败后的自动回退改掉, - * 那不代表用户改了选择;例行的进度同步也一律不碰这个字段, - * 否则上一部剧遗留的会话内核会覆盖本剧记住的选择。 - */ - private void rememberInlinePlayerKernel(int type) { - if (history == null || !PlayerSetting.isPlayer(type)) return; - history.setPlayer(type); - } - - /** 本剧记住的内核;没有记录时退回设置页的全局默认。 */ - private int inlineHistoryPlayerKernel() { - return history == null ? PlayerSetting.getPlayer() : history.getPlayerOrDefault(); + private void updateInlineHistoryPlayer() { + if (history != null && service() != null && player() != null && !player().isReleased()) history.setPlayer(player().getPlayerType()); } private void requestIntroSkipPlan() { - if (!Setting.isIntroSkipEnabled() || player() == null) { + if (!Setting.isAutoSkipIntroOutro() || player() == null) { introSkipPlayback.reset(); - updateInlineOpeningEndingText(); return; } - ensureIntroSkipListeners(); IntroSkipService.Query query = buildIntroSkipQuery(); - // 切集后 plan 已被 reset,这里先刷一次,避免 query 拿不到时残留上一集的探测值 - updateInlineOpeningEndingText(); if (query == null) return; - introSkipPlayback.request(query, this::onIntroSkipPlanLoaded); - } - - private void onIntroSkipPlanLoaded() { - if (isFinishing() || isDestroyed() || !isInlinePlayerMode() || !inlineStarted - || service() == null || player() == null || player().isReleased() || !isOwner()) return; - updateInlineOpeningEndingText(); - applyAutoIntroSkip(); - preloadAdjacentIntroSkipPlans(); - } - - /** - * 预热前后各一集。查询不需要时长(IntroDB 不收,TheIntroDB 可选),这里传 0 即可; - * 缓存按剧集身份存原始段,等那一集真开播时按其实际时长折算,不再走网络。 - */ - private void preloadAdjacentIntroSkipPlans() { - if (!Setting.isIntroSkipEnabled()) return; - TmdbItem item = matchedTmdbItem; - if (item == null || item.getTmdbId() <= 0 || !item.isTv()) return; - if (selectedFlag == null || selectedFlag.getEpisodes() == null || selectedEpisode == null) return; - List episodes = selectedFlag.getEpisodes(); - int index = episodes.indexOf(selectedEpisode); - if (index < 0) return; - String imdbId = introSkipImdbId(); - for (int offset : new int[]{1, -1}) { - int next = index + offset; - if (next < 0 || next >= episodes.size()) continue; - TmdbEpisode tmdbEpisode = episodes.get(next).getTmdbEpisode(); - if (tmdbEpisode == null) continue; - int season = tmdbEpisode.getSeasonNumber(); - int number = tmdbEpisode.getNumber(); - if (season < 0 || number <= 0) continue; - introSkipPlayback.preload(new IntroSkipService.Query(item.getTmdbId(), imdbId, item.getMediaType(), season, number, 0)); - } + introSkipPlayback.request(query, this::applyAutoIntroSkip); } private boolean applyAutoIntroSkip() { - if (!Setting.isIntroSkipEnabled() || isFinishing() || isDestroyed() || !isInlinePlayerMode() - || !inlineStarted || service() == null || player() == null || player().isReleased() || !isOwner()) return false; - // notify=true:片尾无处可跳(末集/电影)时至少要有提示,不能静默无反应 - return introSkipPlayback.apply(player(), () -> advanceInlineEpisode(true)); - } - - /** - * 内嵌播放页原先没接确认监听,确认模式下什么都不会发生。这里补上,与另两个播放页一致。 - */ - private void ensureIntroSkipListeners() { - if (introSkipListenersReady) return; - introSkipListenersReady = true; - introSkipPlayback.setSkipConfirmListener((segment, action) -> { - if (isFinishing() || isDestroyed()) return false; - if (introSkipConfirmDialog != null && introSkipConfirmDialog.isShowing()) return false; - introSkipConfirmDialog = new WebHtvAlertDialogBuilder(this) - .setTitle(R.string.intro_skip_confirm_title) - .setMessage(IntroSkipKinds.confirmMessage(segment)) - .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> introSkipPlayback.declineConfirmation(segment)) - .show(); - introSkipConfirmDialog.setOnDismissListener(dialog -> { - introSkipPlayback.cancelConfirmation(segment); - if (introSkipConfirmDialog == dialog) introSkipConfirmDialog = null; - }); - return true; - }); - introSkipPlayback.setSkipNoticeListener(IntroSkipKinds::notifySkipped); - introSkipPlayback.setSkipConfirmDismisser(this::dismissIntroSkipConfirm); - } - - private void dismissIntroSkipConfirm() { - if (introSkipConfirmDialog == null) return; - try { - introSkipConfirmDialog.dismiss(); - } catch (Throwable ignored) { - } - introSkipConfirmDialog = null; + if (!Setting.isAutoSkipIntroOutro() || player() == null) return false; + return introSkipPlayback.apply(player(), () -> checkInlineEnded(false)); } private IntroSkipService.Query buildIntroSkipQuery() { @@ -11963,15 +10651,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override public void onTimeChanged(long time) { if (!isInlinePlayerMode()) return; - if (!isOwner() || history == null || service() == null || player() == null || player().isEmpty()) return; + updateInlineLoadingTraffic(); + if (inlinePlaybackLoading || !isOwner() || history == null || service() == null || player() == null || player().isEmpty()) return; long position = player().getPosition(); long duration = player().getDuration(); boolean canUpdateProgress = inlineStartPositionApplied || getInlineStartPosition() <= 0; - if (canUpdateProgress) canUpdateProgress = isInlinePlayerSettledOnSelection(); if (canUpdateProgress) { updateInlineHistoryProgress(time, position, duration); } else { history.setCreateTime(time); + updateInlineHistoryPlayer(); } if (canUpdateProgress) PlaybackEventCollector.get().onProgress(history, player()); if (canUpdateProgress && history.canSave() && history.canSync()) syncInlineHistory(); @@ -12000,9 +10689,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override public void onReplay() { if (history != null) history.setPosition(C.TIME_UNSET); - // 同集从头重播也必须开窗:否则窗口期 tick 会把当前实时位置写回 history, - // 起播时 getInlineResumePosition() 又读回同一位置,「重播」退化为「续播」。 - inlinePlaybackSettled = false; playInline(); } }; @@ -12018,14 +10704,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean sameFlag = TextUtils.equals(history.getVodFlag(), selectedFlag.getFlag()); if (inlineStarted && (!sameEpisode || !sameFlag)) stopInlinePlaybackSync(); - // 同集换线路也要开窗:播放器仍承载旧线路的解析结果,窗口期写回会把旧位置 - // 续到新线路的同一集上;开窗后由 startInlinePlayer 或 stopInlinePlayerForReload 关闭。 - if (sameEpisode && !sameFlag) inlinePlaybackSettled = false; if (!sameEpisode) { - // 从这里起到 startInlinePlayer 真正接管播放器为止,播放器仍停留在旧集, - // 而 history 已指向新集:禁止把播放器实时进度写回(见 inlinePlaybackSettled)。 - inlinePlaybackSettled = false; - // 保存当前集的播放位置到缓存(此时 history 仍是旧集身份,写入是安全的) + // 保存当前集的播放位置到缓存 boolean skipCache = skipEpisodePositionCache(); if (!TextUtils.isEmpty(history.getVodRemarks()) && !skipCache && service() != null && player() != null && !player().isReleased()) { EpisodePositionCache.get().put( @@ -12074,215 +10754,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. coalesce(castNames(), vod == null ? "" : vod.getActor()), coalesce(firstCrew("Director"), vod == null ? "" : vod.getDirector()), yearLabel()); - if (modeController.shouldPublishPlaybackHistory()) PlaybackEventCollector.get().updateHistory(history); + if (isFusionMode() || isPlayerMode()) PlaybackEventCollector.get().updateHistory(history); syncDanmakuCompatHistory(); } - private void onFollowing() { - if (!FollowingSettings.isEnabled()) { - Notify.show(R.string.following_enabled_hint); - return; - } - if (followingActionPending) return; - TmdbItem tmdb = followingTmdbItem(); - String siteKey = getKeyText(); - String vodId = getIdText(); - if (TextUtils.isEmpty(siteKey) || TextUtils.isEmpty(vodId)) return; - int season = Math.max(0, currentSeasonSourceScope()); - String identityKey = tmdb == null - ? FollowingIdentity.identityKey(VodConfig.getCid(), siteKey, vodId, season) - : FollowingIdentity.identityKey(tmdb, season); - followingActionPending = true; - setFollowingButtonsEnabled(false); - resolveFollowing(tmdb, identityKey, siteKey, vodId, season, existing -> { - if (isFinishing() || isDestroyed()) return; - if (isFollowed(existing)) { - // 详情页与播放页统一为就地开关:再次点击「已追更」立即取消,绝不跳转追更页。 - cancelFollowing(identityKey); - return; - } - addFollowing(tmdb, siteKey, vodId, season, identityKey); - }); - } - - /** - * 是否处于「已追更」状态。 - *

- * `resolveFollowing` 走 TMDB 身份迁移路径时,为防复活会**故意返回墓碑行** - * (`FollowingStore.resolveTmdb` 的墓碑守卫,C9 语义),因此这里不能只用 `!= null` - * 判断:否则取消追更后按钮仍显示「已追更」,内联播放中再点只会重复写墓碑、 - * 永远无法重新追更。墓碑行必须视为未追更,交给 `addFollowing` 走复活路径。 - */ - private boolean isFollowed(Following item) { - return item != null && !item.isDeleted(); - } - - /** - * 详情页取消追更:写墓碑、取消该条 one-shot 检查,然后立即刷新按钮状态。 - *

- * 无论是否处于内联播放,本页都必须就地生效而不是跳转追更页:跳页既会打断播放, - * 也会把「取消」这种一步操作变成两步,与播放页(mobile/leanback `VideoActivity`)的 - * 就地开关语义保持一致(见 `docs/FOLLOW-1-following-updates-design.md` 8.3.1)。 - */ - private void cancelFollowing(String identityKey) { - FollowingScheduler.cancelNext(this, identityKey); - FollowingPlaybackBridge.deleteAsync(identityKey, error -> { - followingActionPending = false; - if (isFinishing() || isDestroyed()) return; - if (error != null) { - setFollowingButtonsEnabled(true); - Notify.show(error.getMessage()); - return; - } - updateFollowingState(); - FollowingPlaybackBridge.refreshUnreadCountAsync(null); - Notify.show(R.string.following_canceled); - }); - } - - private void addFollowing(TmdbItem tmdb, String siteKey, String vodId, int season, String identityKey) { - long now = System.currentTimeMillis(); - Following item = new Following(); - item.identityKey = identityKey; - item.seriesKey = tmdb == null - ? FollowingIdentity.seriesKey(VodConfig.getCid(), siteKey, vodId) - : FollowingIdentity.seriesKey(tmdb); - item.cid = VodConfig.getCid(); - item.siteKey = FollowingIdentity.normalize(siteKey); - item.vodId = FollowingIdentity.normalize(vodId); - item.vodName = playbackHistoryName(); - item.vodPic = playbackHistoryPic(); - item.mediaType = tmdb == null ? "tv" : FollowingIdentity.normalizeMediaType(tmdb.getMediaType()); - item.tmdbId = tmdb == null ? 0 : tmdb.getTmdbId(); - item.trackedSeason = season; - item.trackedEpisode = history == null ? 0 : Math.max(0, history.getTmdbEpisodeNumber()); - item.watchedSeason = history == null ? 0 : Math.max(0, history.getTmdbSeasonNumber()); - item.watchedEpisode = history == null ? 0 : Math.max(0, history.getTmdbEpisodeNumber()); - item.position = history == null ? 0 : Math.max(0, history.getPosition()); - item.duration = history == null ? 0 : Math.max(0, history.getDuration()); - item.notifyEnabled = FollowingSettings.isNotificationsEnabled(); - item.enabled = true; - item.createdAt = now; - item.updatedAt = now; - item.nextCheckAt = now; - applyInitialFollowingMetadata(item); - FollowingUpdatePolicy.initializeNew(item, FollowingUpdatePolicy.releasedEpisode(item), now); - item.nextCheckAt = now; - FollowingSource source = new FollowingSource(); - source.followingKey = identityKey; - source.cid = item.cid; - source.siteKey = item.siteKey; - source.vodId = item.vodId; - source.vodName = item.vodName; - source.vodPic = item.vodPic; - source.vodFlag = selectedFlag == null ? "" : selectedFlag.getFlag(); - source.playableSeason = Math.max(0, season); - source.preferred = true; - FollowingPlaybackBridge.addAsync(item, source, (saved, error) -> { - followingActionPending = false; - if (isFinishing() || isDestroyed()) return; - if (error != null) { - setFollowingButtonsEnabled(true); - Notify.show(error.getMessage()); - return; - } - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); - updateFollowingState(); - Notify.show(R.string.following_added); - }); - } - - private TmdbItem followingTmdbItem() { - TmdbItem item = matchedTmdbItem != null ? matchedTmdbItem : initialTmdbItem; - return item != null && item.isTv() && item.getTmdbId() > 0 ? item : null; - } - - private void applyInitialFollowingMetadata(Following item) { - TmdbEpisodeInfo info = tmdbEpisodeInfo(); - if (info == null || info.isEmpty()) return; - item.officialStatus = followingStatus(info); - item.seriesTotalEpisodes = info.getTotalEpisodes(); - item.seasonTotalEpisodes = info.getScopedTotalEpisodes(); - item.seasonReleasedEpisodes = info.getScopedAiredEpisodes(); - if (info.getLastSeason() == item.trackedSeason) item.latestReleasedEpisode = info.getLastEpisode(); - else item.latestReleasedEpisode = info.getScopedAiredEpisodes(); - item.latestReleasedSeason = info.getLastSeason(); - item.metadataUpdatedAt = System.currentTimeMillis(); - } - - private String followingStatus(TmdbEpisodeInfo info) { - if (info == null) return FollowingMetadataSnapshot.UNKNOWN; - return switch (info.getState()) { - case ONGOING -> FollowingMetadataSnapshot.RETURNING; - case PLANNED -> FollowingMetadataSnapshot.PLANNED; - case COMPLETE -> FollowingMetadataSnapshot.ENDED; - case CANCELED -> FollowingMetadataSnapshot.CANCELED; - default -> FollowingMetadataSnapshot.UNKNOWN; - }; - } - - private FollowingMetadataSnapshot followingSnapshot() { - TmdbEpisodeInfo info = tmdbEpisodeInfo(); - if (info == null || info.isEmpty()) return null; - FollowingMetadataSnapshot snapshot = new FollowingMetadataSnapshot(); - snapshot.source = "tmdb"; - snapshot.status = followingStatus(info); - snapshot.latestReleasedSeason = Math.max(0, info.getLastSeason()); - snapshot.latestReleasedEpisode = Math.max(0, info.getLastEpisode()); - snapshot.seasonTotalEpisodes = Math.max(0, info.getScopedTotalEpisodes()); - snapshot.seasonReleasedEpisodes = Math.max(0, info.getScopedAiredEpisodes()); - snapshot.seriesTotalEpisodes = Math.max(0, info.getTotalEpisodes()); - snapshot.fetchedAt = System.currentTimeMillis(); - return snapshot; - } - - private void resolveFollowing(TmdbItem tmdb, String identityKey, String siteKey, String vodId, int season, - java.util.function.Consumer callback) { - if (tmdb == null) FollowingPlaybackBridge.findAsync(identityKey, callback); - else FollowingPlaybackBridge.resolveTmdbAsync(tmdb, season, VodConfig.getCid(), siteKey, vodId, followingSnapshot(), callback); - } - - private void updateFollowingState() { - boolean enabled = FollowingSettings.isEnabled(); - boolean eligible = enabled && (followingTmdbItem() != null || (vod != null && !vod.getFlags().isEmpty())); - int generation = ++followingUiGeneration; - if (!eligible) { - applyFollowingButtonState(false, false); - return; - } - TmdbItem tmdb = followingTmdbItem(); - int season = Math.max(0, currentSeasonSourceScope()); - String identityKey = tmdb == null - ? FollowingIdentity.identityKey(VodConfig.getCid(), getKeyText(), getIdText(), season) - : FollowingIdentity.identityKey(tmdb, season); - applyFollowingButtonState(true, false); - resolveFollowing(tmdb, identityKey, getKeyText(), getIdText(), season, item -> { - if (generation != followingUiGeneration || isFinishing() || isDestroyed()) return; - applyFollowingButtonState(true, isFollowed(item)); - }); - } - - private void applyFollowingButtonState(boolean eligible, boolean followed) { - String text = getString(followed ? R.string.following_added : R.string.following_add); - binding.following.setVisibility(eligible ? View.VISIBLE : View.GONE); - binding.followingTop.setVisibility(View.GONE); - binding.followingFusion.setVisibility(eligible ? View.VISIBLE : View.GONE); - binding.following.setText(text); - binding.followingTop.setText(text); - binding.followingFusion.setText(text); - binding.following.setSelected(followed); - binding.followingTop.setSelected(followed); - binding.followingFusion.setSelected(followed); - setFollowingButtonsEnabled(true); - } - - private void setFollowingButtonsEnabled(boolean enabled) { - binding.following.setEnabled(enabled); - binding.followingTop.setEnabled(enabled); - binding.followingFusion.setEnabled(enabled); - } - private void onKeep() { Keep keep = Keep.find(getHistoryKey()); if (keep != null) keep.delete(); @@ -12479,7 +10954,6 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void wireInlineQuickSearchExtras(Class dialogClass, Object dialog) { invokeQuiet(dialog, "title", new Class[]{String.class}, getString(R.string.play_search) + " " + inlineSearchKeyword); invokeQuiet(dialog, "keyword", new Class[]{String.class}, inlineSearchKeyword); - invokeQuiet(dialog, "currentSiteKey", new Class[]{String.class}, getKeyText()); // 手机版:弹层内改关键词重搜 + 自有 dismiss 接口 bindProxyListener(dialogClass, dialog, "searchListener", dialogClass.getName() + "$OnSearchListener", args -> restartInlineSourceSearch(args == null || args.length != 1 ? "" : String.valueOf(args[0]))); @@ -12589,7 +11063,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void openMatchedDetail(Site site, Vod match, TmdbItem item) { - if (modeController.isFusionMode()) { + if (isFusionMode()) { switchSourceDetail(site, match, item, ""); return; } @@ -12636,7 +11110,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. TmdbBundle reusableBundle = canReuseTmdbBundle(item) ? activeTmdbBundle : null; Intent intent = new Intent(getIntent()); intent.putExtra("detail_mode", getDetailMode()); - intent.putExtra("fusion", modeController.isFusionMode()); + intent.putExtra("fusion", isFusionMode()); intent.putExtra("key", site.getKey()); intent.putExtra("id", match.getId()); intent.putExtra("name", match.getName()); @@ -12898,18 +11372,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. TmdbCandidate second = null; int count = Math.min(6, candidates.size()); logTmdbMatch("详情消歧开始拉取候选详情:候选数量=%d,拉取上限=%d", candidates.size(), count); - JsonObject[] details = fetchDisambiguationDetails(candidates, count); for (int i = 0; i < count; i++) { TmdbCandidate candidate = candidates.get(i); - JsonObject detail = details[i]; - if (detail == null) continue; try { - int peopleScore = hasSourcePeople(sourceVod) ? scoreTmdbPeople(detail, sourceVod) : 0; - int splitSeasonScore = TmdbMatchPolicy.splitSeasonDetailScore(tmdbMatchSourceText(keyword, sourceVod), detail); + TmdbBundle bundle = loadTmdbBundle(candidate.item); + int peopleScore = hasSourcePeople(sourceVod) ? scoreTmdbPeople(bundle.detail(), sourceVod) : 0; + int splitSeasonScore = TmdbMatchPolicy.splitSeasonDetailScore(tmdbMatchSourceText(keyword, sourceVod), bundle.detail()); int score = candidate.score + peopleScore + splitSeasonScore; logTmdbMatch("详情消歧候选:标题=%s,年份=%d,基础分=%d,演员主创分=%d,分季分=%d,总分=%d", candidate.item.getTitle(), tmdbItemYear(candidate.item), candidate.score, peopleScore, splitSeasonScore, score); - TmdbCandidate scored = new TmdbCandidate(candidate.item, candidate.titleScore, score, detail); + TmdbCandidate scored = new TmdbCandidate(candidate.item, candidate.titleScore, score, bundle); if (best == null || scored.score > best.score) { second = best; best = scored; @@ -12917,10 +11389,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. second = scored; } } catch (Throwable ignored) { - logTmdbMatch("详情消歧候选打分失败:标题=%s,错误=%s", candidate.item.getTitle(), ignored.getMessage()); + logTmdbMatch("详情消歧候选加载失败:标题=%s,错误=%s", candidate.item.getTitle(), ignored.getMessage()); } } - if (best == null || best.detail == null) { + if (best == null || best.bundle == null) { logTmdbMatch("详情消歧结束:没有成功加载的候选详情"); return Setting.isTmdbSmartMatch() ? chooseSmartTmdbBundle(items, keyword, sourceVod) : null; } @@ -12928,53 +11400,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean accepted = best.score >= 420 && gap >= 50; logTmdbMatch("详情消歧判定:最佳=%s(%d年, 分=%d),第二=%s,分差=%d,结果=%s", best.item.getTitle(), tmdbItemYear(best.item), best.score, second == null ? "无" : second.item.getTitle() + "(" + second.score + ")", gap, accepted ? "通过" : "不通过"); - if (accepted) return loadDisambiguationWinner(best.item); + if (accepted) return best.bundle; return Setting.isTmdbSmartMatch() ? chooseSmartTmdbBundle(items, keyword, sourceVod) : null; } - /** - * 并行拉取消歧候选的 lite 详情,按候选原顺序返回(失败为 {@code null})。 - *

- * 每次最多 {@link #DISAMBIGUATION_CONCURRENCY} 个在途请求;打分仍按候选原顺序串行执行, - * 因此排序、分差判定与改造前完全一致。lite 详情只含 {@code credits}/{@code aggregate_credits}, - * 人物打分输入不变。 - */ - private JsonObject[] fetchDisambiguationDetails(List candidates, int count) { - JsonObject[] details = new JsonObject[count]; - for (int start = 0; start < count; start += DISAMBIGUATION_CONCURRENCY) { - int end = Math.min(count, start + DISAMBIGUATION_CONCURRENCY); - List> futures = new ArrayList<>(end - start); - for (int i = start; i < end; i++) { - TmdbItem item = candidates.get(i).item; - futures.add(detailTasks.submitCallable(Task.largeExecutor(), - () -> tmdbService.detailForDisambiguation(item, tmdbConfig))); - } - for (int i = start; i < end; i++) { - TmdbItem item = candidates.get(i).item; - try { - details[i] = futures.get(i - start).get(); - } catch (Throwable e) { - logTmdbMatch("详情消歧候选加载失败:标题=%s,错误=%s", item.getTitle(), e.getMessage()); - } - } - } - return details; - } - - /** - * 消歧阶段只拿到 lite 详情;最终赢家必须回到完整详情(images/translations/recommendations 等) - * 才能渲染详情页。完整详情多命中一次 7 天文件缓存时不会产生网络请求。 - */ - @Nullable - private TmdbBundle loadDisambiguationWinner(TmdbItem item) { - try { - return loadTmdbBundle(item); - } catch (Throwable e) { - logTmdbMatch("详情消歧赢家完整详情加载失败:标题=%s,错误=%s", item.getTitle(), e.getMessage()); - return null; - } - } - private List scoreTmdbCandidates(List items, String keyword, @Nullable Vod sourceVod) { List candidates = new ArrayList<>(); if (items == null || items.isEmpty()) return candidates; @@ -13022,21 +11451,19 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. List scored = new ArrayList<>(); int count = Math.min(6, candidates.size()); logTmdbMatch("智能匹配开始拉取候选详情:关键词=%s,候选数量=%d,拉取上限=%d", keyword, candidates.size(), count); - JsonObject[] details = fetchDisambiguationDetails(candidates, count); for (int i = 0; i < count; i++) { TmdbCandidate candidate = candidates.get(i); - JsonObject detail = details[i]; - if (detail == null) continue; try { - int detailScore = scoreTmdbCountry(detail, sourceVod) - + scoreTmdbPeople(detail, sourceVod) - + TmdbMatchPolicy.splitSeasonDetailScore(tmdbMatchSourceText(keyword, sourceVod), detail); + TmdbBundle bundle = loadTmdbBundle(candidate.item); + int detailScore = scoreTmdbCountry(bundle.detail(), sourceVod) + + scoreTmdbPeople(bundle.detail(), sourceVod) + + TmdbMatchPolicy.splitSeasonDetailScore(tmdbMatchSourceText(keyword, sourceVod), bundle.detail()); int score = candidate.score + detailScore; logTmdbMatch("智能匹配候选:标题=%s,媒体=%s,年份=%d,基础分=%d,详情分=%d,总分=%d", candidate.item.getTitle(), candidate.item.getMediaType(), tmdbItemYear(candidate.item), candidate.score, detailScore, score); - scored.add(new TmdbCandidate(candidate.item, candidate.titleScore, score, detail)); + scored.add(new TmdbCandidate(candidate.item, candidate.titleScore, score, bundle)); } catch (Throwable ignored) { - logTmdbMatch("智能匹配候选打分失败:标题=%s,错误=%s", candidate.item.getTitle(), ignored.getMessage()); + logTmdbMatch("智能匹配候选加载失败:标题=%s,错误=%s", candidate.item.getTitle(), ignored.getMessage()); } } if (scored.isEmpty()) { @@ -13051,7 +11478,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean accepted = best.score >= smartAcceptScore(sourceYear) && (gap >= 25 || isUniqueNewest(best, scored, sourceYear)); logTmdbMatch("智能匹配判定:最佳=%s(%d年, 分=%d),第二=%s,分差=%d,结果=%s", best.item.getTitle(), tmdbItemYear(best.item), best.score, second == null ? "无" : second.item.getTitle() + "(" + second.score + ")", gap, accepted ? "通过" : "不通过"); - return accepted ? loadDisambiguationWinner(best.item) : null; + return accepted ? best.bundle : null; } private List scoreSmartTmdbCandidates(List items, String keyword, @Nullable Vod sourceVod) { @@ -13263,12 +11690,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private int scoreTmdbMediaType(TmdbItem item, @Nullable Vod sourceVod) { // 类型只做轻量加减分,不作为放宽条件:标题/年份不一致的候选不会走到这里。 if (sourceVod == null) return 0; - // 站源类型名在繁体界面已被 Vod.trans() 转成繁体。这里先做简繁归一(压成繁体)再判断, - // 关键词也只写繁体形:归一后简体「电视剧」与繁体「電視劇」落到同一个串,两种界面都能命中。 String type = normalize(sourceVod.getTypeName() + " " + sourceVod.getRemarks()); if (TextUtils.isEmpty(type)) return 0; - boolean tv = type.contains("劇") || type.contains("集") || type.contains("連續") || type.contains("電視劇"); - boolean movie = type.contains("電影") || type.contains("影片"); + boolean tv = type.contains("剧") || type.contains("集") || type.contains("连续") || type.contains("电视剧"); + boolean movie = type.contains("电影") || type.contains("影片"); if (tv && "tv".equalsIgnoreCase(item.getMediaType())) return 20; if (movie && "movie".equalsIgnoreCase(item.getMediaType())) return 20; if ((tv && "movie".equalsIgnoreCase(item.getMediaType())) || (movie && "tv".equalsIgnoreCase(item.getMediaType()))) return -30; @@ -13436,27 +11861,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private String normalize(String text) { - return plainNormalize(canonicalTitle(text)); - } - - /** - * 只做标点/空白/大小写归一,不做简繁转换。噪声词表与类型关键词都是简体字形, - * 一旦吃到简繁归一就会整体失配,因此这些判定必须继续走这里。 - */ - private String plainNormalize(String text) { return Objects.toString(text, "").replaceAll("[\\s·•::\\-_/\\\\|()()\\[\\]【】]+", "").trim().toLowerCase(Locale.ROOT); } - /** - * 站源标题在繁体界面会被 Vod.trans() 逐字转繁,而 TMDB 返回的标题字形由 TmdbConfig.language - * 决定(默认 zh-CN,简体)。两侧字形不同时逐字符比较必然失败,所以比较前统一压到繁体一侧。 - * 方向只能单向转繁:t2s 存在 10 组「多简对一繁」坍缩,会把余罪/强势/线上等常用简体词改坏。 - * 必须显式传 pass=false,Trans.s2t(String) 在简体态会短路返回原文而假绿。 - */ - private static String canonicalTitle(String text) { - return Trans.s2t(false, Objects.toString(text, "")); - } - private String cleanTmdbSearchQuery(String text) { String raw = Objects.toString(text, "").trim(); if (TextUtils.isEmpty(raw)) return ""; @@ -13473,7 +11880,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. cleaned = cleaned.replaceAll("(?i)(? flags) { @@ -13577,15 +11983,13 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. button.setMinimumHeight(0); button.setInsetTop(0); button.setInsetBottom(0); - button.setInsetLeft(0); - button.setInsetRight(0); button.setMaxWidth(ResUtil.dp2px(CHIP_MAX_WIDTH_DP)); button.setEllipsize(TextUtils.TruncateAt.MARQUEE); button.setMarqueeRepeatLimit(-1); button.setHorizontallyScrolling(true); button.setSingleLine(true); button.setTextColor(getColor(android.R.color.white)); - applyChipPadding(button); + button.setPadding(24, 12, 24, 12); FlexboxLayout.LayoutParams params = new FlexboxLayout.LayoutParams(FlexboxLayout.LayoutParams.WRAP_CONTENT, FlexboxLayout.LayoutParams.WRAP_CONTENT); params.setMargins(0, 0, 12, 12); button.setLayoutParams(params); @@ -13595,14 +11999,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void setChipState(MaterialButton button, boolean selected) { ThemeColors colors = lightTheme ? ThemeColors.light() : ThemeColors.dark(); button.setTextColor(colors.primary); - // 必须走 backgroundTint 通道,不能用 setBackgroundColor。 - // setBackgroundColor 只把颜色写进当前那个 MaterialShapeDrawable 实例, - // 而 Material 之后会因 inset/圆角/测量变化重建背景,重建时用的是 - // backgroundTint 字段(这些代码新建的按钮该字段仍是透明),于是填充被 - // 悄悄丢回全透明——线路与选集 chip 因此只剩描边、没有底色,压在亮色剧照 - // 上就看不清。setBackgroundTintList 写的是那个持久字段,重建后依然生效, - // 这也是同一页里选集「第 N 季」按钮一直有底色的原因。 - button.setBackgroundTintList(ColorStateList.valueOf(selected ? colors.chipActive : colors.chip)); + button.setBackgroundColor(selected ? colors.chipActive : colors.chip); button.setOnFocusChangeListener(null); applyChipFocus(button, selected, button.hasFocus(), colors); button.setOnFocusChangeListener((view, focused) -> applyChipFocus(button, selected, focused, colors)); @@ -13610,16 +12007,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void applyChipFocus(MaterialButton button, boolean selected, boolean focused, ThemeColors colors) { button.setSelected(!Util.isLeanback() || selected || focused); - button.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(selected ? 2 : CHIP_STROKE_DP)); - button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : (selected ? colors.accent : colors.line))); - // 描边/底色只改 MaterialShapeDrawable,不会重写 View padding(material 1.14.0 里只有 - // setInsets/updateButtonShape/loadFromAttributes 会调动它),所以这里无需再回写内边距。 - } - - private void applyChipPadding(MaterialButton button) { - int horizontal = ResUtil.dp2px(CHIP_PADDING_H_DP); - int vertical = ResUtil.dp2px(CHIP_PADDING_V_DP); - button.setPadding(horizontal, vertical, horizontal, vertical); + button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : (selected ? 2 : CHIP_STROKE_DP))); + button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : (selected ? colors.accent : colors.line))); } private void styleMetaChips() { @@ -14051,6 +12440,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. Objects.toString(getIntent().getStringExtra("tmdb_credit"), "")); } + private record TmdbBundle(TmdbItem item, JsonObject detail, List cast, List creators, List photos, List related, List seasons, Map seasonCounts, Map> seasonEpisodes, Map> seasonCast, Map> seasonPhotos) { + } + private record TmdbLoadResult(TmdbBundle bundle, List searchItems) { } @@ -14086,8 +12478,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } } - /** 消歧候选的 lite 详情(只用于打分);赢家再取完整详情渲染。 */ - private record TmdbCandidate(TmdbItem item, int titleScore, int score, @Nullable JsonObject detail) { + private record TmdbCandidate(TmdbItem item, int titleScore, int score, @Nullable TmdbBundle bundle) { } private record RatingChipTag(String platform, int color) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbPersonActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbPersonActivity.java index 84c20de187..647c4513a4 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbPersonActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbPersonActivity.java @@ -28,7 +28,6 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; import com.bumptech.glide.Glide; @@ -46,8 +45,6 @@ import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.databinding.ActivityTmdbPersonBinding; import com.fongmi.android.tv.service.TmdbService; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.TmdbPersonPhotoAdapter; import com.fongmi.android.tv.ui.adapter.TmdbWorkAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; @@ -59,6 +56,7 @@ import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.utils.TmdbImageSaver; import com.fongmi.android.tv.utils.Util; import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -72,6 +70,7 @@ import java.util.Set; public class TmdbPersonActivity extends BaseActivity { + private static final int FOCUS_STROKE = 0xFFFFD166; private static final int FOCUS_STROKE_DP = 3; private static final int PHOTO_STROKE_DP = 1; @@ -115,7 +114,7 @@ public class TmdbPersonActivity extends BaseActivity { @Override protected void initView(Bundle savedInstanceState) { - tmdbConfig = TmdbConfig.effectiveCurrent(); + tmdbConfig = TmdbConfig.objectFrom(Setting.getTmdbConfig()); siteKey = getIntent().getStringExtra("site_key"); detailMode = normalizeDetailMode(getIntent().getIntExtra("detail_mode", Setting.getDetailOpenMode())); light = resolveLightTheme(); @@ -135,93 +134,6 @@ public class TmdbPersonActivity extends BaseActivity { binding.filterTv.setOnClickListener(view -> setFilter("tv")); } - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - if (Util.isLeanback() && binding != null && event != null && event.getAction() == KeyEvent.ACTION_DOWN) { - View focus = getCurrentFocus(); - if (focus != null && isFocusInside(binding.filterGroup, focus)) { - if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) return moveFilterFocus(focus, true); - if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) return moveFilterFocus(focus, false); - if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_DOWN && focusFirstWork()) return true; - } else if (focus != null && isFirstWorkFocused(focus) - && event.getKeyCode() == KeyEvent.KEYCODE_DPAD_UP && focusSelectedFilter()) { - return true; - } - } - return super.dispatchKeyEvent(event); - } - - private boolean moveFilterFocus(View focus, boolean left) { - int index = binding.filterGroup.indexOfChild(focus); - if (index < 0) return false; - int targetIndex = index + (left ? -1 : 1); - if (targetIndex < 0 || targetIndex >= binding.filterGroup.getChildCount()) return true; - View target = binding.filterGroup.getChildAt(targetIndex); - if (target == null || !target.isFocusable()) return true; - target.requestFocus(left ? View.FOCUS_LEFT : View.FOCUS_RIGHT); - scrollFilterIntoView(target); - return true; - } - - private boolean focusFirstWork() { - if (workAdapter.getItemCount() == 0) return false; - RecyclerView.LayoutManager layoutManager = binding.works.getLayoutManager(); - View target = layoutManager == null ? null : layoutManager.findViewByPosition(0); - if (target != null) return target.requestFocus(); - binding.works.scrollToPosition(0); - return binding.works.post(() -> { - RecyclerView.LayoutManager manager = binding.works.getLayoutManager(); - View first = manager == null ? null : manager.findViewByPosition(0); - if (first != null) first.requestFocus(); - }); - } - - private boolean isFirstWorkFocused(View focus) { - View item = focus; - while (item != null && item.getParent() instanceof View && item.getParent() != binding.works) { - item = (View) item.getParent(); - } - return item != null && item.getParent() == binding.works && binding.works.getChildAdapterPosition(item) == 0; - } - - private boolean focusSelectedFilter() { - View target = null; - for (int i = 0; i < binding.filterGroup.getChildCount(); i++) { - View child = binding.filterGroup.getChildAt(i); - if (child.getVisibility() == View.VISIBLE && filter.equals(filterKey(child))) { - target = child; - break; - } - } - if (target == null && binding.filterGroup.getChildCount() > 0) { - target = binding.filterGroup.getChildAt(0); - } - if (target == null || !target.isFocusable() || !target.requestFocus()) return false; - scrollFilterIntoView(target); - return true; - } - - private boolean isFocusInside(ViewGroup parent, View focus) { - for (View current = focus; current != null; current = current.getParent() instanceof View ? (View) current.getParent() : null) { - if (current == parent) return true; - } - return false; - } - - private String filterKey(View button) { - if (button == binding.filterAll) return "all"; - if (button == binding.filterCast) return "cast"; - if (button == binding.filterCrew) return "crew"; - if (button == binding.filterDirector) return "director"; - if (button == binding.filterMovie) return "movie"; - if (button == binding.filterTv) return "tv"; - return ""; - } - - private void scrollFilterIntoView(View child) { - binding.filterScroll.post(() -> binding.filterScroll.smoothScrollTo(Math.max(0, child.getLeft() - dp(12)), 0)); - } - private void setInitialPerson() { binding.name.setText(textExtra("person_name")); binding.subtitle.setVisibility(View.GONE); @@ -428,14 +340,6 @@ public class TmdbPersonActivity extends BaseActivity { for (int i = 0; i < count; i++) { View child = binding.filterGroup.getChildAt(i); if (child instanceof MaterialButton button) { - button.setFocusable(true); - button.setFocusableInTouchMode(false); - button.setNextFocusLeftId(i == 0 ? button.getId() : binding.filterGroup.getChildAt(i - 1).getId()); - button.setNextFocusRightId(i == count - 1 ? button.getId() : binding.filterGroup.getChildAt(i + 1).getId()); - button.setOnFocusChangeListener((view, focused) -> { - updateFilters(); - if (focused) scrollFilterIntoView(view); - }); button.setMinWidth(0); button.setMinimumWidth(0); button.setInsetLeft(0); @@ -592,14 +496,13 @@ public class TmdbPersonActivity extends BaseActivity { private void updateFilter(MaterialButton button, String value) { boolean selected = filter.equals(value); - boolean focused = button.hasFocus(); int bg = selected ? (light ? 0xFFDBEAFE : 0xFF2F4F6F) : (light ? 0xFFF5F8FB : 0xFF1A2530); int fg = light ? 0xFF12202D : 0xFFFFFFFF; - int stroke = focused ? ThemeController.focusRingColor(this) : selected ? 0xFF6DA8E8 : (light ? 0x33424B57 : 0x33FFFFFF); + int stroke = selected ? 0xFF6DA8E8 : (light ? 0x33424B57 : 0x33FFFFFF); button.setTextColor(fg); button.setBackgroundTintList(ColorStateList.valueOf(bg)); button.setStrokeColor(ColorStateList.valueOf(stroke)); - button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : selected ? 2 : 1)); + button.setStrokeWidth(selected ? 2 : 1); } private void setThemeColors() { @@ -834,11 +737,11 @@ public class TmdbPersonActivity extends BaseActivity { private void applyPhotoButtonFocus(MaterialButton button, boolean focused) { button.setBackgroundTintList(ColorStateList.valueOf(focused ? 0x33FFFFFF : 0x18FFFFFF)); button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : PHOTO_STROKE_DP)); - button.setStrokeColor(ColorStateList.valueOf(focused ? ThemeController.focusRingColor(this) : 0x4DFFFFFF)); + button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : 0x4DFFFFFF)); } private void showPhotoActionDialog(String url) { - new WebHtvAlertDialogBuilder(this) + new MaterialAlertDialogBuilder(this) .setItems(new CharSequence[]{getString(R.string.detail_image_save)}, (dialog, which) -> savePhoto(url, null)) .show(); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBinding.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBinding.java deleted file mode 100644 index 885b3ad636..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBinding.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import androidx.annotation.Nullable; - -/** - * 把「当前这个引擎该不该挂到 PlayerView 上」从 {@code PlaybackActivity#attachSurface(boolean)} 里 - * 拆出来,变成一个可单测的纯判定。 - * - *

背景(设备日志 {@code webhtv-debug-log (27).txt},2026-10-21 21:50):自动连播时服务侧 - * {@code PlaybackService#startBrowse} 用「浏览下一集」的 {@code VE:...} 键起播,Activity 侧 - * {@code isOwner()} 随之为假,重建回调 {@code onPlayerRebuild()} 整段被跳过;于是 - * {@code attachSurface()} 既没走 {@code render != targetRender} 的重绑分支,也没走「引擎实例已不同」 - * 的强制重绑分支,新引擎从未拿到 Surface —— 解码器可初始化、能到 READY、音频正常推进,但视频帧 - * 零提交(全黑有声)。手工重开 App 会重建整页与整条绑定链,所以「重开就正常」。 - * - *

结论:重绑条件不能依赖「Activity 是否自认归属」,只能依赖「PlayerView 上挂着的引擎实例是否 - * 就是当前引擎」。本类只用实例身份回答这一个问题,不关心引擎类型,因此可被单元测试直接证伪。 - */ -final class VideoSurfaceBinding { - - private VideoSurfaceBinding() { - } - - /** - * 一次绑定判定的结果。 - * - * @param applied 是否需要在视图上执行绑定/解绑 - * @param detachedFirst 是否需要先把旧引擎解绑再绑新引擎(旧引擎非空时为真,保证顺序确定) - * @param player 判定后视图应当挂着的引擎实例 - */ - record Result(boolean applied, boolean detachedFirst, @Nullable T player) { - - static Result skipped(@Nullable T player) { - return new Result<>(false, false, player); - } - - static Result rebound(@Nullable T player, boolean detachedFirst) { - return new Result<>(true, detachedFirst, player); - } - } - - /** - * 判定是否把 {@code next} 绑到视图上。 - * - * @param current 视图当前挂着的引擎实例(可能已被 {@code release()},但仍非 null) - * @param next 当前会话应当挂着的引擎实例 - */ - static Result resolve(@Nullable T current, @Nullable T next) { - // 实例身份比较:口径是「视图上挂着的引擎是不是当前引擎」。这里刻意不用 equals, - // 免得将来某个 Player 实现自定义相等语义,把两个不同实例误判为同一个而漏绑。 - if (current == next) return Result.skipped(next); - return Result.rebound(next, current != null); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/BaseDiffAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/BaseDiffAdapter.java index fe473b8b91..0c535dffe8 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/BaseDiffAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/adapter/BaseDiffAdapter.java @@ -17,38 +17,10 @@ public abstract class BaseDiffAdapter, VH extends Recycler protected final AsyncListDiffer differ; - /** - * 列表数量变化后的回调。 - * - *

为什么放在这里。本仓库反复出现过同一类缺陷(共 5 处):删除/清空之后宿主忘了 - * 驱动状态机,于是列表空了但页面留白——连空态都不出现。根因是"驱动状态机"这件事被留给了 - * 每个调用点,而调用点很容易漏(单条删除、删除全部、删除后又刷新,各是一个入口)。 - * - *

所有变更最终都经过 {@code setItems(...)},而它的回调在 diff **应用完成**后才触发 - * ({@code AsyncListDiffer} 是异步的,只有那时 {@code getItemCount()} 才是新值)。 - * 因此把回调挂在这里:宿主只需注册一次,之后无论从哪条路径改动列表, - * 状态都会跟着列表走——不可能再忘。 - */ - public interface OnCountChanged { - void onCountChanged(int count); - } - - private OnCountChanged onCountChanged; - public BaseDiffAdapter() { this.differ = new AsyncListDiffer<>(this, new BaseItemCallback()); } - /** 注册列表数量变化回调;传 {@code null} 取消注册。 */ - public void setOnCountChanged(OnCountChanged listener) { - this.onCountChanged = listener; - } - - private void afterUpdate(Runnable runnable) { - if (runnable != null) runnable.run(); - if (onCountChanged != null) onCountChanged.onCountChanged(getItemCount()); - } - private boolean listsAreSame(List oldList, List newList) { if (oldList.size() != newList.size()) return false; for (int i = 0; i < oldList.size(); i++) { @@ -72,20 +44,15 @@ public abstract class BaseDiffAdapter, VH extends Recycler } public void setItems(List items, Runnable runnable) { - differ.submitList(Objects.requireNonNullElseGet(items, ArrayList::new), () -> afterUpdate(runnable)); + differ.submitList(Objects.requireNonNullElseGet(items, ArrayList::new), runnable); } public void setItems(List items, Callback callback) { List oldItems = getItems(); List newItems = Objects.requireNonNullElseGet(items, ArrayList::new); boolean hasChange = !listsAreSame(oldItems, newItems); - if (!hasChange) { - callback.onUpdateFinished(false); - afterUpdate(null); - } else differ.submitList(newItems, () -> { - callback.onUpdateFinished(true); - afterUpdate(null); - }); + if (!hasChange) callback.onUpdateFinished(false); + else differ.submitList(newItems, () -> callback.onUpdateFinished(true)); } public void add(T item) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java deleted file mode 100644 index 4ff4df2dd5..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.fongmi.android.tv.ui.adapter; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.databinding.ItemFollowingBinding; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingSource; -import com.fongmi.android.tv.following.FollowingUpdateCoordinator; -import com.fongmi.android.tv.following.FollowingUpdatePolicy; -import com.fongmi.android.tv.utils.ImgUtil; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -public class FollowingAdapter extends RecyclerView.Adapter { - - public interface Listener { - void onOpenDetail(Following item, FollowingSource source); - - void onContinue(Following item, FollowingSource source); - - void onFollowNextSeason(Following item); - - void onCheck(Following item); - - void onRead(Following item); - - void onToggleNotify(Following item); - - void onChangeSource(Following item); - - void onDelete(Following item); - } - - public static final class Row { - public final Following following; - public final FollowingSource source; - - public Row(Following following, FollowingSource source) { - this.following = following; - this.source = source; - } - } - - private final Listener listener; - private final List items = new ArrayList<>(); - - public FollowingAdapter(Listener listener) { - this.listener = listener; - setHasStableIds(true); - } - - public void setItems(List rows) { - items.clear(); - if (rows != null) items.addAll(rows); - notifyDataSetChanged(); - } - - public int indexOf(String identityKey) { - for (int i = 0; i < items.size(); i++) { - if (items.get(i).following.identityKey.equals(identityKey)) return i; - } - return -1; - } - - @Override - public long getItemId(int position) { - return items.get(position).following.identityKey.hashCode(); - } - - @NonNull - @Override - public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new Holder(ItemFollowingBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull Holder holder, int position) { - Row row = items.get(position); - Following item = row.following; - FollowingSource source = row.source; - holder.binding.title.setText(item.vodName); - ImgUtil.load(item.vodName, item.vodPic, holder.binding.image); - int released = FollowingUpdatePolicy.releasedEpisode(item); - if (released > 0) holder.binding.official.setText(holder.itemView.getContext().getString(R.string.following_official, released)); - else holder.binding.official.setText(R.string.following_official_unknown); - bindMetadata(holder, item); - if (source != null && source.playableEpisode > 0) { - holder.binding.source.setText(holder.itemView.getContext().getString(R.string.following_source, source.playableEpisode)); - } else { - // 探针跑过但没算出可播集数必须与「尚未检查」分开,否则失败被静默伪装成未检查。 - // 判据必须与 FILTER_FAILED 同口径,且不能用 lastProbeAt:订阅导入会预写它,从未探过的源会误显示失败。 - holder.binding.source.setText(FollowingUpdateCoordinator.sourceCheckFailed(item.lastError) - ? R.string.following_source_failed : R.string.following_source_unknown); - } - if (item.watchedEpisode > 0) holder.binding.progress.setText(holder.itemView.getContext().getString(R.string.following_watched, item.watchedEpisode)); - else holder.binding.progress.setText(R.string.following_watched_unknown); - if (item.unwatchedCount > 0) holder.binding.progress.append("\n" + holder.itemView.getContext().getString(R.string.following_unwatched, item.unwatchedCount)); - else if (released > 0) holder.binding.progress.append("\n" + holder.itemView.getContext().getString(R.string.following_unwatched_none)); - if (item.hasUpdate && item.unwatchedCount > 0) { - holder.binding.badge.setVisibility(View.VISIBLE); - holder.binding.badge.setText(holder.itemView.getContext().getString(R.string.following_new_count, item.unwatchedCount)); - } else { - holder.binding.badge.setVisibility(View.GONE); - } - if (item.lastError == null || item.lastError.isBlank()) { - holder.binding.error.setVisibility(View.GONE); - } else { - holder.binding.error.setVisibility(View.VISIBLE); - holder.binding.error.setText(item.lastError); - } - holder.binding.notify.setText(item.notifyEnabled ? R.string.following_notify_on : R.string.following_notify_off); - holder.itemView.setOnClickListener(view -> listener.onOpenDetail(item, source)); - if (item.latestReleasedSeason > item.trackedSeason) { - holder.binding.nextSeason.setVisibility(View.VISIBLE); - holder.binding.nextSeason.setText(holder.itemView.getContext().getString(R.string.following_next_season, item.latestReleasedSeason + 1)); - } else { - holder.binding.nextSeason.setVisibility(View.GONE); - } - holder.binding.nextSeason.setOnClickListener(view -> listener.onFollowNextSeason(item)); - holder.binding.continuePlay.setOnClickListener(view -> listener.onContinue(item, source)); - holder.binding.check.setOnClickListener(view -> listener.onCheck(item)); - holder.binding.read.setVisibility(item.hasUpdate ? View.VISIBLE : View.GONE); - holder.binding.read.setOnClickListener(view -> listener.onRead(item)); - holder.binding.notify.setOnClickListener(view -> listener.onToggleNotify(item)); - holder.binding.sourceChange.setOnClickListener(view -> listener.onChangeSource(item)); - holder.binding.delete.setOnClickListener(view -> listener.onDelete(item)); - } - - private void bindMetadata(Holder holder, Following item) { - List parts = new ArrayList<>(); - if (item.seasonTotalEpisodes > 0) { - parts.add(holder.itemView.getContext().getString(R.string.following_season_total, item.seasonTotalEpisodes)); - } - if (item.seriesTotalEpisodes > 0) { - parts.add(holder.itemView.getContext().getString(R.string.following_series_total, item.seriesTotalEpisodes)); - } - if (item.nextAirWeekday > 0) { - parts.add(holder.itemView.getContext().getString(R.string.following_weekday, - holder.itemView.getResources().getStringArray(R.array.following_weekdays)[item.nextAirWeekday - 1])); - } - if (parts.isEmpty()) holder.binding.metadata.setVisibility(View.GONE); - else { - holder.binding.metadata.setVisibility(View.VISIBLE); - holder.binding.metadata.setText(String.join(" · ", parts)); - } - } - - @Override - public int getItemCount() { - return items.size(); - } - - public Row rowAt(int position) { - return position >= 0 && position < items.size() ? items.get(position) : null; - } - - public List getItems() { - return new ArrayList<>(items); - } - - public void markReadLocally(Collection identityKeys) { - if (identityKeys == null || identityKeys.isEmpty()) return; - for (int i = 0; i < items.size(); i++) { - Following item = items.get(i).following; - if (!identityKeys.contains(item.identityKey) || !item.hasUpdate) continue; - FollowingUpdatePolicy.markRead(item, 0); - notifyItemChanged(i); - } - } - - static final class Holder extends RecyclerView.ViewHolder { - final ItemFollowingBinding binding; - - Holder(ItemFollowingBinding binding) { - super(binding.getRoot()); - this.binding = binding; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/GithubProxyAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/GithubProxyAdapter.java index f121d93a4f..1712b33211 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/GithubProxyAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/adapter/GithubProxyAdapter.java @@ -1,27 +1,20 @@ package com.fongmi.android.tv.ui.adapter; import android.view.LayoutInflater; -import android.widget.TextView; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterGithubProxyBinding; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; public class GithubProxyAdapter extends RecyclerView.Adapter { private final OnClickListener listener; private final List mItems; - private final Map mLatency = new ConcurrentHashMap<>(); - private final Set mProbing = ConcurrentHashMap.newKeySet(); private int mSelected; public GithubProxyAdapter(OnClickListener listener) { @@ -39,32 +32,10 @@ public class GithubProxyAdapter extends RecyclerView.Adapter items, String active) { mItems.clear(); mItems.addAll(items); - mLatency.keySet().retainAll(items); mSelected = Math.max(0, mItems.indexOf(active)); notifyDataSetChanged(); } - public void setLatency(String item, String value) { - int position = mItems.indexOf(item); - mLatency.put(item, value); - mProbing.remove(item); - if (position >= 0) notifyItemChanged(position); - } - - public void startProbe(List items) { - mProbing.clear(); - mProbing.addAll(items); - for (String item : items) { - mLatency.remove(item); - int position = mItems.indexOf(item); - if (position >= 0) notifyItemChanged(position); - } - } - - public boolean isProbing() { - return !mProbing.isEmpty(); - } - public int getSelected() { return mSelected; } @@ -87,11 +58,7 @@ public class GithubProxyAdapter extends RecyclerView.Adapter listener.onActive(item)); holder.binding.remove.setOnClickListener(v -> listener.onRemove(item)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/InlineEpisodeAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/InlineEpisodeAdapter.java index 7cf34491ba..1d57d52196 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/InlineEpisodeAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/adapter/InlineEpisodeAdapter.java @@ -105,10 +105,7 @@ public class InlineEpisodeAdapter extends RecyclerView.Adapter此前这里写死 {@code FOCUS_STROKE_DP = 3},而详情页/播放页的卡片是**代码挂环** - * (不走 drawable 层),所以前几轮统一 drawable 宽度时漏掉了这批卡片, - * 用户报告「个性推荐等卡片的边框粗细没有改小没有统一」。 - */ - private static int focusStrokePx(MaterialCardView card) { - return card.getContext().getResources() - .getDimensionPixelSize(com.fongmi.android.tv.R.dimen.webhtv_focus_ring_width); + private TmdbCardFocusHelper() { } interface FocusCallback { @@ -58,26 +50,23 @@ final class TmdbCardFocusHelper { } private static void apply(MaterialCardView card, boolean focused, int backgroundColor, int strokeColor, int strokeWidthDp) { - int focus = ThemeController.focusRingColor(card.getContext()); - int focusWidth = focusStrokePx(card); card.setCardBackgroundColor(backgroundColor); - card.setStrokeColor(focused ? focus : strokeColor); - card.setStrokeWidth(focused ? focusWidth : ResUtil.dp2px(strokeWidthDp)); + card.setStrokeColor(focused ? FOCUS_STROKE : strokeColor); + card.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : strokeWidthDp)); card.setCardElevation(ResUtil.dp2px(focused ? FOCUS_ELEVATION_DP : 0)); card.setTranslationZ(ResUtil.dp2px(focused ? FOCUS_ELEVATION_DP : 0)); - card.setForeground(focused ? foregroundBorder(card, focus, focusWidth) : null); + card.setForeground(focused ? foregroundBorder(card, FOCUS_STROKE, FOCUS_STROKE_DP) : null); card.animate().cancel(); card.setScaleX(1f); card.setScaleY(1f); } - /** 描边宽度接受**像素**值(调用方自己从 token 或 dp 换算),避免再一次写死 dp。 */ - static GradientDrawable foregroundBorder(MaterialCardView card, int strokeColor, int strokeWidthPx) { + static GradientDrawable foregroundBorder(MaterialCardView card, int strokeColor, int strokeWidthDp) { GradientDrawable drawable = new GradientDrawable(); drawable.setShape(GradientDrawable.RECTANGLE); drawable.setColor(Color.TRANSPARENT); drawable.setCornerRadius(card.getRadius()); - drawable.setStroke(strokeWidthPx, strokeColor); + drawable.setStroke(ResUtil.dp2px(strokeWidthDp), strokeColor); return drawable; } } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapter.java index 6be7e1843f..a53d97273e 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapter.java @@ -17,7 +17,6 @@ import com.fongmi.android.tv.bean.Episode; import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.databinding.AdapterTmdbEpisodeBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.ui.helper.TmdbEpisodeGridPolicy; import com.fongmi.android.tv.ui.helper.TmdbEpisodeMatcher; import com.fongmi.android.tv.utils.EpisodeTitleFormatter; @@ -34,6 +33,8 @@ import java.util.Objects; public class TmdbEpisodeAdapter extends RecyclerView.Adapter { + private static final int FOCUS_STROKE = 0xFFFFD166; + private static final int FOCUS_STROKE_DP = 3; private static final int ACTIVE_STROKE_DP = 2; private static final int FOCUS_ELEVATION_DP = 8; @@ -45,7 +46,7 @@ public class TmdbEpisodeAdapter extends RecyclerView.Adapter listener.onItemClick(episode)); holder.binding.getRoot().setOnLongClickListener(view -> { - listener.onItemLongClick(view, episode, episodeNumber, boundTmdbEpisode); + listener.onItemLongClick(view, episode, episodeNumber); return true; }); } - private void applyCardSize(ViewHolder holder, int position, boolean compact, boolean hasTmdbEpisodeData) { + private void applyCardSize(ViewHolder holder, boolean compact) { View root = holder.binding.getRoot(); ViewGroup.LayoutParams params = root.getLayoutParams(); int width; int height; - boolean standardGridItem = mode == Mode.GRID && !hasTmdbEpisodeData; if (isNativeEnhanced()) { width = mode == Mode.GRID ? ViewGroup.LayoutParams.MATCH_PARENT : dp(holder.itemView, 280); - height = dp(holder.itemView, mode == Mode.GRID - ? nativeEnhancedGridCardHeightDp(isPhoneWidth(holder.itemView), hasTmdbEpisodeData) - : 160); + height = dp(holder.itemView, mode == Mode.GRID ? nativeEnhancedGridCardHeight(holder.itemView) : 160); } else if (mode == Mode.GRID) { width = ViewGroup.LayoutParams.MATCH_PARENT; height = dp(holder.itemView, 118); @@ -330,16 +324,10 @@ public class TmdbEpisodeAdapter extends RecyclerView.Adapter= 0 ? position % gridSpanCount : 0; - int marginStart = mode == Mode.GRID ? gridSpacing * gridColumn / gridSpanCount : 0; - int marginEnd = mode == Mode.GRID - ? gridSpacing - gridSpacing * (gridColumn + 1) / gridSpanCount - : dp(holder.itemView, 12); - int bottomMargin = dp(holder.itemView, isNativeEnhanced() && mode == Mode.GRID && hasTmdbEpisodeData ? 16 : mode == Mode.GRID ? 10 : 0); + int gridSpacing = dp(holder.itemView, isNativeEnhanced() ? 12 : 8); + int marginStart = mode == Mode.GRID ? gridSpacing / 2 : 0; + int marginEnd = mode == Mode.GRID ? gridSpacing - marginStart : dp(holder.itemView, 12); + int bottomMargin = dp(holder.itemView, isNativeEnhanced() && mode == Mode.GRID ? 16 : mode == Mode.GRID ? 10 : 0); layoutChanged |= marginParams.getMarginStart() != marginStart || marginParams.getMarginEnd() != marginEnd || marginParams.bottomMargin != bottomMargin; @@ -349,16 +337,14 @@ public class TmdbEpisodeAdapter extends RecyclerView.Adapter 0) return height; ViewGroup.LayoutParams params = holder.binding.getRoot().getLayoutParams(); if (params != null && params.height > 0) return params.height; - if (isNativeEnhanced()) { - int gridHeight = pageHasTmdbEpisodeData - ? nativeEnhancedGridCardHeight(holder.itemView) - : TmdbEpisodeGridPolicy.GRID_CARD_HEIGHT_DP; - return ResUtil.dp2px(mode == Mode.GRID ? gridHeight : 160); - } + if (isNativeEnhanced()) return ResUtil.dp2px(mode == Mode.GRID ? nativeEnhancedGridCardHeight(holder.itemView) : 160); return ResUtil.dp2px(mode == Mode.GRID ? TmdbEpisodeGridPolicy.GRID_CARD_HEIGHT_DP : (isPhoneWidth(holder.itemView) ? 172 : 190)); } - private boolean hasPageTmdbEpisodeData() { - for (int position = 0; position < items.size(); position++) { - Episode episode = items.get(position); - if (episode == null) continue; - int number = episodeNumber(episode, position); - TmdbEpisode tmdbEpisode = tmdbItems.get(number); - if (TmdbEpisodeMatcher.shouldApply(episode, tmdbEpisode, number)) return true; - } - return false; - } - private boolean shouldSuppressSharedFallbackVisuals() { return false; } @@ -545,27 +509,19 @@ public class TmdbEpisodeAdapter extends RecyclerView.Adapter { - TvFocusScale.applyCompact(view, focused); - // 焦点环宽度必须走唯一 token(此前写死 2dp,前几轮统一时漏掉)。 - card.setStrokeWidth(focused - ? view.getContext().getResources().getDimensionPixelSize(com.fongmi.android.tv.R.dimen.webhtv_focus_ring_width) - : ResUtil.dp2px(1)); - card.setStrokeColor(focused ? ThemeController.focusRingColor(view.getContext()) : 0x33FFFFFF); + float scale = focused ? 1.04f : 1.0f; + view.animate().scaleX(scale).scaleY(scale).setDuration(120).start(); + card.setStrokeWidth(ResUtil.dp2px(focused ? 2 : 1)); + card.setStrokeColor(focused ? 0xFFFFD166 : 0x33FFFFFF); }); } } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/TrackAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/TrackAdapter.java index 3a242f3fe4..3aea0ca29a 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/TrackAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/adapter/TrackAdapter.java @@ -78,7 +78,7 @@ public class TrackAdapter extends RecyclerView.Adapter public void onBindViewHolder(@NonNull ViewHolder holder, int position) { Track item = mItems.get(position); holder.binding.text.setText(item.getName()); - holder.binding.text.setActivated(item.isSelected()); + holder.binding.text.setSelected(item.isSelected()); } public class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/audio/AudioMiniPlayer.java b/app/src/main/java/com/fongmi/android/tv/ui/audio/AudioMiniPlayer.java index 865c57b77b..936dd8f9b0 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/audio/AudioMiniPlayer.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/audio/AudioMiniPlayer.java @@ -179,7 +179,6 @@ public final class AudioMiniPlayer implements ServiceConnection { } private void applyInsets() { - if (root == null) return; root.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> updateWindowBounds()); if (root.getParent() instanceof View) { ((View) root.getParent()).addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> updateWindowBounds()); @@ -190,13 +189,7 @@ public final class AudioMiniPlayer implements ServiceConnection { return insets; }); ViewCompat.requestApplyInsets(root); - // The host activity can be recreated (theme/language change) before this - // posted runnable executes; capture the view and bail out once detached. - View target = root; - target.post(() -> { - if (root != target) return; - ViewCompat.requestApplyInsets(target); - }); + root.post(() -> ViewCompat.requestApplyInsets(root)); } private void updateWindowBounds() { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomRecyclerView.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomRecyclerView.java index 5d71b035c2..82fdd58d90 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomRecyclerView.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomRecyclerView.java @@ -95,38 +95,15 @@ public class CustomRecyclerView extends RecyclerView { setMeasuredDimension(finalWidth, finalHeight); } - private static final int FOCUS_ATTEMPTS = 5; - private static final long FOCUS_RETRY_DELAY_MS = 50; - private void focus(int position) { - focus(position, FOCUS_ATTEMPTS); - } - - /** - * 把焦点落到指定行,但在布局完成前不放弃。 - * - *

弹层刚 show 时 RecyclerView 往往还没走到 onLayout,{@code findViewHolderForLayoutPosition} - * 会返回 null,原先的实现就直接丢掉这次请求——结果是整张列表没有任何行处于 focus, - * 遥控器上下键既看不到焦点环,也看不出光标停在哪(用户报告「TV 版本视轨/音轨没有焦点显示」)。 - * 电视盒子冷启动时更容易命中这个时序。 - * - *

失败时按 50ms 重试,总时长上限 {@code FOCUS_ATTEMPTS * FOCUS_RETRY_DELAY_MS} = 250ms。 - * 只要列表中已经有子 View 拿到焦点(用户已经开始按键)就立刻停止,不去抢焦点。 - */ - private void focus(int position, int attempts) { ViewHolder holder = findViewHolderForLayoutPosition(position); - if (holder != null) { - holder.itemView.requestFocus(); - return; - } - if (getFocusedChild() != null || attempts <= 1) return; - postDelayed(() -> focus(position, attempts - 1), FOCUS_RETRY_DELAY_MS); + if (holder != null) holder.itemView.requestFocus(); } @Override public void scrollToPosition(int position) { super.scrollToPosition(position); - postDelayed(() -> focus(position), FOCUS_RETRY_DELAY_MS); + postDelayed(() -> focus(position), 50); } @Override diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomSeekView.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomSeekView.java index a751420f1d..a538cbdc14 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomSeekView.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomSeekView.java @@ -25,7 +25,6 @@ import com.github.catvod.crawler.SpiderDebug; import java.util.Formatter; import java.util.Locale; import java.util.concurrent.TimeUnit; -import java.util.function.LongSupplier; public class CustomSeekView extends FrameLayout implements Player.Listener, TimeBar.OnScrubListener { @@ -38,7 +37,6 @@ public class CustomSeekView extends FrameLayout implements Player.Listener, Time private static final int MIN_UPDATE_INTERVAL_MS = 200; private static final long DISK_RANGE_GAP_TOLERANCE_MS = 2000; private static final long PAUSED_BUFFER_LOG_INTERVAL_MS = 5000; - private static final long DURATION_FALLBACK_LOG_INTERVAL_MS = 5000; private static final long SEEK_POSITION_TOLERANCE_MS = 1500; private static final long SEEK_POSITION_HOLD_TIMEOUT_MS = 10000; @@ -56,8 +54,6 @@ public class CustomSeekView extends FrameLayout implements Player.Listener, Time private Player player; private SeekListener seekListener; private Player progressPlayer; - private LongSupplier durationFallback; - private long lastDurationFallbackLogAtMs; private long lastPausedBufferLogAtMs; private long lastPausedBufferedPosition; private long lastBufferReadLogAtMs; @@ -101,17 +97,6 @@ public class CustomSeekView extends FrameLayout implements Player.Listener, Time this.seekListener = seekListener; } - /** - * Supplies the last known total duration while the engine has not published - * one yet. A positive engine duration always wins over this fallback. - * A {@code null} supplier or a returned value {@code <= 0} means no fallback. - */ - public void setDurationFallback(@Nullable LongSupplier durationFallback) { - this.durationFallback = durationFallback; - lastDurationFallbackLogAtMs = 0; - if (attached) updateTimeline(); - } - /** * Uses the in-process engine for timeline reads while commands continue to * go through the MediaController. MediaSession does not continuously send @@ -139,31 +124,11 @@ public class CustomSeekView extends FrameLayout implements Player.Listener, Time return Util.getStringForTime(timeBuilder, timeFormatter, time); } - private long resolveDuration(Player progress) { - long duration = progress.getDuration(); - if (duration > 0) return duration; - LongSupplier fallbackSource = durationFallback; - if (fallbackSource == null) return 0; - long fallback; - try { - fallback = fallbackSource.getAsLong(); - } catch (Throwable ignored) { - return 0; - } - if (fallback <= 0) return 0; - long now = SystemClock.elapsedRealtime(); - if (SpiderDebug.isEnabled() && now - lastDurationFallbackLogAtMs >= DURATION_FALLBACK_LOG_INTERVAL_MS) { - lastDurationFallbackLogAtMs = now; - SpiderDebug.log("playback-progress", "action=duration-fallback source=%s durationMs=%d", - progress == player ? "controller" : "direct-engine", fallback); - } - return fallback; - } - private void updateTimeline() { Player progress = getProgressPlayer(); if (!attached || progress == null) return; - long duration = resolveDuration(progress); + long duration = progress.getDuration(); + if (duration < 0) duration = 0; currentDuration = duration; setKeyTimeIncrement(duration); timeBar.setDuration(duration); @@ -178,7 +143,8 @@ public class CustomSeekView extends FrameLayout implements Player.Listener, Time long reportedPosition = Math.max(0, progress.getCurrentPosition()); long position = stableSeekPosition(reportedPosition); long buffered = effectiveBufferedPosition(progress); - long duration = resolveDuration(progress); + long duration = progress.getDuration(); + if (duration < 0) duration = 0; if (duration != currentDuration) { currentDuration = duration; setKeyTimeIncrement(duration); @@ -210,7 +176,7 @@ public class CustomSeekView extends FrameLayout implements Player.Listener, Time long diskBuffered = PlaybackDiskBufferStore.process().contiguousEnd( mediaKey, buffered, DISK_RANGE_GAP_TOLERANCE_MS); long effective = Math.max(buffered, diskBuffered); - long duration = resolveDuration(progress); + long duration = progress.getDuration(); long bounded = duration > 0 ? Math.min(effective, duration) : effective; long now = SystemClock.elapsedRealtime(); if (SpiderDebug.isEnabled() diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWallView.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWallView.java index 70cfcb6e69..52e6a56340 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWallView.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWallView.java @@ -28,7 +28,6 @@ import com.fongmi.android.tv.databinding.ViewWallBinding; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.FileUtil; import com.github.catvod.crawler.SpiderDebug; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWebView.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWebView.java index 4d86985974..d990032934 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWebView.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWebView.java @@ -41,7 +41,6 @@ import com.google.common.net.HttpHeaders; import java.io.ByteArrayInputStream; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -69,8 +68,6 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL private String from; private String key; private String url; - private Map pageHeaders; - private int mediaProbeCount; public static CustomWebView create(@NonNull Context context) { return new CustomWebView(context); @@ -113,7 +110,6 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL this.from = from; this.key = key; this.url = url; - this.mediaProbeCount = 0; start(headers); return this; } @@ -121,8 +117,6 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL private void start(Map headers) { CookieManager.getInstance().setAcceptThirdPartyCookies(this, true); Map pageHeaders = WebSniffHeaders.forPage(headers, getSettings().getUserAgentString()); - this.pageHeaders = new HashMap<>(pageHeaders); - if (!this.pageHeaders.containsKey(HttpHeaders.REFERER)) this.pageHeaders.put(HttpHeaders.REFERER, url); checkHeader(url, pageHeaders); SpiderDebug.log("webview-parse", "page headers input=%s applied=%s ua=%s", headers == null ? "[]" : headers.keySet(), pageHeaders.keySet(), getSettings().getUserAgentString()); loadUrl(url, pageHeaders); @@ -144,22 +138,11 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL if (TextUtils.isEmpty(host) || isAd(host)) return empty; Map headers = request.getRequestHeaders(); if (url.contains("/cdn-cgi/challenge-platform/")) post(() -> showDialog()); - boolean player = PLAYER.matcher(url).find(); - boolean video = isVideoFormat(url) && !isPlaceholderVideoUrl(url); - if (SpiderDebug.isEnabled() && (player || video || host.contains("hls") || url.contains("m3u8") || url.contains("mp4") || url.contains("video"))) { - SpiderDebug.log("webview-sniff", "from=%s host=%s path=%s player=%s video=%s", from, host, request.getUrl().getPath(), player, video); - } - if (detect && player && addUrl(url)) onParseAdd(headers, url); - else if (video) onParseSuccess(headers, url); + if (detect && PLAYER.matcher(url).find() && addUrl(url)) onParseAdd(headers, url); + else if (isVideoFormat(url)) onParseSuccess(headers, url); return super.shouldInterceptRequest(view, request); } - @Override - public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { - super.onPageStarted(view, url, favicon); - if (needsWasmSignShim(url)) view.evaluateJavascript(WASM_SIGN_SHIM, null); - } - @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); @@ -197,147 +180,6 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL }; } - - /** - * 部分解析站(虾米/jx.m3u8.tv/jx.hls.one 等同源引擎)的签名脚本依赖 - * WebAssembly reference-types 与多值返回,只有 Chrome 96+ 的 WebView 才能实例化。 - * 老模拟器 WebView(如 91)实例化失败后签名不会生成,解析接口根本不会被调用, - * 表现为“打开解析页后一直嗅探不到地址”。 - * - * 这里在页面脚本执行前注入同样的签名兜底实现: - * 先按原样调用 WebAssembly.instantiate,成功则完全走原逻辑;只有失败时才回退到 - * 等价的 JS HMAC-SHA256(sign = HMAC(key, be64(tm) || url)), - * 让解析页自己完成后续的请求、解密与播放器构建。 - */ - private static final String WASM_SIGN_SHIM = """ -(function () { - if (window.__whWasmShim) return; - window.__whWasmShim = 1; - if (typeof WebAssembly === 'undefined' || typeof WebAssembly.instantiate !== 'function') return; - var KEY = ']QAK[I]DGUHp4$%,145"0$3&3%4'; - var K0 = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]; - var KT = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, - 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, - 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, - 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, - 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, - 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, - 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, - 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]; - - function rotr(x, n) { return ((x >>> n) | (x << (32 - n))) >>> 0; } - - function sha256(bytes) { - var len = bytes.length; - var withPad = new Uint8Array(((len + 9 + 63) >> 6) << 6); - withPad.set(bytes, 0); - withPad[len] = 0x80; - var bitLenHi = Math.floor(len / 0x20000000); - var bitLenLo = (len << 3) >>> 0; - var dv = new DataView(withPad.buffer); - dv.setUint32(withPad.length - 8, bitLenHi); - dv.setUint32(withPad.length - 4, bitLenLo); - var h = K0.slice(0), w = new Array(64); - for (var off = 0; off < withPad.length; off += 64) { - for (var i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4); - for (i = 16; i < 64; i++) { - var s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); - var s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); - w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; - } - var a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7]; - for (i = 0; i < 64; i++) { - var S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); - var ch = (e & f) ^ (~e & g); - var t1 = (hh + S1 + ch + KT[i] + w[i]) >>> 0; - var S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); - var mj = (a & b) ^ (a & c) ^ (b & c); - var t2 = (S0 + mj) >>> 0; - hh = g; g = f; f = e; e = (d + t1) >>> 0; - d = c; c = b; b = a; a = (t1 + t2) >>> 0; - } - h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0; - h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + hh) >>> 0; - } - var out = new Uint8Array(32); - var odv = new DataView(out.buffer); - for (i = 0; i < 8; i++) odv.setUint32(i * 4, h[i]); - return out; - } - - function hmacSha256(keyBytes, msgBytes) { - var block = new Uint8Array(64); - var k = keyBytes.length > 64 ? sha256(keyBytes) : keyBytes; - block.set(k, 0); - var ipad = new Uint8Array(64), opad = new Uint8Array(64); - for (var i = 0; i < 64; i++) { ipad[i] = block[i] ^ 0x36; opad[i] = block[i] ^ 0x5c; } - var inner = new Uint8Array(64 + msgBytes.length); - inner.set(ipad, 0); inner.set(msgBytes, 64); - var ih = sha256(inner); - var outer = new Uint8Array(96); - outer.set(opad, 0); outer.set(ih, 64); - return sha256(outer); - } - - var encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; - function utf8Bytes(str) { - if (encoder) return encoder.encode(str); - var s = unescape(encodeURIComponent(str)), b = new Uint8Array(s.length); - for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i); - return b; - } - - function hex64(tm, url) { - var msg = new Uint8Array(8 + utf8Bytes(url).length); - var v = tm; - for (var i = 7; i >= 0; i--) { msg[i] = Number(v & BigInt(0xff)); v = v >> BigInt(8); } - msg.set(utf8Bytes(url), 8); - return hmacSha256(Uint8Array.from(KEY.split('').map(function (c) { return c.charCodeAt(0); })), msg); - } - - function fallbackInstance() { - var buffer = new ArrayBuffer(1 << 20); - var heap = new Uint8Array(buffer); - var next = 4096; - return { - instance: { - exports: { - memory: { buffer: buffer }, - alloc: function (size) { var p = next; next = (next + size + 15) & ~15; return p; }, - free: function () { }, - gen_sign: function (tm, ptr, len, out) { - var url = ''; - for (var i = 0; i < len; i++) url += String.fromCharCode(heap[ptr + i]); - url = decodeURIComponent(escape(url)); - var sig = hex64(tm, url); - heap.set(sig, out); - return 0; - } - } - } - }; - } - - var real = WebAssembly.instantiate; - WebAssembly.instantiate = function () { - var args = arguments; - var p; - try { p = real.apply(WebAssembly, args); } catch (e) { p = Promise.reject(e); } - return Promise.resolve(p).then(function (r) { return r; }, function () { - try { console.log('[wh-wasm] wasm unavailable, using JS HMAC shim'); } catch (e) { } - return fallbackInstance(); - }); - }; -})(); -"""; - - /** 只有这几个解析站需要 WASM 签名兜底,避免影响其它解析/嗅探站点。 */ - private static boolean needsWasmSignShim(String url) { - if (TextUtils.isEmpty(url)) return false; - String lower = url.toLowerCase(java.util.Locale.ROOT); - return lower.contains("xmflv.com") || lower.contains("m3u8.tv") || lower.contains("hls.one"); - } - private boolean addUrl(String url) { if (urls.size() > MAX_URLS) urls.clear(); return urls.add(url); @@ -368,10 +210,7 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL } private void evaluate(List script, int index) { - if (index >= script.size()) { - probeDomMedia(0); - return; - } + if (index >= script.size()) return; String js = script.get(index); if (TextUtils.isEmpty(js)) { evaluate(script, index + 1); @@ -380,45 +219,6 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL } } - /** - * 部分解析页给 video/src 分配的是没有 .m3u8/.mp4 后缀的签名地址, - * 这类地址不会经过 URL 正则嗅探;从 DOM/performance 资源中补采一次。 - */ - private void probeDomMedia(int attempt) { - if (stopped || attempt > 6 || mediaProbeCount++ > 12) return; - String js = "(function(){var a=[];var seen=[];function walk(d){if(!d||seen.indexOf(d)>=0)return;seen.push(d);try{d.querySelectorAll('video,audio,video source,audio source').forEach(function(v){var u=v.currentSrc||v.src;if(u)a.push({u:u,k:'video'});});d.querySelectorAll('iframe,frame').forEach(function(f){try{walk(f.contentDocument);}catch(e){}});try{d.defaultView.performance.getEntriesByType('resource').forEach(function(e){if(e.initiatorType==='video'||e.initiatorType==='audio')a.push({u:e.name,k:'resource'});});}catch(e){}}walk(document);return JSON.stringify(a);})()"; - evaluateJavascript(js, value -> { - try { - String payload = value == null ? "" : new org.json.JSONTokener(value).nextValue().toString(); - org.json.JSONArray array = new org.json.JSONArray(payload); - if (SpiderDebug.isEnabled() && array.length() > 0) SpiderDebug.log("webview-probe", "from=%s attempt=%d candidates=%s", from, attempt, payload); - for (int i = 0; i < array.length(); i++) { - org.json.JSONObject item = array.optJSONObject(i); - if (item == null) continue; - String candidate = item.optString("u", ""); - String kind = item.optString("k", ""); - if (("video".equals(kind) || "resource".equals(kind)) && isHttpMediaCandidate(candidate)) { - onParseSuccess(pageHeaders == null ? new HashMap<>() : new HashMap<>(pageHeaders), candidate); - return; - } - } - } catch (Throwable ignored) { - } - if (!stopped) postDelayed(() -> probeDomMedia(attempt + 1), 1000); - }); - } - - private boolean isHttpMediaCandidate(String candidate) { - if (TextUtils.isEmpty(candidate) || !candidate.regionMatches(true, 0, "http", 0, 4)) return false; - if (candidate.equals(url) || candidate.contains("/favicon")) return false; - return !isPlaceholderVideoUrl(candidate) && (Sniffer.isVideoFormat(candidate) || !candidate.contains(".js") && !candidate.contains(".css") && !candidate.contains(".html")); - } - - private boolean isPlaceholderVideoUrl(String candidate) { - String lower = candidate == null ? "" : candidate.toLowerCase(java.util.Locale.ROOT); - return lower.contains("/404_") || lower.contains("/404.mp4") || lower.contains("/error.mp4"); - } - private boolean isAd(String host) { for (String ad : RuleConfig.get().getAds()) { if (Util.containOrMatch(host, ad)) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/FocusRestorer.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/FocusRestorer.java deleted file mode 100644 index 93239a2738..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/FocusRestorer.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.fongmi.android.tv.ui.custom; - -import androidx.core.view.OneShotPreDrawListener; -import androidx.recyclerview.widget.RecyclerView; - -/** - * 条目被移除后把 D-pad 焦点交回列表。 - * RecyclerView 自身的焦点恢复救不了这种场景:移除 scrap 时已经先 clearFocus(), - * 焦点被窗口兜底给了布局里第一个可聚焦控件(历史页是右上角的删除按钮), - * 等到 recoverFocusFromState() 时 hasFocus() 已为 false 直接返回; - * 适配器也没有 stable id,基于 id 的恢复路径同样是死的。 - */ -public final class FocusRestorer { - - private OneShotPreDrawListener pending; - private int generation; - - /** - * 网格中移除 position 后焦点应落到哪一项。 - * 原位仍有条目就留在原位(后面的条目补位);原位已越界说明删的是末位, - * 此时同行还有条目就退到新末位,整行被删空则上移一行保持同列。 - */ - public static int nextPosition(int position, int count, int spanCount) { - if (position < 0 || count <= 0) return RecyclerView.NO_POSITION; - if (position < count) return position; - int span = Math.max(1, spanCount); - int last = count - 1; - if (last / span == position / span) return last; - return Math.min(last, Math.max(0, position - span)); - } - - public void restore(RecyclerView recycler, int position) { - cancel(); - if (recycler == null || position < 0) return; - schedule(recycler, position, generation, true); - } - - /** 取消排队中的恢复,包含已经进入滚动重试阶段的那一次。 */ - public void cancel() { - generation++; - if (pending != null) pending.removeListener(); - pending = null; - } - - private void schedule(RecyclerView recycler, int position, int scheduled, boolean retry) { - pending = OneShotPreDrawListener.add(recycler, () -> { - if (scheduled != generation) return; - pending = null; - if (!recycler.isShown()) return; - if (focus(recycler, position)) return; - if (!retry) { - recycler.requestFocus(); - return; - } - // 目标行尚未 layout(列表已滚动过)时先滚过去;scrollToPosition 只是 requestLayout, - // 必须再等一次 pre-draw 才能拿到 holder,用 post 会跑在这次 layout 之前 - recycler.scrollToPosition(position); - schedule(recycler, position, scheduled, false); - }); - } - - private static boolean focus(RecyclerView recycler, int position) { - RecyclerView.ViewHolder holder = recycler.findViewHolderForAdapterPosition(position); - return holder != null && holder.itemView.requestFocus(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerGesture.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerGesture.java index 6d0b8f89a0..fe473d82a7 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerGesture.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerGesture.java @@ -35,13 +35,9 @@ public class PlayerGesture extends GestureDetector.SimpleOnGestureListener imple private boolean changeTime; private boolean multiTouch; private boolean animating; - private boolean shortDrama; private boolean touch; private boolean lock; private float bright; - private float anchorY = Float.NaN; - private float downX; - private float downY; private float volume; private float scale; private long time; @@ -63,49 +59,12 @@ public class PlayerGesture extends GestureDetector.SimpleOnGestureListener imple public boolean onTouchEvent(MotionEvent e) { int action = e.getActionMasked(); - boolean end = action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL; if (action == MotionEvent.ACTION_DOWN) multiTouch = false; - // 起点在这里记而不是 onDown:onDown 在边缘/缩放/锁定时会提前返回, - // 而 changeScale 会在 onScaleEnd 之后 500ms 才解除,那段窗口里按下的手势 - // 拿不到自己的起点,长按转调节时会拿上一次的起点算位移,一按就跳。 - if (action == MotionEvent.ACTION_DOWN) { - downX = e.getX(); - downY = e.getY(); - } if (action == MotionEvent.ACTION_POINTER_DOWN) multiTouch = true; - if (end) listener.onTouchEnd(); - // 短剧的亮度/音量在长按之后才生效,此时 GestureDetector 已不再回调 onScroll, - // 只能在这里自行处理移动事件,详见 handleAdjust。用实时指数判断而不是 multiTouch: - // 后者在 ACTION_POINTER_UP 时不会复位,会把抬起一根手指后的单指调节一起挡掉。 - if (shortDrama && action == MotionEvent.ACTION_MOVE && e.getPointerCount() == 1 && !lock && !changeScale) handleAdjust(e); - // 被父容器拦截或来电时只收到 CANCEL:倍速同样要交还,否则会卡在长按后的速率。 - if (changeSpeed && end) listener.onSpeedEnd(); - // seek 只在正常抬手时提交:CANCEL 属于手势被打断,维持原位置更符合预期。 + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) listener.onTouchEnd(); + if (changeSpeed && action == MotionEvent.ACTION_UP) listener.onSpeedEnd(); if (changeTime && action == MotionEvent.ACTION_UP) listener.onSeekEnd(time); - boolean handled = e.getPointerCount() == 2 ? scaleDetector.onTouchEvent(e) : detector.onTouchEvent(e); - // 必须等 detector 处理完再清:onFling 是 detector 在 UP 时回调的,它要读 - // changeSpeed/changeBright/changeVolume 才能判断这次抬手是否该切集。 - if (end) clearGesture(); - return handled; - } - - /** - * 一次手势结束就把功能标记清干净。 - *

- * {@link #reset()} 挂在 onDown 上,而 onDown 在边缘/缩放/锁定时会提前返回, - * 于是上一次手势的 changeBright/anchorY 可能活到下一次手势——短剧的亮度/音量 - * 由 onTouchEvent 直接驱动,不再有 onScroll 的兜底,残留状态会直接造成 - * 「没长按就跳亮度」。所以收尾统一在这里做,不依赖下一次 onDown 是否被接受。 - */ - private void clearGesture() { - changeTime = false; - changeSpeed = false; - changeBright = false; - changeVolume = false; - anchorY = Float.NaN; - // 不动 touch:它表示「本次手势还没选定功能」,是就绪态而非残留动作。 - // 在这里清成 false 会让缩放后 500ms 内(changeScale 还没解除时)起手的那一次 - // 拖动选不到任何功能,白滑一次。它由 reset() 置真、checkFunc 置假,无需插手。 + return e.getPointerCount() == 2 ? scaleDetector.onTouchEvent(e) : detector.onTouchEvent(e); } public void resetScale() { @@ -121,17 +80,6 @@ public class PlayerGesture extends GestureDetector.SimpleOnGestureListener imple this.lock = lock; } - /** - * 短剧竖屏形态下换用另一套手势轴向,参考红果短剧、抖音等主流竖屏播放器: - * 上下滑切上下集(全屏可用),亮度/音量改为长按后再上下滑,左半亮度右半音量。 - *

- * 原来「中间竖滑切集 + 左右 1/4 竖滑调亮度音量」在铺满屏幕的竖屏短剧里极易误触 - * (用户反馈):想切集却滑到了边缘,调亮度又常常切了集。 - */ - public void setShortDrama(boolean shortDrama) { - this.shortDrama = shortDrama; - } - private boolean isMultiple(MotionEvent e) { return e.getPointerCount() > 1; } @@ -177,7 +125,6 @@ public class PlayerGesture extends GestureDetector.SimpleOnGestureListener imple private void reset() { time = 0; touch = true; - anchorY = Float.NaN; changeTime = false; changeSpeed = false; changeBright = false; @@ -212,41 +159,6 @@ public class PlayerGesture extends GestureDetector.SimpleOnGestureListener imple return true; } - /** - * 短剧的「长按后上下滑调亮度/音量」。 - *

- * 不能挂在 {@link #onScroll} 上:GestureDetector 触发长按后会把后续 ACTION_MOVE - * 全部吞掉(mInLongPress 分支直接 break),onScroll 再也不会回调,所以这条手势 - * 必须由 onTouchEvent 自己驱动。 - */ - private void handleAdjust(MotionEvent e) { - // 只认「本次手势自己长按出来」的调节,避免上一次手势的残留标记把亮度带跑。 - if (!changeSpeed && Float.isNaN(anchorY)) return; - if (changeSpeed) { - float dx = e.getX() - downX; - float dy = e.getY() - downY; - if (Math.abs(dy) <= Math.abs(dx) || Math.abs(dy) < ResUtil.dp2px(20)) return; - startAdjust(e); - } - // 以「长按转调节」的那一刻为基准,避免把长按期间的位移一次性算进亮度/音量。 - float deltaY = anchorY - e.getY(); - if (changeBright) setBright(deltaY); - if (changeVolume) setVolume(deltaY); - } - - /** - * 长按倍速中途转为亮度/音量调节:先把倍速交还,再以当前触点为新基准。 - */ - private void startAdjust(MotionEvent e) { - listener.onSpeedEnd(); - changeSpeed = false; - touch = false; - anchorY = e.getY(); - bright = Util.getBrightness(activity); - volume = manager.getStreamVolume(AudioManager.STREAM_MUSIC); - checkSide(e); - } - @Override public boolean onDoubleTap(@NonNull MotionEvent e) { if (isMultiple(e) || isEdge(e) || changeScale) return true; @@ -263,21 +175,15 @@ public class PlayerGesture extends GestureDetector.SimpleOnGestureListener imple @Override public boolean onFling(MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) { - // 短剧上下滑切集不再受左右 1/4 的亮度/音量分区限制(那两块已改为长按触发), - // 但 isEdge 的 24dp 边框仍然保留:那里要让给系统的返回/通知栏手势。 - if (isMultiple(e1) || isEdge(e1) || (!shortDrama && isSide(e1)) || changeScale || lock || animating) return true; - // 这次手势已经用于调亮度/音量或倍速,抬手不应再被当成切集。 - if (changeSpeed || changeBright || changeVolume) return true; + if (isMultiple(e1) || isEdge(e1) || isSide(e1) || changeScale || lock || animating) return true; checkFunc(e1, e2); return true; } private void checkFunc(float distanceX, float distanceY, MotionEvent e2) { if ((float) Math.sqrt(distanceX * distanceX + distanceY * distanceY) < ResUtil.dp2px(20)) return; - // 短剧的上下滑整屏留给切集(onFling),亮度/音量改由 startAdjust(长按后再滑)接管, - // 所以这里只保留横滑拖进度,不再按左右 1/4 分派亮度/音量。 if (distanceX >= distanceY) changeTime = true; - else if (!shortDrama && isSide(e2)) checkSide(e2); + else if (isSide(e2)) checkSide(e2); touch = false; } @@ -285,13 +191,10 @@ public class PlayerGesture extends GestureDetector.SimpleOnGestureListener imple float dx = e2.getX() - e1.getX(); float dy = e2.getY() - e1.getY(); double angle = Math.toDegrees(Math.atan2(Math.abs(dy), Math.abs(dx))); - // 短剧的切集动画沿用同一套竖向回弹,但不跟随直播的「反转」开关: - // 竖屏短剧里上滑=下一集是抖音/红果的既定语义,反转会让用户完全迷失。 - boolean invert = !shortDrama && LiveSetting.isInvert(); if (angle > 70 && e1.getY() - e2.getY() > DISTANCE) { - videoView.animate().translationYBy(ResUtil.dp2px(invert ? 24 : -24)).setDuration(150).withStartAction(() -> animating = true).withEndAction(() -> videoView.animate().translationY(0).setDuration(100).withStartAction(listener::onFlingUp).withEndAction(() -> animating = false).start()).start(); + videoView.animate().translationYBy(ResUtil.dp2px(LiveSetting.isInvert() ? 24 : -24)).setDuration(150).withStartAction(() -> animating = true).withEndAction(() -> videoView.animate().translationY(0).setDuration(100).withStartAction(listener::onFlingUp).withEndAction(() -> animating = false).start()).start(); } else if (angle > 70 && e2.getY() - e1.getY() > DISTANCE) { - videoView.animate().translationYBy(ResUtil.dp2px(invert ? -24 : 24)).setDuration(150).withStartAction(() -> animating = true).withEndAction(() -> videoView.animate().translationY(0).setDuration(100).withStartAction(listener::onFlingDown).withEndAction(() -> animating = false).start()).start(); + videoView.animate().translationYBy(ResUtil.dp2px(LiveSetting.isInvert() ? -24 : 24)).setDuration(150).withStartAction(() -> animating = true).withEndAction(() -> videoView.animate().translationY(0).setDuration(100).withStartAction(listener::onFlingDown).withEndAction(() -> animating = false).start()).start(); } } @@ -314,7 +217,6 @@ public class PlayerGesture extends GestureDetector.SimpleOnGestureListener imple private void setVolume(float deltaY) { int height = Math.max(videoView.getMeasuredHeight(), 1); int maxVolume = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC); - if (maxVolume <= 0) return; float deltaV = deltaY * 2.0f / height * maxVolume; float index = volume + deltaV; if (index > maxVolume) index = maxVolume; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerOsdController.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerOsdController.java index 2fa2cf32a2..0cf66afae9 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerOsdController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerOsdController.java @@ -2,7 +2,6 @@ package com.fongmi.android.tv.ui.custom; import android.content.Context; import android.content.pm.PackageInfo; -import android.graphics.drawable.GradientDrawable; import android.media.MediaCodecInfo; import android.media.MediaCodecList; import android.media.MediaFormat; @@ -14,13 +13,8 @@ import android.text.TextUtils; import android.util.DisplayMetrics; import android.util.TypedValue; import android.view.Display; -import android.view.Gravity; import android.view.View; import android.view.ViewGroup; -import android.view.ViewParent; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.LinearLayout; import android.webkit.WebView; import android.widget.TextView; @@ -32,10 +26,7 @@ import androidx.media3.common.Tracks; import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.player.DolbyVisionFormatLabel; -import com.fongmi.android.tv.player.AudioPlaybackDiagnostics; import com.fongmi.android.tv.player.GpuLoadMonitor; -import com.fongmi.android.tv.player.MediaBadgeCatalog; -import com.fongmi.android.tv.player.MediaBadgeResolver; import com.fongmi.android.tv.player.ijk.IjkDecodePressurePolicy; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.PlaybackPanelResourceMonitor; @@ -49,15 +40,11 @@ import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.PreloadSetting; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.utils.Util; -import com.google.android.flexbox.FlexboxLayout; import java.text.DecimalFormat; import java.text.SimpleDateFormat; import java.util.Date; -import java.util.HashMap; -import java.util.List; import java.util.Locale; -import java.util.Map; public class PlayerOsdController { @@ -65,30 +52,12 @@ public class PlayerOsdController { PlayerManager getPlayer(); String getTitle(); - - /** - * 是否处于全屏播放。媒体信息徽标只属于全屏画面:半屏/小窗(详情页内嵌、竖屏小屏)里 - * 它会和一堆控件挤在一起,因此非全屏时无论屏显开关如何都不显示。 - */ - default boolean isFullscreen() { - return true; - } } private static volatile String cachedDeviceText; private static volatile String cachedSystemText; private static volatile String cachedWebViewText; private static volatile String cachedHevcDecoderText; - /** - * The verdict threshold for "掉帧较多". It used to be a bare raw count of 60, but media3 - * settles its dropped-frame accumulator whenever the renderer stops, so the count also - * jumps at every pause. The rate is the comparable measure; it matches - * ExoUtil.ENHANCED_DROPPED_FRAMES_PER_SECOND_THRESHOLD, the local downgrade trigger. - */ - private static final double DROPPED_FRAMES_PER_SECOND_DIAGNOSIS = 4d; - /** Below this played time a rate is not yet meaningful; the raw count is used instead. */ - private static final long DROPPED_FRAMES_RATE_MIN_PLAY_TIME_MS = 30_000L; - private static final long DROPPED_FRAMES_RAW_COUNT_DIAGNOSIS = 60; private final SimpleDateFormat timeFormat; private final TextView topLeft; @@ -98,7 +67,6 @@ public class PlayerOsdController { private final TextView diagnostics; private final TextView diagnosticsExtra; private final View diagnosticsPanel; - private final ViewGroup mediaBadges; private final MiniProgressView miniProgress; private final Runnable update; private final Source source; @@ -110,24 +78,18 @@ public class PlayerOsdController { private final DecimalFormat frameFormat; private final DecimalFormat refreshFormat; private final DecimalFormat bitrateFormat; - private final DecimalFormat frameDropRateFormat; private final PlaybackSpeedMeter speedMeter = new PlaybackSpeedMeter(); private long lastSpeedKBps; private String lastSpeedText; private boolean controlsVisible; private boolean diagnosticsVisible; private boolean persistentSuppressed; - private String mediaBadgeSignature = ""; - private View observedControlBar; - private View.OnLayoutChangeListener observedControlBarListener; private boolean started; private PlayerManager diagnosticsSamplingPlayer; public PlayerOsdController(View root, TextView topLeft, TextView topRight, TextView bottomLeft, TextView bottomRight, TextView diagnostics, MiniProgressView miniProgress, Source source, float miniSp) { this.timeFormat = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); this.bitrateFormat = new DecimalFormat("#.0"); - // "#.0" prints 0.5 as ".5"; a rate must keep its leading digit. - this.frameDropRateFormat = new DecimalFormat("0.0"); this.refreshFormat = new DecimalFormat("#.##"); this.frameFormat = new DecimalFormat("#.###"); this.miniProgress = miniProgress; @@ -136,7 +98,6 @@ public class PlayerOsdController { this.diagnostics = diagnostics; this.diagnosticsExtra = root.findViewById(R.id.osdDiagnosticsExtra); this.diagnosticsPanel = root.findViewById(R.id.osdDiagnosticsPanel); - this.mediaBadges = root.findViewById(R.id.osdMediaBadges); this.topRight = topRight; this.topLeft = topLeft; this.miniSp = miniSp; @@ -174,7 +135,6 @@ public class PlayerOsdController { public void release() { stop(); - stopObservingControlBar(); } public void setSuppressed(boolean suppressed) { @@ -209,10 +169,6 @@ public class PlayerOsdController { return diagnosticsVisible; } - public String sampleSpeedText() { - return lastSpeedText; - } - public void setDiagnosticsVisible(boolean visible) { boolean next = visible && PlayerSetting.isOsdDiagnostics(); if (diagnosticsVisible == next) return; @@ -234,9 +190,6 @@ public class PlayerOsdController { private boolean render() { if (suppressed) { - // mobile 播放器在控制栏出现时会抑制整个 OSD 根(控制栏自带标题与分辨率)。 - // 媒体信息徽标是唯一的例外:它只在「屏显设置 → 媒体信息」开启时才放行。 - if (controlsVisible && renderMediaBadgesOnly()) return true; root.setVisibility(View.GONE); return false; } @@ -254,11 +207,9 @@ public class PlayerOsdController { bottomLeft.setVisibility(View.GONE); bottomRight.setVisibility(View.GONE); diagnosticsPanel.setVisibility(View.GONE); - setMediaBadges(player); if (miniProgress != null) miniProgress.setVisibility(View.GONE); - // 标题、时间、媒体信息徽标,至少一个可见就显示 root - boolean hasVisible = topLeft.getVisibility() == View.VISIBLE || topRight.getVisibility() == View.VISIBLE - || (mediaBadges != null && mediaBadges.getVisibility() == View.VISIBLE); + // 如果标题或时间至少有一个显示,则显示 root + boolean hasVisible = topLeft.getVisibility() == View.VISIBLE || topRight.getVisibility() == View.VISIBLE; root.setVisibility(hasVisible ? View.VISIBLE : View.GONE); return true; } @@ -271,8 +222,7 @@ public class PlayerOsdController { return false; } - // 控制栏隐藏时,按用户设置显示常驻屏显;用户打开「媒体信息」开关后, - // 徽标与标题 / 时间 / 进度 / 码率同级常驻,不再依赖控制栏是否唤醒。 + // 控制栏隐藏时,按用户设置显示 root.setVisibility(View.VISIBLE); if (persistentSuppressed) { hidePersistent(); @@ -286,52 +236,9 @@ public class PlayerOsdController { setBottomRight(); setDiagnosticsPanel(player); setMiniProgress(player); - setMediaBadges(player); - // 常驻项(含媒体信息徽标)全不可见时不再保留一个空的 OSD 根 - boolean persistentVisible = topLeft.getVisibility() == View.VISIBLE - || topRight.getVisibility() == View.VISIBLE - || bottomLeft.getVisibility() == View.VISIBLE - || bottomRight.getVisibility() == View.VISIBLE - || diagnosticsPanel.getVisibility() == View.VISIBLE - || (mediaBadges != null && mediaBadges.getVisibility() == View.VISIBLE) - || (miniProgress != null && miniProgress.getVisibility() == View.VISIBLE); - root.setVisibility(persistentVisible ? View.VISIBLE : View.GONE); return true; } - /** - * 控制栏出现但 OSD 根被抑制(mobile 播放器)时的渲染路径。 - * 只放行媒体信息徽标,四角文本、诊断面板与迷你进度一律隐藏; - * 徽标自身的两条约束与常驻路径完全一致:必须全屏、且媒体信息总开关打开, - * 否则控制栏唤醒也不显示(「媒体信息位置 = 关闭」时正是这种情况)。 - */ - private boolean renderMediaBadgesOnly() { - if (!source.isFullscreen() || !PlayerSetting.isDisplayMediaBadges()) { - topLeft.setVisibility(View.GONE); - topRight.setVisibility(View.GONE); - bottomLeft.setVisibility(View.GONE); - bottomRight.setVisibility(View.GONE); - diagnosticsPanel.setVisibility(View.GONE); - if (miniProgress != null) miniProgress.setVisibility(View.GONE); - if (mediaBadges != null) { - mediaBadges.setVisibility(View.GONE); - mediaBadgeSignature = ""; - } - root.setVisibility(View.GONE); - return false; - } - topLeft.setVisibility(View.GONE); - topRight.setVisibility(View.GONE); - bottomLeft.setVisibility(View.GONE); - bottomRight.setVisibility(View.GONE); - diagnosticsPanel.setVisibility(View.GONE); - if (miniProgress != null) miniProgress.setVisibility(View.GONE); - setMediaBadges(source.getPlayer()); - boolean visible = mediaBadges != null && mediaBadges.getVisibility() == View.VISIBLE; - root.setVisibility(visible ? View.VISIBLE : View.GONE); - return visible; - } - private void setTopLeft(PlayerManager player) { boolean showTitle = PlayerSetting.isOsdTitle(); boolean showResolution = PlayerSetting.isOsdResolution(); @@ -454,470 +361,6 @@ public class PlayerOsdController { miniProgress.setVisibility(View.VISIBLE); } - /** - * 屏显媒体信息徽标:只显示播放器能直接证明的事实。 - * 每秒轮询都会走到这里,因此只有徽标文本发生变化时才重建子视图, - * 避免每秒钟反复 addView 触发测量与布局。 - */ - private void setMediaBadges(PlayerManager player) { - if (mediaBadges == null) return; - // 三条判断: - // 1. 只在全屏画面里出现——半屏/小窗里它会把本来就拥挤的控件堆得更挤; - // 2. 媒体信息总开关(设置页「媒体信息位置」)必须打开——选「关闭」时任何情况都不显示; - // 3. 控制栏唤醒时直接显示(用户要的就是唤醒时能看到), - // 控制栏隐藏时则看「屏显设置」里的常驻开关,两者互不牵连。 - if (player == null || !source.isFullscreen() || !PlayerSetting.isDisplayMediaBadges() - || (!controlsVisible && !PlayerSetting.isDisplayMediaBadgesPersistent())) { - mediaBadges.setVisibility(View.GONE); - mediaBadgeSignature = ""; - return; - } - Format video = player.getVideoFormat(); - PlayerEngine.VideoPlaybackDetails details = player.getVideoPlaybackDetails(); - AudioPlaybackDiagnostics.Snapshot audio = player.getAudioPlaybackDiagnostics(); - List badges = MediaBadgeResolver.resolve(video, details, audio == null ? null : audio.activeTrack(), player.getUrl(), source.getTitle()); - if (badges.isEmpty()) { - mediaBadges.setVisibility(View.GONE); - mediaBadgeSignature = ""; - return; - } - applyMediaBadgePosition(); - StringBuilder signature = new StringBuilder(); - for (MediaBadgeResolver.MediaBadge badge : badges) signature.append(badge.group()).append(':').append(badge.plate()).append(':').append(badge.text()).append('|'); - String next = signature.toString(); - if (!next.equals(mediaBadgeSignature)) { - mediaBadgeSignature = next; - mediaBadges.removeAllViews(); - for (MediaBadgeResolver.MediaBadge badge : badges) mediaBadges.addView(createMediaBadge(badge)); - } - mediaBadges.setVisibility(View.VISIBLE); - } - - /** - * 徽标贴角规则: - * 1. 控制栏唤醒时,控制栏占据画面顶部与底部,四角都要让开它的高度,否则会压住标题栏 / - * 进度条;这是唯一的「无条件内缩」场景。 - * 2. 控制栏隐藏时徽标是常驻项,此时只有在同一个角**确实还有别的常驻文本** - * (同角的标题 / 时间 / 进度 / 网速)时才抬高一行躲开它;没有邻居就直接贴到画面边缘, - * 这才是用户要的「最靠边缘」。 - * 3. 布局给四角留了内边距,而 margin 是从内沿开始量的;所以要把它折算掉,让「徽标到正下方 - * 障碍物」的肉眼间距无论控制栏在不在、手机还是电视,都正好等于 {@link #badgeGap()}。 - */ - private void applyMediaBadgePosition() { - if (mediaBadges == null) return; - ViewGroup.LayoutParams params = mediaBadges.getLayoutParams(); - if (!(params instanceof FrameLayout.LayoutParams)) return; - FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) params; - int position = PlayerSetting.getMediaBadgePosition(); - View neighbour = persistentNeighbour(position); - boolean stacked = !controlsVisible && neighbour != null; - // 邻居的实际高度 + 一点呼吸位;量不到时退回一条文本行的高度。 - int clear = (stacked ? Math.max(neighbour.getHeight(), dp(18)) : 0) + dp(4); - // 贴边 / 避让控制栏用的 margin:都把容器内边距折算掉,肉眼间距才等于 badgeGap()。 - // 用负 margin 去顶边缘会顶进系统栏导致胶囊被裁切,所以下限取 0。 - View badgeParent = mediaBadges.getParent() instanceof View ? (View) mediaBadges.getParent() : null; - int gap = badgeGap(); - int edgeTop = Math.max(0, gap - (badgeParent == null ? 0 : badgeParent.getPaddingTop())); - int edgeBottom = Math.max(0, gap - (badgeParent == null ? 0 : badgeParent.getPaddingBottom())); - int edgeStart = Math.max(0, gap - (badgeParent == null ? 0 : badgeParent.getPaddingLeft())); - int edgeEnd = Math.max(0, gap - (badgeParent == null ? 0 : badgeParent.getPaddingRight())); - // 控制栏唤醒时的避让:必须按控制栏的真实高度算,不能写死。 - // 电视端控制栏是底部一整条(按钮行 + 进度条),手机端高度也不同;写死 64/80dp 会让 - // 徽标落到按钮行上,看起来就像「被塞进了控制栏」。 - View bar = controlBar(); - observeControlBar(bar); - int barTop = controlBarTopInset(bar, edgeBottom); - int gravity; - int marginStart; - int marginEnd; - int topMargin; - int bottomMargin; - switch (position) { - case 0: - gravity = Gravity.TOP | Gravity.START; - marginStart = stacked ? 0 : edgeStart; - marginEnd = 0; - topMargin = controlsVisible ? dp(64) : (stacked ? clear : edgeTop); - bottomMargin = 0; - break; - case 1: - gravity = Gravity.TOP | Gravity.END; - marginStart = 0; - marginEnd = stacked ? 0 : edgeEnd; - topMargin = controlsVisible ? dp(64) : (stacked ? clear : edgeTop); - bottomMargin = 0; - break; - case 2: - gravity = Gravity.BOTTOM | Gravity.START; - marginStart = stacked ? 0 : edgeStart; - marginEnd = 0; - topMargin = 0; - bottomMargin = controlsVisible ? barTop : (stacked ? clear : edgeBottom); - break; - default: - gravity = Gravity.BOTTOM | Gravity.END; - marginStart = 0; - marginEnd = stacked ? 0 : edgeEnd; - topMargin = 0; - bottomMargin = controlsVisible ? barTop : (stacked ? clear : edgeBottom); - break; - } - if (layoutParams.gravity == gravity && layoutParams.topMargin == topMargin && layoutParams.bottomMargin == bottomMargin - && layoutParams.getMarginStart() == marginStart && layoutParams.getMarginEnd() == marginEnd) return; - layoutParams.gravity = gravity; - layoutParams.topMargin = topMargin; - layoutParams.bottomMargin = bottomMargin; - layoutParams.setMarginStart(marginStart); - layoutParams.setMarginEnd(marginEnd); - mediaBadges.setLayoutParams(layoutParams); - } - - /** - * 徽标与「正下方 / 正上方障碍物」之间要留的呼吸位。控制栏唤醒时是控制栏边缘, - * 控制栏隐藏且同角无邻居时是画面边缘;手机与电视共用同一个值。 - * - *

这个 16dp 是**扣除** app/src/main/res/layout/view_player_osd.xml 外层 FrameLayout 的 - * android:padding="14dp" 之后生效的肉眼间距;改那个 padding 会同步改变肉眼距离 - * ({@link #applyMediaBadgePosition()} 用 Math.max(0, gap - getPaddingXxx()) 折算)。 - */ - private int badgeGap() { - return dp(16); - } - - /** - * 下两角控制栏可见时的 bottomMargin:控制栏高度 + 呼吸位,再扣掉容器内边距。 - * 电视端是底部一整条(按钮行 + 进度条),手机端真正占位的只是它内部的 bottom 条; - * 写死数值会让徽标落到按钮行上,看起来像「被塞进控制栏」。 - * 量不到时退回旧常量,保证首帧不会跳到画面外。 - */ - private int controlBarTopInset(View bar, int edgeGap) { - if (bar == null || bar.getVisibility() != View.VISIBLE) return dp(80) + edgeGap; - // 手机端控制栏根是 match_parent(整屏铺底),真正占位的是它里面的 bottom 底部条; - // 电视端控制栏根本身就是底部一整条。两种都按「实际占位的那个」来量。 - // 手机端内联控制栏的底部条是 {@code @+id/bottom},电视端详情页内嵌播放器是 {@code @+id/playerBottom}。 - View bottom = bar.findViewById(R.id.bottom); - if (bottom == null) bottom = bar.findViewById(R.id.playerBottom); - int height = bottom != null && bottom.getVisibility() == View.VISIBLE && bottom.getHeight() > 0 - ? bottom.getHeight() - : bar.getHeight(); - if (height <= 0 || height >= root.getHeight()) return dp(80) + edgeGap; - return height + edgeGap; - } - - /** - * 控制栏与 OSD 是同一父容器下的兄弟节点,不是 OSD 的子节点,所以要从父容器里找。 - * 详情页内嵌播放器没有 {@code @+id/control}:手机端的内联控件容器是 - * {@code @+id/detailControlHost},电视端是 {@code @+id/playerControls}。按可见性挑第一个, - * 避免拿到 GONE 的那个容器从而错误回退 dp(80);都还没有时返回 null, - * 由 {@link #controlBarTopInset(View, int)} 回退 dp(80)。 - */ - private View controlBar() { - ViewParent parent = root.getParent(); - if (!(parent instanceof View)) return null; - View host = (View) parent; - return firstVisible(host.findViewById(R.id.control), host.findViewById(R.id.detailControlHost), host.findViewById(R.id.playerControls)); - } - - private static View firstVisible(View... candidates) { - for (View candidate : candidates) { - if (candidate != null && candidate.getVisibility() == View.VISIBLE) return candidate; - } - for (View candidate : candidates) { - if (candidate != null) return candidate; - } - return null; - } - - /** - * 控制栏唤醒的那一帧,它内部的按钮行 / 进度条可能还没完成布局,此时量到的高度偏小, - * 徽标就会压到按钮行上(看起来像被塞进了控制栏)。所以登记一次布局监听: - * 控制栏每次布局定稿后重算徽标位置,保证等到的永远是最终高度。 - */ - private void observeControlBar(View bar) { - if (bar == null || bar == observedControlBar) return; - stopObservingControlBar(); - observedControlBar = bar; - observedControlBarListener = (view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> - root.post(this::applyMediaBadgePosition); - bar.addOnLayoutChangeListener(observedControlBarListener); - } - - /** - * 注销控制栏布局监听,避免 OSD 释放后仍被控制栏布局回调持有(回调泄漏)。 - */ - private void stopObservingControlBar() { - if (observedControlBar != null && observedControlBarListener != null) { - observedControlBar.removeOnLayoutChangeListener(observedControlBarListener); - } - observedControlBar = null; - observedControlBarListener = null; - } - - /** - * 徽标所在的角在控制栏隐藏时是否还有别的常驻文本。只有此时才需要把徽标抬高 / 内缩, - * 否则应当贴住画面边缘(用户要的「最靠边缘」)。 - */ - private View persistentNeighbour(int position) { - switch (position) { - case 0: - return isShown(topLeft) ? topLeft : null; - case 1: - return isShown(topRight) ? topRight : null; - case 2: - return isShown(bottomLeft) ? bottomLeft : null; - default: - return isShown(bottomRight) ? bottomRight : null; - } - } - - private static boolean isShown(View view) { - return view != null && view.getVisibility() == View.VISIBLE; - } - - /** - * 一个徽标 = 成品牌 + 事实文本,整块包在分组色胶囊里。 - * 图形全部来自随包内置的参考实现牌(离线可用,不会出现首帧空白); - * 没有对应牌的纯数值事实(帧率、码率、容器、少见编码)只显示文本, - * 不再用语义无关的自绘占位图标,避免「图标和内容不搭边」。 - */ - private View createMediaBadge(MediaBadgeResolver.MediaBadge badge) { - Context context = root.getContext(); - boolean hasPlate = badge.plate() != null; - boolean hasText = badge.text() != null && !badge.text().isEmpty(); - LinearLayout view = new LinearLayout(context); - view.setOrientation(LinearLayout.HORIZONTAL); - view.setGravity(Gravity.CENTER_VERTICAL); - // 固定胶囊高度:有牌的徽标由 16dp 牌撑高,纯文本徽标只有一行 10sp 文本, - // 不固定高度时两者会差 4px 左右,一行里看起来参差不齐。 - FlexboxLayout.LayoutParams params = new FlexboxLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(20)); - params.setMargins(0, dp(2), dp(4), 0); - view.setLayoutParams(params); - view.setBackground(categoryBackground(badge.group())); - view.setPadding(dp(6), dp(2), dp(6), dp(2)); - - int plate = hasPlate ? plateResource(badge.plate()) : 0; - if (plate != 0) { - ImageView icon = new ImageView(context); - LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(16)); - if (hasText) iconParams.setMarginEnd(dp(4)); - icon.setLayoutParams(iconParams); - icon.setImageResource(plate); - icon.setAdjustViewBounds(true); - icon.setScaleType(ImageView.ScaleType.FIT_CENTER); - view.addView(icon); - } - - if (hasText) { - TextView text = new TextView(context); - text.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - text.setSingleLine(true); - text.setText(badge.text()); - text.setTextColor(0xFFFFFFFF); - text.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f); - text.setShadowLayer(2f, 1.2f, 1.2f, App.get().getResources().getColor(R.color.webhtv_color_player_scrim)); - view.addView(text); - } - return view; - } - - /** 类别色胶囊:左侧偏类别色、右侧压暗,既有颜色又保证任意画面下白字可读。 */ - private GradientDrawable categoryBackground(MediaBadgeCatalog.Group group) { - int color = mediaBadgeColor(group); - GradientDrawable background = new GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, new int[]{withAlpha(color, 0x8A), 0xD9040A12}); - background.setCornerRadius(dp(4)); - background.setStroke(dp(1), withAlpha(color, 0xE6)); - return background; - } - - private static int withAlpha(int color, int alpha) { - return (color & 0x00FFFFFF) | (alpha << 24); - } - - /** - * 每个分组的主题色。参考实现的 JSON 只给了黑白边框, - * 这里的彩色是本项目自己分的组,让一行里「分辨率/画质/编码/音轨/来源」一眼可分。 - */ - private static int mediaBadgeColor(MediaBadgeCatalog.Group group) { - switch (group) { - case RESOLUTION: - return 0xFF5A8DEE; - case DYNAMIC_RANGE: - return 0xFF7B61FF; - case VIDEO_TECH: - return 0xFF22D3EE; - case VIDEO_CODEC: - return 0xFF14B8A6; - case AUDIO: - return 0xFFEF4444; - case CHANNELS: - return 0xFFF97316; - case FRAME_RATE: - return 0xFF10B981; - case BITRATE: - return 0xFFF59E0B; - case CONTAINER: - return 0xFF64748B; - case MEDIA_SOURCE: - return 0xFF0EA5E9; - case QUALITY: - return 0xFFA855F7; - case EDITION: - return 0xFF8B5CF6; - case SPECIAL_TAG: - return 0xFFEC4899; - case IMAX: - return 0xFF2563EB; - case STREAMING: - return 0xFFDC2626; - default: - return 0xFF64748B; - } - } - - /** - * 牌资源映射:键与 {@link MediaBadgeCatalog} 的 plate 一一对应, - * 资源名规则为 {@code badge_}。这里写成显式常量引用, - * 保证 release 的资源压缩不会把只在运行时拼名字的牌删掉。 - */ - private static int plateResource(String plate) { - Integer resource = PLATES.get(plate); - return resource == null ? 0 : resource; - } - - private static final Map PLATES = plates(); - - private static Map plates() { - Map map = new HashMap<>(); - map.put("gst_seadex", R.drawable.badge_gst_seadex); - map.put("gst_hybrid", R.drawable.badge_gst_hybrid); - map.put("gst_criterion", R.drawable.badge_gst_criterion); - map.put("gst_proper", R.drawable.badge_gst_proper); - map.put("gst_repack", R.drawable.badge_gst_repack); - map.put("gst_remastered", R.drawable.badge_gst_remastered); - map.put("gst_open_matte", R.drawable.badge_gst_open_matte); - map.put("gst_regraded", R.drawable.badge_gst_regraded); - map.put("gst_directors_cut", R.drawable.badge_gst_directors_cut); - map.put("gst_extended", R.drawable.badge_gst_extended); - map.put("gst_uncut", R.drawable.badge_gst_uncut); - map.put("gst_uncensored", R.drawable.badge_gst_uncensored); - map.put("gst_black_and_white", R.drawable.badge_gst_black_and_white); - map.put("gst_true_hue", R.drawable.badge_gst_true_hue); - map.put("gst_theatrical", R.drawable.badge_gst_theatrical); - map.put("ged_cc", R.drawable.badge_ged_cc); - map.put("ged_hk", R.drawable.badge_ged_hk); - map.put("ged_tw", R.drawable.badge_ged_tw); - map.put("ged_jp", R.drawable.badge_ged_jp); - map.put("ged_us", R.drawable.badge_ged_us); - map.put("ged_uk", R.drawable.badge_ged_uk); - map.put("ged_de", R.drawable.badge_ged_de); - map.put("ged_fr", R.drawable.badge_ged_fr); - map.put("ged_eu", R.drawable.badge_ged_eu); - map.put("ged_kr", R.drawable.badge_ged_kr); - map.put("ged_arrow", R.drawable.badge_ged_arrow); - map.put("ged_dieying", R.drawable.badge_ged_dieying); - map.put("ged_allregion", R.drawable.badge_ged_allregion); - map.put("gms_iso", R.drawable.badge_gms_iso); - map.put("gms_high_bitrate", R.drawable.badge_gms_high_bitrate); - map.put("gms_remux_1", R.drawable.badge_gms_remux_1); - map.put("gms_remux_2", R.drawable.badge_gms_remux_2); - map.put("gms_remux_3", R.drawable.badge_gms_remux_3); - map.put("gms_blu_ray_1", R.drawable.badge_gms_blu_ray_1); - map.put("gms_blu_ray_2", R.drawable.badge_gms_blu_ray_2); - map.put("gms_blu_ray_3", R.drawable.badge_gms_blu_ray_3); - map.put("gms_blu_ray_4", R.drawable.badge_gms_blu_ray_4); - map.put("gms_blu_ray_5", R.drawable.badge_gms_blu_ray_5); - map.put("gms_blu_ray_6", R.drawable.badge_gms_blu_ray_6); - map.put("gms_blu_ray_7", R.drawable.badge_gms_blu_ray_7); - map.put("gms_blu_ray_8", R.drawable.badge_gms_blu_ray_8); - map.put("gms_web_1", R.drawable.badge_gms_web_1); - map.put("gms_web_2", R.drawable.badge_gms_web_2); - map.put("gms_web_3", R.drawable.badge_gms_web_3); - map.put("gms_web_4", R.drawable.badge_gms_web_4); - map.put("gms_web_5", R.drawable.badge_gms_web_5); - map.put("gms_web_6", R.drawable.badge_gms_web_6); - map.put("gr_8k", R.drawable.badge_gr_8k); - map.put("gr_4k", R.drawable.badge_gr_4k); - map.put("gr_1440p", R.drawable.badge_gr_1440p); - map.put("gr_1080p", R.drawable.badge_gr_1080p); - map.put("gr_720p", R.drawable.badge_gr_720p); - map.put("gr_576p", R.drawable.badge_gr_576p); - map.put("gr_480p", R.drawable.badge_gr_480p); - map.put("gr_360p", R.drawable.badge_gr_360p); - map.put("gr_240p", R.drawable.badge_gr_240p); - map.put("gr_144p", R.drawable.badge_gr_144p); - map.put("gq_remux", R.drawable.badge_gq_remux); - map.put("gq_bluray", R.drawable.badge_gq_bluray); - map.put("gq_web_dl", R.drawable.badge_gq_web_dl); - map.put("gq_webrip", R.drawable.badge_gq_webrip); - map.put("gq_hdrip", R.drawable.badge_gq_hdrip); - map.put("gq_hc_hdrip", R.drawable.badge_gq_hc_hdrip); - map.put("gq_dvdrip", R.drawable.badge_gq_dvdrip); - map.put("gq_hdtv", R.drawable.badge_gq_hdtv); - map.put("gq_scr", R.drawable.badge_gq_scr); - map.put("gq_tc", R.drawable.badge_gq_tc); - map.put("gq_ts", R.drawable.badge_gq_ts); - map.put("gq_cam", R.drawable.badge_gq_cam); - map.put("imax_imax_enhanced", R.drawable.badge_imax_imax_enhanced); - map.put("imax_imax", R.drawable.badge_imax_imax); - map.put("gv_dolby_vision_hdr10_plus", R.drawable.badge_gv_dolby_vision_hdr10_plus); - map.put("gv_dolby_vision_hdr10", R.drawable.badge_gv_dolby_vision_hdr10); - map.put("gv_dolby_vision_hdr", R.drawable.badge_gv_dolby_vision_hdr); - map.put("gv_12bit", R.drawable.badge_gv_12bit); - map.put("gv_8bit", R.drawable.badge_gv_8bit); - map.put("gvf_hdrvivid", R.drawable.badge_gvf_hdrvivid); - map.put("gvf_3d", R.drawable.badge_gvf_3d); - map.put("gv_dolby_vision", R.drawable.badge_gv_dolby_vision); - map.put("gv_hdr10_plus", R.drawable.badge_gv_hdr10_plus); - map.put("gv_hdr10", R.drawable.badge_gv_hdr10); - map.put("gv_hdr", R.drawable.badge_gv_hdr); - map.put("gv_hlg", R.drawable.badge_gv_hlg); - map.put("gv_10bit", R.drawable.badge_gv_10bit); - map.put("gv_sdr", R.drawable.badge_gv_sdr); - map.put("gv_ai", R.drawable.badge_gv_ai); - map.put("ga_dolby_atmos_truehd", R.drawable.badge_ga_dolby_atmos_truehd); - map.put("ga_dts_x_hd_ma", R.drawable.badge_ga_dts_x_hd_ma); - map.put("ga_dolby_atmos_digital_plus", R.drawable.badge_ga_dolby_atmos_digital_plus); - map.put("ga_dts_x_hd", R.drawable.badge_ga_dts_x_hd); - map.put("ga_dolby_atmos", R.drawable.badge_ga_dolby_atmos); - map.put("ga_dts_x", R.drawable.badge_ga_dts_x); - map.put("ga_dolby_truehd", R.drawable.badge_ga_dolby_truehd); - map.put("ga_dts_hd_ma", R.drawable.badge_ga_dts_hd_ma); - map.put("ga_flac", R.drawable.badge_ga_flac); - map.put("ga_dts_hd", R.drawable.badge_ga_dts_hd); - map.put("ga_dolby_digital_plus", R.drawable.badge_ga_dolby_digital_plus); - map.put("ga_dts_es", R.drawable.badge_ga_dts_es); - map.put("ga_dts", R.drawable.badge_ga_dts); - map.put("ga_opus", R.drawable.badge_ga_opus); - map.put("ga_dolby_digital", R.drawable.badge_ga_dolby_digital); - map.put("ga_aac", R.drawable.badge_ga_aac); - map.put("ga_lpcm", R.drawable.badge_ga_lpcm); - map.put("ga_lossless", R.drawable.badge_ga_lossless); - map.put("gc_7_1", R.drawable.badge_gc_7_1); - map.put("gc_6_1", R.drawable.badge_gc_6_1); - map.put("gc_5_1", R.drawable.badge_gc_5_1); - map.put("gc_2_0", R.drawable.badge_gc_2_0); - map.put("gc_7_1_4", R.drawable.badge_gc_7_1_4); - map.put("gc_5_1_4", R.drawable.badge_gc_5_1_4); - map.put("gc_5_1_2", R.drawable.badge_gc_5_1_2); - map.put("ge_av1", R.drawable.badge_ge_av1); - map.put("ge_vvc", R.drawable.badge_ge_vvc); - map.put("gfr_hfr", R.drawable.badge_gfr_hfr); - map.put("ge_hevc", R.drawable.badge_ge_hevc); - map.put("ge_avc", R.drawable.badge_ge_avc); - map.put("ge_xvid", R.drawable.badge_ge_xvid); - map.put("ge_divx", R.drawable.badge_ge_divx); - map.put("gs_peacock", R.drawable.badge_gs_peacock); - map.put("gs_netflix", R.drawable.badge_gs_netflix); - map.put("gs_prime_video", R.drawable.badge_gs_prime_video); - map.put("gs_apple_tv_plus", R.drawable.badge_gs_apple_tv_plus); - map.put("gs_disney_plus", R.drawable.badge_gs_disney_plus); - map.put("gs_hbo_max", R.drawable.badge_gs_hbo_max); - map.put("gs_hulu", R.drawable.badge_gs_hulu); - map.put("gs_paramount_plus", R.drawable.badge_gs_paramount_plus); - map.put("gs_crunchyroll", R.drawable.badge_gs_crunchyroll); - return map; - } - private void setTextSize(float sp) { topLeft.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp); topLeft.setTextColor(0xFFFFFFFF); @@ -939,7 +382,6 @@ public class PlayerOsdController { bottomLeft.setVisibility(View.GONE); bottomRight.setVisibility(View.GONE); miniProgress.setVisibility(View.GONE); - if (mediaBadges != null) mediaBadges.setVisibility(View.GONE); } private void updateSpeed(PlayerManager player) { @@ -961,11 +403,7 @@ public class PlayerOsdController { Format audio = snapshot.audioFormat(); String state = stateText(player.getPlaybackState()) + (player.isLoading() ? " / 正在加载" : ""); String buffer = join(" / ", formatDuration(player.getBufferedDuration()), player.getBufferedPercentage() > 0 ? player.getBufferedPercentage() + "%" : ""); - // 只有 Exo 有 analytics 快照;IJK/MPV 走的是 PlayerManager 的引擎无关缓冲追踪器。 - // 原先只对 MPV 分支,IJK 因此落到 Exo 分支读到 Snapshot.empty(),重缓冲计数恒为 0。 - int rebufferCount = player.isExo() ? snapshot.rebufferCount() : player.getRebufferCount(); - long rebufferTotalMs = player.isExo() ? snapshot.rebufferTotalMs() : player.getRebufferTotalMs(); - String rebuffer = rebufferCount <= 0 ? "0 次" : rebufferCount + " 次 / " + formatDuration(rebufferTotalMs); + String rebuffer = snapshot.rebufferCount() <= 0 ? "0 次" : snapshot.rebufferCount() + " 次 / " + formatDuration(snapshot.rebufferTotalMs()); long stableThroughput = player.getNetworkProtectionStableThroughput(); long consumption = player.getNetworkProtectionConsumption(); String networkProtection = player.getNetworkProtectionText(); @@ -993,25 +431,20 @@ public class PlayerOsdController { String frameTiming = player.isExo() ? summarizeFrameTiming() : ""; PlayerEngine.VideoPlaybackDetails videoDetails = player.getVideoPlaybackDetails(); - VideoTrackState videoTrack = getVideoTrackState(player); String videoText = summarizeVideo(video, player, - snapshot.videoDecoderName(), videoTrack, + snapshot.videoDecoderName(), getVideoTrackState(player), videoDetails); AudioTrackState audioTrack = getAudioTrackState(player); - AudioPlaybackDiagnostics.Snapshot audioDetails = player.getAudioPlaybackDiagnostics(); - String runtimeAudio = AudioPlaybackDiagnostics.format(audioDetails); - String audioText = TextUtils.isEmpty(runtimeAudio) - ? summarizeAudio(audio, audioTrack, snapshot.audioDecoderName()) - : runtimeAudio; + String audioText = summarizeAudio(audio, audioTrack, snapshot.audioDecoderName()); String render = PlayerSetting.getRender() == PlayerSetting.RENDER_SURFACE ? "Surface" : "Texture"; String tunnel = switchText(PlayerSetting.isTunnelingEnabled()); - String performance = PlaybackPerformanceSetting.getProfileName(player.getPlayerType()); + String performance = PlaybackPerformanceSetting.getProfileName(); String passThrough = player.getAudioPassThroughText(); - String preload = "预载" + switchText(PreloadSetting.isPreload(player.getPlayerType())); + String preload = "预载" + switchText(PreloadSetting.isPreload()); String frameRateMatch = player.isExo() ? "帧率匹配 开" : ""; String softTune = getSoftDecodeTuneText(player); - String playerText = join(" / ", player.getPlayerText(), player.getDecodeText(), render, "隧道" + tunnel, "性能" + performance, frameRateMatch, preload, softTune, player.isExo() ? "兜底开" : ""); - String playback = join(" / ", state, buffer, "重缓冲 " + rebuffer, droppedFrameText(player)); + String playerText = join(" / ", player.getPlayerText(), player.getDecodeText(), render, "隧道" + tunnel, "性能" + performance, frameRateMatch, preload, "直通" + passThrough, softTune, player.isExo() ? "兜底开" : ""); + String playback = join(" / ", state, buffer, "重缓冲 " + rebuffer, "掉帧 " + player.getDroppedFrames()); String startup = getStartupText(player); String error = getErrorText(player, snapshot); String main = join("\n", @@ -1029,7 +462,7 @@ public class PlayerOsdController { row("播放", playback), row("配置", playerText), TextUtils.isEmpty(startup) ? "" : row("起播", startup), - row("结论", getDiagnosis(player, snapshot, video, videoTrack, audioTrack, audioDetails, localSource))); + row("结论", getDiagnosis(player, snapshot, video, audioTrack, localSource))); String extra = join("\n", row("设备", getDeviceText()), row("系统", getSystemText()), @@ -1051,46 +484,12 @@ public class PlayerOsdController { return TextUtils.isEmpty(slowest) ? summary : summary + " 最慢 " + slowest; } - /** - * "掉帧 12(1.4/秒)" for EXO. The rate is what the number should be read against: media3 - * settles a renderer's dropped-frame accumulator from onStopped(), so a pause adds - * whatever tail accumulated while playing and a bare total looks like pause damage. - * MPV and the other engines keep reporting their own raw counter unchanged. - */ - private String droppedFrameText(PlayerManager player) { - long dropped = player.getDroppedFrames(); - if (!player.isExo()) return "掉帧 " + dropped; - double rate = PlaybackAnalyticsListener.droppedFramesPerSecond(); - return "掉帧 " + dropped + "(" + frameDropRateFormat.format(rate) + "/秒)"; - } - - /** - * Whether the drop count is worth a verdict. The raw total is only comparable to a fixed - * budget while there is no measured play time to divide by; after that the rate decides, - * so a pause that settles the accumulator no longer reads as decoder pressure. - */ - private boolean excessiveDroppedFrames(PlayerManager player) { - if (!player.isExo()) return player.getDroppedFrames() >= DROPPED_FRAMES_RAW_COUNT_DIAGNOSIS; - long playTimeMs = PlaybackAnalyticsListener.playTimeMs(); - if (playTimeMs < DROPPED_FRAMES_RATE_MIN_PLAY_TIME_MS) { - return player.getDroppedFrames() >= DROPPED_FRAMES_RAW_COUNT_DIAGNOSIS; - } - return PlaybackAnalyticsListener.droppedFramesPerSecond() >= DROPPED_FRAMES_PER_SECOND_DIAGNOSIS; - } - - private String getDiagnosis(PlayerManager player, PlaybackAnalyticsListener.Snapshot snapshot, Format video, VideoTrackState videoTrack, AudioTrackState audioTrack, AudioPlaybackDiagnostics.Snapshot audioDetails, boolean localSource) { - if (audioDetails.runtimeState() == AudioPlaybackDiagnostics.RuntimeState.FAILED) return "音频链路失败,先看音频和错误行"; + private String getDiagnosis(PlayerManager player, PlaybackAnalyticsListener.Snapshot snapshot, Format video, AudioTrackState audioTrack, boolean localSource) { if (isDecodeError(snapshot) && player.isHardDecode()) return "硬件解码失败:设备可能不支持该视频编码、分辨率、帧率或规格"; if (!TextUtils.isEmpty(snapshot.errorCode())) return "播放器报错,先看错误行"; if (audioTrack.hasTracks() && audioTrack.isUnsupported()) return "音频轨不支持:" + summarizeAudioFormat(audioTrack.format()) + " / " + supportText(audioTrack.support()); if (player.isExo() && audioTrack.hasTracks() && !audioTrack.selected() && snapshot.audioFormat() == null && player.getPlaybackState() == androidx.media3.common.Player.STATE_READY) return "已发现音轨但未选中,可能无声"; - // A video track without any usable decoder is dropped silently: playback - // keeps advancing with audio and subtitles only, so nothing else in this - // panel would flag it and the verdict would read as healthy. "Handled" is - // the test rather than the unsupported codes alone: EXCEEDS_CAPABILITIES - // drops the track the same way, and it is one of the two states - // PlayerManager.needsSoftDecode reacts to. - if (videoTrack.hasTracks() && !videoTrack.selected() && !videoTrack.isHandled()) return videoDiagnosis(player, videoTrack); + if (player.isExo() && audioTrack.hasTracks() && TextUtils.isEmpty(snapshot.audioDecoderName()) && player.getPlaybackState() == androidx.media3.common.Player.STATE_READY) return "已发现音轨但 decoder 未初始化,可能无声"; if (!localSource) { long mediaBitrate = getMediaBitrate(video, snapshot.audioFormat() != null ? snapshot.audioFormat() : audioTrack.format()); long availableBitrate = snapshot.bandwidthEstimate() > 0 ? snapshot.bandwidthEstimate() : lastSpeedKBps * 1024 * 8; @@ -1099,38 +498,12 @@ public class PlayerOsdController { // disk ranges, which reads high enough that this hint could never fire. if (player.isLoading() && player.getNativeBufferedDuration() < 3000) return "缓冲偏少,可能是网络或源响应慢"; } - if (excessiveDroppedFrames(player)) return "掉帧较多,可能是解码或渲染压力"; + if (player.getDroppedFrames() >= 60) return "掉帧较多,可能是解码或渲染压力"; if (!localSource && formatBitrateValue(video) >= 30_000_000) return "资源码率较高,对网络和解码要求高"; - // Use the same observed output as the audio row. A missing decoder name - // is not a failure signal, and encoded output does not need an app decoder. - if (audioDetails.runtimeState() == AudioPlaybackDiagnostics.RuntimeState.ACTIVE) { - return switch (audioDetails.outputMode()) { - case PASSTHROUGH -> "音频直通,无需 App 解码器"; - case COMPRESSED_DIRECT -> "音频压缩直出,由音频设备解码"; - case OFFLOAD -> "音频卸载,由音频设备解码"; - case PCM -> TextUtils.isEmpty(audioDetails.decoderName()) - ? "PCM 输出已建立,解码器名称未上报" : "正常"; - case UNKNOWN -> "音频输出状态待确认"; - }; - } if (player.isExo() && audioTrack.hasTracks() && snapshot.audioFormat() == null) return "正在等待音频轨信息"; - if (audioTrack.hasTracks() || audioDetails.runtimeState() == AudioPlaybackDiagnostics.RuntimeState.PENDING) return "音频输出状态待确认"; return "正常"; } - /** - * The verdict for a video track no renderer can decode. The support code is spelled out the - * way the audio rows and the video summary row already do it, and an encrypted track does not - * get the "switch to software decode" advice: no decoder choice fixes a DRM rejection. - */ - private String videoDiagnosis(PlayerManager player, VideoTrackState videoTrack) { - String support = supportText(videoTrack.support()); - if (videoTrack.support() == C.FORMAT_UNSUPPORTED_DRM) return "视频轨" + support + ",加密轨请更换资源"; - return player.isHardDecode() - ? "视频轨无可用解码器:" + support + ",请切软解或更换资源" - : "视频轨无可用解码器:" + support + ",请更换资源"; - } - private String getSystemGpuText() { GpuLoadMonitor.Snapshot system = GpuLoadMonitor.process().snapshot(); if (!system.available()) return ""; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java index d459127259..8958aab80a 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java @@ -6,12 +6,9 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.RelativeLayout; -import android.widget.TextView; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.ViewEmptyBinding; import com.fongmi.android.tv.databinding.ViewProgressBinding; -import com.fongmi.android.tv.plugin.PluginHostEvents; import java.util.ArrayList; import java.util.List; @@ -21,20 +18,12 @@ public class ProgressLayout extends RelativeLayout { private static final String TAG_PROGRESS = "ProgressLayout.TAG_PROGRESS"; public enum State { - CONTENT, PROGRESS, EMPTY, ERROR + CONTENT, PROGRESS, EMPTY } private List mContentViews; private View mProgressView; private View mEmptyView; - private int mEmptyTitleRes; - private int mEmptyDescriptionRes; - private int mEmptyActionRes; - private OnClickListener mEmptyActionListener; - private int mErrorTitleRes; - private CharSequence mErrorReason; - private int mErrorRetryRes; - private OnClickListener mErrorRetryListener; private State mState; public ProgressLayout(Context context) { @@ -62,140 +51,6 @@ public class ProgressLayout extends RelativeLayout { mEmptyView.setTag(TAG_PROGRESS); mEmptyView.setVisibility(GONE); addView(mEmptyView, centerParams()); - applyEmptyContent(); - } - /** - * 配置空态内容。 - * - *

设计文档 §9.9 要求空态是「标题 + 解释 + 一个主动作」,并明确「不能只显示一句 - * '暂无数据'」。本方法让宿主按需接入:不调用时渲染结果与改动前完全一致 - * (只有标题、解释与动作保持 GONE),因此既有调用点无需改动。 - * - *

可在任意时刻调用(早于首次显示也可以):空态视图尚未创建时会先记住参数, - * 在创建时应用。 - * - * @param titleRes 标题;传 0 表示沿用布局里的默认文案 - * @param descriptionRes 解释;传 0 表示不显示 - * @param actionRes 主按钮文案;传 0 或不传监听器表示不显示按钮 - * @param listener 主按钮动作;为空则不显示按钮(避免出现点了没反应的死按钮) - */ - public void setEmptyContent(int titleRes, int descriptionRes, int actionRes, OnClickListener listener) { - mEmptyTitleRes = titleRes; - mEmptyDescriptionRes = descriptionRes; - mEmptyActionRes = actionRes; - mEmptyActionListener = listener; - applyEmptyContent(); - } - - private void applyEmptyContent() { - if (mEmptyView == null) return; - // 用 findViewById 而不是 ViewEmptyBinding 的字段:view_empty.xml 是**分 flavor** 的, - // mobile 版没有 description/action 两个控件,而本类在 main 源集。直接引用 binding - // 字段会让 mobile 编译失败;这里按 id 查找并对缺失做空值守卫,于是 TV 端拿到完整空态、 - // 手机端保持原样(传进来的内容会被静默忽略)。 - View illustration = mEmptyView.findViewById(R.id.illustration); - TextView title = mEmptyView.findViewById(R.id.text); - TextView description = mEmptyView.findViewById(R.id.description); - TextView action = mEmptyView.findViewById(R.id.action); - // 从错误态回到空态时要把外观复原:插画显示、标题回到常规前景色。 - if (illustration != null) illustration.setVisibility(VISIBLE); - if (title != null) { - title.setTextColor(themeColor(R.attr.webhtvColorOnWallpaper)); - if (mEmptyTitleRes != 0) title.setText(mEmptyTitleRes); - } - if (description != null) { - if (mEmptyDescriptionRes != 0) { - description.setText(mEmptyDescriptionRes); - description.setVisibility(VISIBLE); - } else { - description.setVisibility(GONE); - } - } - if (action != null) { - // 没有动作就不显示按钮:避免出现点了没反应的死按钮。 - if (mEmptyActionRes != 0 && mEmptyActionListener != null) { - action.setText(mEmptyActionRes); - action.setOnClickListener(mEmptyActionListener); - action.setVisibility(VISIBLE); - } else { - action.setOnClickListener(null); - action.setVisibility(GONE); - } - } - } - - /** - * 进入错误态:设计文档 §9.9「错误态:错误色 + 原因 + 重试动作」。 - * - *

复用空态的三个槽位(标题 / 解释 / 主动作),只做两处区分:标题改用 - * {@code ?attr/webhtvColorOnWallpaperError},并隐藏插画——空态那张"空箱子"表达的是 - * "这里没有内容",用在错误上会误导。 - * - *

原因正文刻意**不**用错误色:错误前景色在壁纸上是 4.11:1(只够 Display 档大字号), - * 而原因是 16sp 正文,需要 4.5:1;它沿用空态的次级壁纸前景色。 - * - * @param titleRes 错误标题;传 0 沿用当前标题 - * @param reasonRes 失败原因;传 0 不显示 - * @param retryRes 重试按钮文案;传 0 或不传监听器则不显示按钮 - * @param retry 重试动作 - */ - public void showError(int titleRes, int reasonRes, int retryRes, OnClickListener retry) { - showError(titleRes, reasonRes == 0 ? null : getContext().getString(reasonRes), retryRes, retry); - } - - /** - * 同上,但原因来自运行时字符串(例如服务端返回的 message)。 - */ - public void showError(int titleRes, CharSequence reason, int retryRes, OnClickListener retry) { - mErrorTitleRes = titleRes; - mErrorReason = reason; - mErrorRetryRes = retryRes; - mErrorRetryListener = retry; - // 已经在错误态时 switchState 会提前返回,但重试再次失败需要刷新原因文案, - // 所以这里显式重放一次。 - if (mState == State.ERROR) applyErrorContent(); - else switchState(State.ERROR); - } - - public boolean isError() { - return mState == State.ERROR; - } - - private void applyErrorContent() { - if (mEmptyView == null) return; - View illustration = mEmptyView.findViewById(R.id.illustration); - TextView title = mEmptyView.findViewById(R.id.text); - TextView description = mEmptyView.findViewById(R.id.description); - TextView action = mEmptyView.findViewById(R.id.action); - if (illustration != null) illustration.setVisibility(GONE); - if (title != null) { - title.setTextColor(themeColor(R.attr.webhtvColorOnWallpaperError)); - if (mErrorTitleRes != 0) title.setText(mErrorTitleRes); - } - if (description != null) { - if (mErrorReason != null && mErrorReason.length() > 0) { - description.setText(mErrorReason); - description.setVisibility(VISIBLE); - } else { - description.setVisibility(GONE); - } - } - if (action != null) { - if (mErrorRetryRes != 0 && mErrorRetryListener != null) { - action.setText(mErrorRetryRes); - action.setOnClickListener(mErrorRetryListener); - action.setVisibility(VISIBLE); - } else { - action.setOnClickListener(null); - action.setVisibility(GONE); - } - } - } - - /** 解析主题属性里的颜色值;未赋值时返回 {@code 0}(透明),调用方自行决定是否使用。 */ - private int themeColor(int attr) { - android.util.TypedValue value = new android.util.TypedValue(); - return getContext().getTheme().resolveAttribute(attr, value, true) ? value.data : 0; } private void ensureProgressView() { @@ -265,15 +120,11 @@ public class ProgressLayout extends RelativeLayout { public void switchState(State state) { if (mState == state) return; mState = state; - applyScrim(state); switch (state) { case CONTENT: if (mEmptyView != null) mEmptyView.setVisibility(GONE); if (mProgressView != null) mProgressView.setVisibility(GONE); setContentVisibility(true); - // 宿主内容可见的埋点。本类被 Home/File/History/Keep/Video 等多页共用, - // 因此事件只带 Activity 身份,由订阅者按类型过滤(详见 PluginEventBus 类注释)。 - PluginHostEvents.uiContentShown(this); break; case PROGRESS: ensureProgressView(); @@ -283,45 +134,10 @@ public class ProgressLayout extends RelativeLayout { break; case EMPTY: ensureEmptyView(); - // 从错误态过来时要把外观复原(插画显示、标题回到常规前景色), - // 所以进 EMPTY 时重新应用一次空态内容。 - applyEmptyContent(); mEmptyView.setVisibility(VISIBLE); if (mProgressView != null) mProgressView.setVisibility(GONE); setContentVisibility(false); break; - case ERROR: - ensureEmptyView(); - applyErrorContent(); - mEmptyView.setVisibility(VISIBLE); - if (mProgressView != null) mProgressView.setVisibility(GONE); - setContentVisibility(false); - break; - } - } - - /** - * 空态/错误态给浮层加 scrim,其余状态清掉。 - * - *

为什么必须加。设计文档 §5.3 要求「图片上的文字必须有 scrim/gradient」、 - * §5.4 要求「所有内容表面必须有不透明或可控的 surface 层」。而空态/错误态的文字此前 - * **直接压在壁纸(一张图)上、没有任何 scrim**——这违反那两条,不只是对比度差一点。 - * - *

而且靠调色无解:实测壁纸合成亮度 L=0.1217/0.1476/0.2019,正文(16sp)门槛 4.5:1, - * 80% 白文字只有 4.61/4.08/3.30:1;**即使换成纯白,最亮壁纸上限也只有 4.17:1**, - * 即 4.5:1 物理上不可达。加 scrim 后(overlay_dark ≈32% 黑)为 6.02/5.43/4.53:1,全部达标。 - * - *

加在 {@code ProgressLayout} 自身而不是 {@code view_empty.xml} 的内容块上: - * 空态视图是**居中摆放的 wrap_content 内容块**({@code centerParams()}), - * 给它加背景会变成"文字后面一块小方框";本类是 match_parent 的宿主层,盖的是整屏。 - * - *

PROGRESS 态刻意**不加**:它是短暂的加载瞬间,加 scrim 会让页面闪一下暗。 - */ - private void applyScrim(State state) { - if (state == State.EMPTY || state == State.ERROR) { - setBackgroundColor(themeColor(R.attr.webhtvColorOverlayDark)); - } else { - setBackground(null); } } @@ -333,26 +149,12 @@ public class ProgressLayout extends RelativeLayout { } private void showView(View view) { - // 幂等:已经完整可见时不再重放淡入。 - // - // 为什么必须幂等。switchState(CONTENT) 会在 EMPTY/ERROR ↔ CONTENT 之间被反复调用, - // 而每次切换都会走一遍 setContentVisibility(true)。旧实现每次都 setAlpha(0f) 再 - // new 一个 ViewPropertyAnimator,两步都会请求一次绘制;加载圈期间主线程本来就紧绷 - // (详情页/播放页要 inflate、解析、建 ExoPlayer),这些无效的 alpha 动画会把转圈 - // 的帧挤掉——用户看到的就是「转圈卡住不动再恢复」。 - if (view.getVisibility() == VISIBLE && view.getAlpha() == 1f) return; - // 取消上一轮可能还在跑的淡入,避免多个 animator 同时写同一个 alpha。 - view.animate().cancel(); view.setAlpha(0f); view.setVisibility(VISIBLE); view.animate().alpha(1f).setDuration(100); } private void hideView(View view) { - if (view.getVisibility() == INVISIBLE) return; - // 取消未完成的淡入:否则它仍会把 alpha 推到 1,下一次 show 又从 0 重来, - // 同样是一帧白干的请求。 - view.animate().cancel(); view.setVisibility(INVISIBLE); } } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/TmdbHeaderView.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/TmdbHeaderView.java index b6ffd0e16c..9416ff914f 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/TmdbHeaderView.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/TmdbHeaderView.java @@ -110,8 +110,6 @@ public class TmdbHeaderView { default void onRematchLongClick() { onRematch(); } void onKeep(); - - void onFollowing(); } private final Activity activity; @@ -232,20 +230,6 @@ public class TmdbHeaderView { keep.setText(TmdbDetailLabels.keepLabel(selected)); } - public void setFollowingSelected(boolean selected) { - if (headerRoot == null) return; - MaterialButton following = headerRoot.findViewById(R.id.tmdbFollowing); - if (following == null) return; - following.setSelected(selected); - following.setText(ResUtil.getString(selected ? R.string.following_added : R.string.following_add)); - } - - public void setFollowingVisible(boolean visible) { - if (headerRoot == null) return; - View following = headerRoot.findViewById(R.id.tmdbFollowing); - if (following != null) following.setVisibility(visible ? View.VISIBLE : View.GONE); - } - /** * 绑定 TMDB 数据并填充视图。 */ @@ -668,9 +652,6 @@ public class TmdbHeaderView { headerRoot.findViewById(R.id.tmdbKeep).setOnClickListener(view -> { if (actionListener != null) actionListener.onKeep(); }); - headerRoot.findViewById(R.id.tmdbFollowing).setOnClickListener(view -> { - if (actionListener != null) actionListener.onFollowing(); - }); } private void updateOriginalEnhancedActionVisibility() { @@ -1470,11 +1451,6 @@ public class TmdbHeaderView { chip.setOrientation(androidx.appcompat.widget.LinearLayoutCompat.VERTICAL); chip.setGravity(android.view.Gravity.CENTER); chip.setPadding(28, 14, 28, 14); - // “评分与数据”卡片必须可被遥控/键盘选中:此前是纯装饰 LinearLayout, - // 上下键会整行跳过,与 TV 播放页保持同一行为。 - chip.setFocusable(true); - chip.setFocusableInTouchMode(false); - chip.setClickable(true); android.graphics.drawable.GradientDrawable background = new android.graphics.drawable.GradientDrawable(); background.setCornerRadius(ResUtil.dp2px(6)); @@ -1506,28 +1482,9 @@ public class TmdbHeaderView { boolean lightChrome = isLightDetailChrome() || (fusion && !isDarkDetailTheme()); boolean lightSurface = !backdropSurfaceMode && lightChrome; styleSourceRatingChip(chip, fusion, lightChrome, lightSurface); - chip.setOnFocusChangeListener((view, focused) -> { - if (focused) applyUnifiedRatingChipFocus(view); - else styleSourceRatingChip((ViewGroup) view, Setting.isFusionDetailPage(), - isLightDetailChrome() || (Setting.isFusionDetailPage() && !isDarkDetailTheme()), - !backdropSurfaceMode && (isLightDetailChrome() || (Setting.isFusionDetailPage() && !isDarkDetailTheme()))); - }); return chip; } - /** - * 统一焦点环:3dp @color/tv_item_focus_ring,与 selector_video_item.xml 同一套取值。 - * 失焦时由 styleSourceRatingChip 恢复当前主题的常态外观,不改变既有亮/暗/融合配色。 - */ - private void applyUnifiedRatingChipFocus(View chip) { - if (!(chip instanceof ViewGroup group)) return; - android.graphics.drawable.GradientDrawable background = new android.graphics.drawable.GradientDrawable(); - background.setColor(backdropSurfaceMode ? COLOR_BACKDROP_SURFACE_CONTROL_BG : 0x26FFFFFF); - background.setCornerRadius(ResUtil.dp2px(6)); - background.setStroke(ResUtil.dp2px(3), activity.getColor(R.color.tv_item_focus_ring)); - group.setBackground(background); - } - private void styleRatingChipContainer(ViewGroup container) { if (container == null) return; boolean fusion = Setting.isFusionDetailPage(); @@ -2062,7 +2019,6 @@ public class TmdbHeaderView { private void tintActions(int style) { tintAction(R.id.tmdbChangeSource, style); tintAction(R.id.tmdbKeep, style); - tintAction(R.id.tmdbFollowing, style); tintAction(R.id.tmdbRematch, style); tintAction(R.id.tmdbThemeToggle, style); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/BaseTmdbDetailModeController.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/BaseTmdbDetailModeController.java index 138e4678d9..6ffe1f3775 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/BaseTmdbDetailModeController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/BaseTmdbDetailModeController.java @@ -35,11 +35,6 @@ public abstract class BaseTmdbDetailModeController implements TmdbDetailModeCont // 默认空实现,子类按需覆盖 } - @Override - public void play() { - host.playDefaultPlayback(); - } - @Override public boolean shouldShowInlinePlayer() { return showInlinePlayer(); @@ -50,26 +45,11 @@ public abstract class BaseTmdbDetailModeController implements TmdbDetailModeCont return autoPlay(); } - @Override - public boolean shouldBindPlaybackService() { - return shouldShowInlinePlayer() || shouldAutoPlay(); - } - - @Override - public boolean shouldPublishPlaybackHistory() { - return shouldBindPlaybackService(); - } - @Override public boolean handleBack() { return false; // 默认不处理,交给 Activity } - @Override - public void onExitFullscreen() { - // Most modes keep the embedded player after leaving fullscreen. - } - @Override public void release() { // 默认空实现,子类按需覆盖 diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/DetailModeHost.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/DetailModeHost.java index 394e32dd12..6273cfc222 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/DetailModeHost.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/DetailModeHost.java @@ -22,15 +22,4 @@ public interface DetailModeHost { * 返回 ActivityTmdbDetailBinding */ ViewBinding binding(); - - /** Whether the selected detail theme should use the cinema presentation. */ - boolean isCinemaStyle(); - - void closeDetailFullscreenPlayer(); - - void playInline(); - - void playDetailFullscreen(); - - void playDefaultPlayback(); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/EnhancedDetailController.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/EnhancedDetailController.java index 1ef516411f..50875219fc 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/EnhancedDetailController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/EnhancedDetailController.java @@ -19,11 +19,6 @@ public class EnhancedDetailController extends BaseTmdbDetailModeController { super(host); } - @Override - public boolean isCinemaStyle() { - return host.isCinemaStyle(); - } - @Override protected boolean showInlinePlayer() { return false; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/FusionDetailController.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/FusionDetailController.java index fbd70f2a08..cf2d5a15e1 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/FusionDetailController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/FusionDetailController.java @@ -3,14 +3,13 @@ package com.fongmi.android.tv.ui.detail; import android.view.View; import com.fongmi.android.tv.databinding.ActivityTmdbDetailBinding; -import com.fongmi.android.tv.setting.PlayerSetting; /** * 沉浸融合模式 Controller。 *

* 特点: * - 显示内联播放器 - * - 按“点播自动播放”设置决定是否自动播放 + * - 不自动播放 * - 特殊按钮布局(fusionActions 可见,detailActions 隐藏) * - heroSpacer 隐藏 */ @@ -20,11 +19,6 @@ public class FusionDetailController extends BaseTmdbDetailModeController { super(host); } - @Override - public boolean isFusionMode() { - return true; - } - @Override protected boolean showInlinePlayer() { return true; @@ -32,7 +26,7 @@ public class FusionDetailController extends BaseTmdbDetailModeController { @Override protected boolean autoPlay() { - return PlayerSetting.isAutoPlay(); + return false; // 融合模式不自动播放 } @Override @@ -59,9 +53,4 @@ public class FusionDetailController extends BaseTmdbDetailModeController { // TODO: 融合模式特有的播放开始逻辑 // 目前留空,等 Phase 2 迁移具体逻辑 } - @Override - public void play() { - host.playInline(); - } - } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/PlayerDetailController.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/PlayerDetailController.java index e4bb6a2fae..d20edd80bb 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/PlayerDetailController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/PlayerDetailController.java @@ -9,7 +9,7 @@ import com.fongmi.android.tv.databinding.ActivityTmdbDetailBinding; *

* 特点: * - 不显示内联播放器(初始) - * - 由用户点击播放后进入全屏 + * - 自动播放(进入后立即全屏播放) * - detailActions 可见,fusionActions 隐藏 * - heroSpacer 可见 */ @@ -19,16 +19,6 @@ public class PlayerDetailController extends BaseTmdbDetailModeController { super(host); } - @Override - public boolean isCinemaStyle() { - return host.isCinemaStyle(); - } - - @Override - public boolean isPlayerMode() { - return true; - } - @Override protected boolean showInlinePlayer() { return false; @@ -36,7 +26,7 @@ public class PlayerDetailController extends BaseTmdbDetailModeController { @Override protected boolean autoPlay() { - return false; // 详情直放必须由用户点击播放 + return true; // 详情直放模式自动播放 } @Override @@ -44,30 +34,14 @@ public class PlayerDetailController extends BaseTmdbDetailModeController { return View.VISIBLE; } - @Override - public boolean shouldBindPlaybackService() { - return true; - } - - - @Override - public void onExitFullscreen() { - host.closeDetailFullscreenPlayer(); - } - @Override public void applyInitialLayout() { ActivityTmdbDetailBinding binding = (ActivityTmdbDetailBinding) host.binding(); - // 详情直放模式布局设置,等待用户点击播放 + // 详情直放模式布局设置(与炫彩详情类似,但后续会自动触发播放) binding.playerPanel.setVisibility(View.GONE); binding.heroSpacer.setVisibility(View.VISIBLE); binding.fusionActions.setVisibility(View.GONE); binding.detailActions.setVisibility(View.VISIBLE); } - @Override - public void play() { - host.playDetailFullscreen(); - } - } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeController.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeController.java index e866ce7656..408aa29095 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeController.java @@ -46,9 +46,6 @@ public interface TmdbDetailModeController { */ void onPlaybackStarted(); - /** Launch playback using this mode's presentation. */ - void play(); - /** * 是否显示内联播放器。 *

@@ -63,27 +60,6 @@ public interface TmdbDetailModeController { */ boolean shouldAutoPlay(); - /** Whether this mode uses the cinema presentation style. */ - default boolean isCinemaStyle() { - return false; - } - - /** Whether this is the immersive fusion mode. */ - default boolean isFusionMode() { - return false; - } - - /** Whether this is the direct-play detail mode. */ - default boolean isPlayerMode() { - return false; - } - - /** Whether this mode owns an in-process playback service connection. */ - boolean shouldBindPlaybackService(); - - /** Whether this mode publishes selected playback history to the in-process collector. */ - boolean shouldPublishPlaybackHistory(); - /** * 处理返回键。 *

@@ -91,9 +67,6 @@ public interface TmdbDetailModeController { */ boolean handleBack(); - /** Called after the shared fullscreen layout has been exited. */ - void onExitFullscreen(); - /** * 释放资源。 *

diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AboutDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AboutDialog.java index 7913dea35b..e2dae86088 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AboutDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AboutDialog.java @@ -15,32 +15,23 @@ import android.view.WindowManager; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; -import com.fongmi.android.tv.App; import com.fongmi.android.tv.BuildConfig; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogAboutBinding; import com.fongmi.android.tv.databinding.DialogGithubProxyBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.GithubProxyAdapter; import com.fongmi.android.tv.utils.AppVersion; import com.fongmi.android.tv.utils.GithubProxy; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.github.catvod.net.OkHttp; - -import java.util.List; -import java.util.Locale; -import java.util.concurrent.TimeUnit; public final class AboutDialog { private static final int DIALOG_VERTICAL_MARGIN_DP = 96; private static final int FULLSCREEN_INSET_DP = 32; - private static final long PROBE_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(5); private AboutDialog() { } @@ -53,10 +44,6 @@ public final class AboutDialog { dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); dialog.setContentView(binding.getRoot()); binding.confirm.setOnClickListener(v -> dialog.dismiss()); - binding.updateSettings.setOnClickListener(v -> { - dialog.dismiss(); - UpdateSettingsDialog.show(activity); - }); binding.checkUpdate.setOnClickListener(v -> { dialog.dismiss(); if (updateAction != null) updateAction.run(); @@ -69,7 +56,7 @@ public final class AboutDialog { } private static void showGithubProxy(FragmentActivity activity) { - MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog); + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog); Context context = builder.getContext(); DialogGithubProxyBinding binding = DialogGithubProxyBinding.inflate(LayoutInflater.from(context)); GithubProxyAdapter adapter = new GithubProxyAdapter(new GithubProxyAdapter.OnClickListener() { @@ -86,15 +73,7 @@ public final class AboutDialog { } }); binding.list.setAdapter(adapter); - binding.list.setHasFixedSize(false); - binding.modeGroup.addOnButtonCheckedListener((group, checkedId, isChecked) -> { - if (isChecked) Setting.putGithubProxyMode(checkedId == R.id.modeStrip - ? GithubProxy.MODE_STRIP_SCHEME - : GithubProxy.MODE_FULL_URL); - }); - binding.modeGroup.check(GithubProxy.MODE_STRIP_SCHEME.equals(Setting.getGithubProxyMode()) - ? R.id.modeStrip - : R.id.modeFull); + binding.list.setHasFixedSize(true); binding.enabled.setChecked(Setting.isGithubProxyEnabled()); binding.enabled.setOnCheckedChangeListener((buttonView, isChecked) -> Setting.putGithubProxyEnabled(isChecked)); refreshGithubProxy(binding); @@ -113,27 +92,20 @@ public final class AboutDialog { Setting.putGithubProxy(GithubProxy.defaultSources()); refreshGithubProxy(binding); }); - binding.probe.setOnClickListener(v -> probeLatency(binding)); View.OnKeyListener dpadNav = (v, keyCode, event) -> { if (event.getAction() != KeyEvent.ACTION_DOWN) return false; if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && v == binding.enabled) { - return binding.modeGroup.requestFocus(); + return binding.input.requestFocus(); } if (keyCode == KeyEvent.KEYCODE_DPAD_UP && v == binding.enabled) { return binding.list.requestFocus(); } - if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && v == binding.modeGroup) { - return binding.input.requestFocus(); - } - if (keyCode == KeyEvent.KEYCODE_DPAD_UP && v == binding.modeGroup) { - return binding.enabled.requestFocus(); - } if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && v == binding.input) { return binding.reset.requestFocus(); } if (keyCode == KeyEvent.KEYCODE_DPAD_UP && v == binding.input) { - return binding.modeGroup.requestFocus(); + return binding.enabled.requestFocus(); } if (keyCode == KeyEvent.KEYCODE_DPAD_UP && v == binding.reset) { return binding.input.requestFocus(); @@ -141,7 +113,6 @@ public final class AboutDialog { return false; }; binding.enabled.setOnKeyListener(dpadNav); - binding.probe.setOnKeyListener(dpadNav); binding.input.setOnKeyListener(dpadNav); binding.reset.setOnKeyListener(dpadNav); @@ -164,47 +135,28 @@ public final class AboutDialog { configureGithubProxyWindow(activity, githubDialog, binding); } - private static void probeLatency(DialogGithubProxyBinding binding) { - GithubProxyAdapter adapter = (GithubProxyAdapter) binding.list.getAdapter(); - if (adapter == null || adapter.isProbing()) return; - List sources = GithubProxy.getSources(); - adapter.startProbe(sources); - for (String source : sources) { - Task.submitLarge(() -> { - long start = System.nanoTime(); - try (var response = OkHttp.newCall(OkHttp.client(PROBE_TIMEOUT_MS), GithubProxy.probeUrl(source), source).execute()) { - long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); - String value = App.get().getString(R.string.setting_github_proxy_latency, elapsed); - App.post(() -> adapter.setLatency(source, value)); - } catch (Exception e) { - App.post(() -> adapter.setLatency(source, App.get().getString(R.string.setting_github_proxy_latency_failed))); - } - }); - } - } - private static void configureGithubProxyWindow(FragmentActivity activity, AlertDialog dialog, DialogGithubProxyBinding binding) { - boolean leanback = Util.isLeanback(); + if (!Util.isLeanback()) return; Window window = dialog.getWindow(); if (window == null) return; - // 手机与电视都铺满全屏:不再按屏幕比例手算宽高,列表改为吃掉剩余空间。 + // 电视端铺满全屏:不再按屏幕比例手算宽高,列表改为吃掉剩余空间。 // 这个弹窗经 MaterialAlertDialogBuilder.setView 装载,root 会被 AlertController // 以 MATCH_PARENT 塞进 @id/custom,窗口给满高度后列表即可自由伸展。 WindowManager.LayoutParams params = window.getAttributes(); params.width = WindowManager.LayoutParams.MATCH_PARENT; - params.height = WindowManager.LayoutParams.WRAP_CONTENT; + params.height = WindowManager.LayoutParams.MATCH_PARENT; window.setAttributes(params); - // 弹窗有 URL 输入框。小窗居中时系统还能上推窗口避让键盘,铺满全屏后没有余量, + // 这个弹窗有 URL 输入框。小窗居中时系统还能上推窗口避让键盘,铺满全屏后没有余量, // 必须显式 ADJUST_RESIZE 让窗口自身缩小,否则输入框会被屏幕键盘盖住。 window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); - if (leanback) binding.list.requestFocus(); - else binding.list.post(() -> { - for (int i = 0; i < binding.list.getChildCount(); i++) { - View child = binding.list.getChildAt(i); - child.findViewById(R.id.text).setFocusable(false); - child.findViewById(R.id.remove).setFocusable(false); - } - }); + ViewGroup.LayoutParams listParams = binding.list.getLayoutParams(); + listParams.height = 0; + if (listParams instanceof androidx.appcompat.widget.LinearLayoutCompat.LayoutParams) { + ((androidx.appcompat.widget.LinearLayoutCompat.LayoutParams) listParams).weight = 1; + } else if (listParams instanceof android.widget.LinearLayout.LayoutParams) { + ((android.widget.LinearLayout.LayoutParams) listParams).weight = 1; + } + binding.list.setLayoutParams(listParams); } private static void refreshGithubProxy(DialogGithubProxyBinding binding) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdBlockLogFilterDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdBlockLogFilterDialog.java deleted file mode 100644 index c38b795bb8..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdBlockLogFilterDialog.java +++ /dev/null @@ -1,304 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Activity; -import android.content.res.Configuration; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.widget.CheckBox; -import android.widget.EditText; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.bean.AdBlockLog; -import com.fongmi.android.tv.bean.AdBlockLogFilter; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.google.android.material.button.MaterialButton; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; - -/** - * 日志列的去重多选筛选器。 - * 候选项只出现一次,弹窗内的输入框只过滤候选项,不会破坏已勾选状态。 - */ -public final class AdBlockLogFilterDialog { - - public interface ValueProvider { - String value(AdBlockLog log); - } - - public interface OnApplyListener { - void onApply(Set values); - } - - private AdBlockLogFilterDialog() { - } - - public static void show(Activity activity, String title, List logs, - Set selectedValues, - ValueProvider valueProvider, - OnApplyListener listener) { - if (activity == null || activity.isFinishing()) return; - List

视图用代码搭建而非 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/java/com/fongmi/android/tv/ui/dialog/AdSkipPromptPresenter.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdSkipPromptPresenter.java new file mode 100644 index 0000000000..82ebb7773c --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdSkipPromptPresenter.java @@ -0,0 +1,141 @@ +package com.fongmi.android.tv.ui.dialog; + +import android.os.Handler; +import android.os.Looper; + +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; + +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.ad.audio.AdSkipCoordinator; +import com.fongmi.android.tv.ad.audio.SpeechAdSignalProvider; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.lang.ref.WeakReference; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class AdSkipPromptPresenter implements AdSkipCoordinator.UiPort, AutoCloseable { + + private final WeakReference activity; + private final Handler main = new Handler(Looper.getMainLooper()); + private AlertDialog dialog; + private Runnable timeout; + private long presentationToken; + private boolean closed; + + public AdSkipPromptPresenter(FragmentActivity activity) { + this.activity = new WeakReference<>(activity); + } + + @Override + public void showCandidate(AdSkipCoordinator.Prompt prompt, AdSkipCoordinator.Actions actions) { + runOnMain(() -> showCandidateInternal(prompt, actions)); + } + + @Override + public void showUndo(AdSkipCoordinator.UndoPrompt prompt, AdSkipCoordinator.Actions actions) { + runOnMain(() -> showUndoInternal(prompt, actions)); + } + + @Override + public void dismiss(long sessionId) { + runOnMain(this::dismissInternal); + } + + @Override + public void close() { + runOnMain(() -> { + if (closed) return; + closed = true; + dismissInternal(); + activity.clear(); + }); + } + + private void showCandidateInternal(AdSkipCoordinator.Prompt prompt, + AdSkipCoordinator.Actions actions) { + FragmentActivity owner = usableActivity(); + if (owner == null) return; + dismissInternal(); + long token = ++presentationToken; + AtomicBoolean handled = new AtomicBoolean(); + boolean speech = SpeechAdSignalProvider.RULE_ID.equals(prompt.ruleId()); + int title = speech + ? R.string.ad_audio_speech_candidate_title + : (prompt.fullMatch() ? R.string.ad_audio_match_title : R.string.ad_audio_candidate_title); + long duration = prompt.skipDurationSeconds(); + String message = speech + ? owner.getString(R.string.ad_audio_speech_candidate_message, duration) + : owner.getString(R.string.ad_audio_candidate_message, prompt.ruleId(), duration); + dialog = new MaterialAlertDialogBuilder(owner, R.style.ThemeOverlay_WebHTV_LightDialog) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.ad_audio_skip, (ignored, which) -> + act(token, handled, actions::confirm)) + .setNegativeButton(R.string.ad_audio_ignore, (ignored, which) -> + act(token, handled, actions::ignore)) + .setOnCancelListener(ignored -> act(token, handled, actions::ignore)) + .create(); + dialog.show(); + if (dialog.getButton(AlertDialog.BUTTON_POSITIVE) != null) { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).requestFocus(); + } + } + + private void showUndoInternal(AdSkipCoordinator.UndoPrompt prompt, + AdSkipCoordinator.Actions actions) { + FragmentActivity owner = usableActivity(); + if (owner == null) return; + dismissInternal(); + long token = ++presentationToken; + AtomicBoolean handled = new AtomicBoolean(); + dialog = new MaterialAlertDialogBuilder(owner, R.style.ThemeOverlay_WebHTV_LightDialog) + .setTitle(R.string.ad_audio_skipped_title) + .setMessage(owner.getString(R.string.ad_audio_skipped_message, + Math.max(1L, prompt.expiresAfterMs() / 1_000L))) + .setPositiveButton(R.string.ad_audio_undo, (ignored, which) -> + act(token, handled, actions::undo)) + .setNegativeButton(R.string.dialog_negative, (ignored, which) -> + act(token, handled, actions::ignore)) + .setOnCancelListener(ignored -> act(token, handled, actions::ignore)) + .create(); + dialog.show(); + if (dialog.getButton(AlertDialog.BUTTON_POSITIVE) != null) { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).requestFocus(); + } + timeout = () -> { + if (!isCurrent(token) || !handled.compareAndSet(false, true)) return; + actions.ignore(); + dismissInternal(); + }; + main.postDelayed(timeout, prompt.expiresAfterMs()); + } + + private void act(long token, AtomicBoolean handled, Runnable action) { + if (!isCurrent(token) || !handled.compareAndSet(false, true)) return; + action.run(); + } + + private boolean isCurrent(long token) { + return !closed && token == presentationToken && usableActivity() != null; + } + + private FragmentActivity usableActivity() { + FragmentActivity owner = activity.get(); + if (owner == null || owner.isFinishing() || owner.isDestroyed()) return null; + return owner; + } + + private void dismissInternal() { + presentationToken++; + if (timeout != null) main.removeCallbacks(timeout); + timeout = null; + if (dialog != null) dialog.dismiss(); + dialog = null; + } + + private void runOnMain(Runnable action) { + if (Looper.myLooper() == Looper.getMainLooper()) action.run(); + else main.post(action); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiAnalysisDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiAnalysisDialog.java index 4de1aa1aaf..765dd59e8c 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiAnalysisDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiAnalysisDialog.java @@ -8,8 +8,8 @@ import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; public final class AiAnalysisDialog { @@ -26,7 +26,7 @@ public final class AiAnalysisDialog { ResUtil.dp2px(40), ResUtil.dp2px(40), Gravity.CENTER); container.addView(progress, params); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity) .setTitle(R.string.tmdb_season_ai_loading_title) .setMessage(R.string.tmdb_season_ai_analyzing) .setView(container) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiConfigDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiConfigDialog.java index ea9e003e9e..3ffa15c9e6 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiConfigDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiConfigDialog.java @@ -17,7 +17,6 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.AiConfig; import com.fongmi.android.tv.service.AiCompletionClient; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.Task; import com.google.android.material.button.MaterialButton; @@ -106,7 +105,7 @@ public class AiConfigDialog { } private MaterialAlertDialogBuilder builder() { - return new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog); + return new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog); } private void showPromptConfig() { @@ -164,7 +163,7 @@ public class AiConfigDialog { dialogContext.getString(R.string.dialog_ai_group_rule_prompt_label) }; boolean[] checked = {true, true, true, true, true}; - AlertDialog picker = new WebHtvAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_Dialog) + AlertDialog picker = new MaterialAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.dialog_ai_prompt_reset) .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> checked[which] = isChecked) .setPositiveButton(R.string.dialog_positive, (d, w) -> { @@ -296,7 +295,7 @@ public class AiConfigDialog { break; } } - new WebHtvAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_Dialog) + new MaterialAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.dialog_ai_protocol_label) .setSingleChoiceItems(labels, currentIndex, (dialog, which) -> { String oldProtocol = config.getProtocol(); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialog.java index a308d3c7f7..676e6f9816 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiRecommendationInfoDialog.java @@ -93,7 +93,7 @@ public class AiRecommendationInfoDialog { ImgUtil.load(item.getTitle(), image, fallbackImage, poster, true, 300, 450); int dialogHeightPx = calculateDialogHeightPx( - DialogSafeArea.height(activity), + ResUtil.getScreenHeight(activity), dp(activity, DIALOG_VERTICAL_MARGIN_DP)); Dialog dialog = LightDialog.create( activity, diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ApkPushProgressDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ApkPushProgressDialog.java deleted file mode 100644 index 99562d4a26..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ApkPushProgressDialog.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; -import android.text.TextUtils; -import android.view.Window; -import android.view.WindowManager; - -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentManager; -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.databinding.DialogApkPushProgressBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.utils.FileUtil; -import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Util; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -public final class ApkPushProgressDialog extends BaseAlertDialog { - - private static final String TAG = "apk_push_progress"; - - private DialogApkPushProgressBinding binding; - private Listener listener; - private int progress = -1; - private long bytes; - private long total; - private long speed; - private long elapsed; - - public static ApkPushProgressDialog open(FragmentActivity activity, Listener listener) { - if (activity == null || activity.isFinishing() || activity.isDestroyed()) return null; - FragmentManager manager = activity.getSupportFragmentManager(); - if (manager.isStateSaved()) return null; - Fragment fragment = manager.findFragmentByTag(TAG); - ApkPushProgressDialog dialog; - if (fragment instanceof ApkPushProgressDialog) { - dialog = (ApkPushProgressDialog) fragment; - } else { - dialog = new ApkPushProgressDialog(); - dialog.show(manager, TAG); - } - dialog.listener = listener; - return dialog; - } - - public static void dismiss(FragmentActivity activity) { - if (activity == null) return; - Fragment fragment = activity.getSupportFragmentManager().findFragmentByTag(TAG); - if (fragment instanceof ApkPushProgressDialog) ((ApkPushProgressDialog) fragment).dismissAllowingStateLoss(); - } - - @Override - protected ViewBinding getBinding() { - return binding = DialogApkPushProgressBinding.inflate(getLayoutInflater()); - } - - @Override - protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()).setCancelable(false); - } - - @Override - protected void initView() { - binding.progress.setMax(100); - render(); - } - - @Override - protected void initEvent() { - binding.cancel.setOnClickListener(view -> { - binding.cancel.setEnabled(false); - if (listener != null) listener.onCancel(); - }); - } - - @Override - public void onStart() { - super.onStart(); - setCancelable(false); - if (getDialog() != null) getDialog().setCanceledOnTouchOutside(false); - configureWindow(); - binding.cancel.requestFocus(); - } - - @Override - public void onDestroyView() { - binding = null; - super.onDestroyView(); - } - - public void setProgress(int progress, long bytes, long total, long speed, long elapsed) { - this.progress = progress; - this.bytes = Math.max(0, bytes); - this.total = total; - this.speed = Math.max(0, speed); - this.elapsed = Math.max(0, elapsed); - render(); - } - - private void render() { - if (binding == null) return; - boolean indeterminate = progress < 0 || total <= 0; - int value = Math.max(0, Math.min(100, progress)); - binding.progress.setIndeterminate(indeterminate); - if (!indeterminate) binding.progress.setProgress(value); - binding.progressText.setText(getProgressText(indeterminate, value)); - binding.cancel.setEnabled(true); - } - - private String getProgressText(boolean indeterminate, int value) { - String sizeText; - if (!indeterminate) { - sizeText = getString(R.string.apk_push_url_progress_known, value, FileUtil.byteCountToDisplaySize(bytes), FileUtil.byteCountToDisplaySize(total)); - } else { - sizeText = getString(R.string.apk_push_url_progress_unknown, FileUtil.byteCountToDisplaySize(bytes)); - } - if (speed <= 0 || elapsed <= 0) return sizeText; - String speedText = FileUtil.byteCountToDisplaySize(speed); - String elapsedText = formatDuration(elapsed); - if (!indeterminate && total > bytes) { - long remaining = Math.max(0, total - bytes) * 1000 / speed; - return sizeText + "\n" + getString(R.string.apk_push_url_progress_detail_remaining, speedText, formatDuration(remaining), elapsedText); - } - return sizeText + "\n" + getString(R.string.apk_push_url_progress_detail, speedText, elapsedText); - } - - private String formatDuration(long time) { - String text = Util.timeMs(Math.max(0, time)); - return TextUtils.isEmpty(text) ? "00:00" : text; - } - - private void configureWindow() { - Window window = getDialog() == null ? null : getDialog().getWindow(); - if (window == null) return; - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int width = Math.min(ResUtil.dp2px(720), (int) (screenWidth * 0.72f)); - width = Math.min(width, screenWidth - ResUtil.dp2px(32)); - WindowManager.LayoutParams params = window.getAttributes(); - params.width = width; - params.height = WindowManager.LayoutParams.WRAP_CONTENT; - window.setAttributes(params); - window.setLayout(width, WindowManager.LayoutParams.WRAP_CONTENT); - } - - public interface Listener { - - void onCancel(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AudioSourceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AudioSourceDialog.java index 28093fd8be..c1236d61d4 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AudioSourceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AudioSourceDialog.java @@ -16,9 +16,9 @@ import com.fongmi.android.tv.bean.AudioConfig; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.List; @@ -76,7 +76,7 @@ public class AudioSourceDialog { manageBtn.setOnClickListener(v -> showSiteManage()); resetBtn.setOnClickListener(v -> resetToDefault()); - dialog = new WebHtvAlertDialogBuilder(activity) + dialog = new MaterialAlertDialogBuilder(activity) .setTitle(R.string.setting_audio_source) .setView(view) .setPositiveButton(R.string.dialog_positive, (d, w) -> onSave()) @@ -111,7 +111,7 @@ public class AudioSourceDialog { checked[i] = matchesRule(enabledRules, site); } - new WebHtvAlertDialogBuilder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(R.string.dialog_audio_site_manage) .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> checked[which] = isChecked) .setPositiveButton(R.string.dialog_positive, (d, w) -> applySiteManage(sites, enabledRules, checked)) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java index 756ce8f87d..c43dd2d0cb 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java @@ -9,9 +9,6 @@ import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.viewbinding.ViewBinding; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -27,17 +24,11 @@ public abstract class BaseAlertDialog extends DialogFragment { Dialog dialog = getBuilder().create(); initView(); initEvent(); - // AlertController installs the custom view and the action buttons during - // show(). Binding here only registers the binder root; the descendant-count - // watcher re-walks the tree once those children appear. The dialog's own - // OnShowListener is deliberately left untouched because - // MaterialAlertDialogBuilder already owns it for its background insets. - ThemeController.bindDialog(dialog); return dialog; } protected MaterialAlertDialogBuilder builder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog); + return new MaterialAlertDialogBuilder(requireActivity()); } protected void initView() { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseBottomSheetDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseBottomSheetDialog.java index f8bdc17f43..6da7798546 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseBottomSheetDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseBottomSheetDialog.java @@ -15,7 +15,6 @@ import androidx.core.view.WindowCompat; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.bottomsheet.BottomSheetBehavior; @@ -58,16 +57,9 @@ public abstract class BaseBottomSheetDialog extends BottomSheetDialogFragment { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); initView(); - bindDialogTheme(); initEvent(); } - private void bindDialogTheme() { - Dialog dialog = getDialog(); - if (dialog == null) return; - ThemeController.bindDialog(dialog); - } - protected void initView() { } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialog.java deleted file mode 100644 index ea38fb4c41..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialog.java +++ /dev/null @@ -1,756 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Dialog; -import android.content.res.Resources; -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.GradientDrawable; -import android.os.Bundle; -import android.view.KeyEvent; -import android.util.TypedValue; -import android.view.View; -import android.view.Window; -import android.view.WindowManager; -import android.widget.Button; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.DialogFragment; -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.cache.CacheCenter; -import com.fongmi.android.tv.cache.CacheCleanupManager; -import com.fongmi.android.tv.cache.CacheCleanupMode; -import com.fongmi.android.tv.cache.CacheCleanupPlan; -import com.fongmi.android.tv.cache.CacheCleanupProgress; -import com.fongmi.android.tv.cache.CacheCleanupResult; -import com.fongmi.android.tv.cache.CacheCleanupStatus; -import com.fongmi.android.tv.cache.CacheFormat; -import com.fongmi.android.tv.cache.CacheLimitOptions; -import com.fongmi.android.tv.cache.CacheMeasurement; -import com.fongmi.android.tv.cache.CacheModuleId; -import com.fongmi.android.tv.cache.CachePolicyEngine; -import com.fongmi.android.tv.cache.CachePolicyStore; -import com.fongmi.android.tv.cache.CacheScheduler; -import com.fongmi.android.tv.cache.CacheSnapshot; -import com.fongmi.android.tv.databinding.DialogCacheManagementBinding; -import com.fongmi.android.tv.player.exo.MediaSourceFactory; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.utils.FileUtil; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Util; - -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; - -import com.google.android.material.button.MaterialButton; - -public class CacheManagementDialog extends DialogFragment { - - private static final CacheModuleId[] MODULE_ORDER = CacheModuleId.values(); - private static final float SCREEN_FRACTION = 0.9f; - private DialogCacheManagementBinding binding; - private boolean loading; - private CharSequence resultLine; - private final java.util.ArrayList moduleButtons = new java.util.ArrayList<>(); - - public static void show(Fragment fragment) { - new CacheManagementDialog().show(fragment.getChildFragmentManager(), null); - } - - public static void show(FragmentActivity activity) { - new CacheManagementDialog().show(activity.getSupportFragmentManager(), null); - } - - /** - * Runs the settings row's plain click: every cache the panel can name is cleared the way the - * single-key clear of the pre-split settings row left them, and the outcome is reported as a - * notification. - * - *

The panel deliberately stays closed. This entry re-creates the action from before the cache - * management split, and that one-key clear never opened a screen either: the click is the - * deliberate act, the cleanup runs in the background, and the settings row re-reads itself once - * the cleanup publishes its change. The panel - graded cleanup, module limits, auto cleanup - - * moved to the row's long press, which is now its only entry: the row stays single-line by user - * request, so no gesture hint is printed under the title.

- */ - public static void cleanEverything() { - if (CacheCleanupManager.isRunning()) { - Resources resources = rowResources(); - Notify.show(resources.getString(R.string.cache_cleanup_busy)); - return; - } - Resources resources = rowResources(); - Notify.show(resources.getString(R.string.cache_cleanup_full_started)); - CacheCleanupManager.execute(CachePolicyEngine.plan(CacheCleanupMode.FULL), "settings_row", - result -> Notify.show(describe(result, resources))); - } - - /** - * The resources the row's toasts resolve their text from. - * - *

{@link Notify} renders through the application context, whose resources carry the system - * locale instead of the in-app language the settings screens apply, so a row toast would - * otherwise disagree with the panel that reports the identical outcome. Resolving through the - * same helper the settings screens use keeps both surfaces in one language.

- */ - private static Resources rowResources() { - return Setting.wrapLanguage(App.get()).getResources(); - } - - /** - * Words a cleanup outcome exactly as the panel's status line does, so the same action reads the - * same way whether it was started from the panel or from the settings row. - * - *

The resources are passed in rather than read globally: the panel renders - and notifies - - * through its host context, which carries the in-app language choice, while the row has to - * ask for that same choice explicitly.

- */ - static String describe(CacheCleanupResult result, Resources resources) { - int message; - if (result.status() == CacheCleanupStatus.COMPLETED) { - message = R.string.cache_cleanup_done; - } else if (result.status() == CacheCleanupStatus.CANCELLED) { - message = R.string.cache_cleanup_cancelled; - } else if (result.status() == CacheCleanupStatus.FAILED) { - message = R.string.cache_cleanup_failed; - } else if (result.status() == CacheCleanupStatus.NOT_ALLOWED) { - message = R.string.cache_cleanup_not_allowed; - } else if (result.status() == CacheCleanupStatus.DEFERRED) { - message = R.string.cache_cleanup_deferred; - } else { - message = R.string.cache_cleanup_partial; - } - if (result.status() == CacheCleanupStatus.COMPLETED) { - return resources.getString(message, FileUtil.byteCountToDisplaySize(result.releasedBytes()), - result.deletedFiles()); - } - if (result.status() == CacheCleanupStatus.PARTIAL) { - return resources.getString(message, FileUtil.byteCountToDisplaySize(result.releasedBytes()), - result.deletedFiles(), result.skippedFiles()); - } - return resources.getString(message); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - Dialog dialog = new Dialog(requireContext()); - dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); - dialog.setContentView(getBinding().getRoot()); - dialog.setCanceledOnTouchOutside(true); - // Design §15.2: while a cleanup is running the BACK key must request cancellation - // instead of silently closing the panel and leaving the job running invisibly. - dialog.setOnKeyListener((ignored, keyCode, event) -> { - if (keyCode != KeyEvent.KEYCODE_BACK || event.getAction() != KeyEvent.ACTION_UP) return false; - if (!CacheCleanupManager.isRunning()) return false; - CacheCleanupManager.cancel(); - return true; - }); - initView(); - initEvent(); - return dialog; - } - - protected ViewBinding getBinding() { - return binding = DialogCacheManagementBinding.inflate(getLayoutInflater()); - } - - protected void initView() { - renderModules(null); - refresh(false); - } - - protected void initEvent() { - binding.refresh.setOnClickListener(view -> refresh(true)); - binding.cancel.setOnClickListener(view -> CacheCleanupManager.cancel()); - binding.cleanupLight.setOnClickListener(view -> { - if (!CacheCleanupManager.isRunning()) confirm(CacheCleanupMode.LIGHT, view); - }); - binding.cleanupStandard.setOnClickListener(view -> { - if (!CacheCleanupManager.isRunning()) confirm(CacheCleanupMode.STANDARD, view); - }); - binding.cleanupDeep.setOnClickListener(view -> { - if (!CacheCleanupManager.isRunning()) confirmDeep(view); - }); - binding.autoCleanup.setOnClickListener(view -> toggleAutoCleanup()); - binding.retention.setOnClickListener(view -> chooseRetention()); - binding.totalLimit.setOnClickListener(view -> chooseTotalLimit()); - // The close button must follow the same rule as BACK: during a cleanup it requests cancellation instead of hiding a still-running job. - binding.close.setOnClickListener(view -> { - if (CacheCleanupManager.isRunning()) { - CacheCleanupManager.cancel(); - return; - } - dismiss(); - }); - updatePolicyButtons(); - } - - @Override - public void onStart() { - super.onStart(); - applyWindowSize(); - DialogSafeArea.follow(requireActivity(), getDialog() == null ? null : getDialog().getWindow(), this::applyWindowSize); - } - - private void applyWindowSize() { - Dialog dialog = getDialog(); - Window window = dialog == null ? null : dialog.getWindow(); - if (window == null) return; - int width = Math.round(DialogSafeArea.width(requireActivity(), window) * SCREEN_FRACTION); - int height = Math.round(DialogSafeArea.height(requireActivity(), window) * SCREEN_FRACTION); - WindowManager.LayoutParams params = window.getAttributes(); - params.width = width; - params.height = height; - params.dimAmount = 0.6f; - window.setAttributes(params); - window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.setLayout(width, height); - // Custom Dialog: the window IS the panel, so it must paint its own themed surface. - // MaterialAlertDialog would keep a wrap_content inner panel (~60% height) regardless of - // the window size, which is what made the earlier TV dialog look too small. - GradientDrawable surface = new GradientDrawable(); - surface.setShape(GradientDrawable.RECTANGLE); - surface.setColor(themeColor(android.R.attr.colorBackground, 0xFF1E1B22)); - surface.setCornerRadius(ResUtil.dp2px(20)); - surface.setStroke(ResUtil.dp2px(1), 0x33FFFFFF); - binding.getRoot().setBackground(surface); - } - - private void toggleAutoCleanup() { - boolean enabled = !CachePolicyStore.isAutoCleanupEnabled(); - CachePolicyStore.putAutoCleanupEnabled(enabled); - if (enabled) CacheScheduler.get().start(); - else CacheScheduler.get().cancelPersistent(requireContext()); - updatePolicyButtons(); - } - - private void chooseRetention() { - String[] labels = {getString(R.string.cache_retention_7), getString(R.string.cache_retention_30), - getString(R.string.cache_retention_90), getString(R.string.cache_retention_forever)}; - int[] days = {7, 30, 90, 3650}; - int checked = 0; - int current = CachePolicyStore.getRetentionDays(); - for (int index = 0; index < days.length; index++) if (days[index] == current) checked = index; - ChoiceDialog.showSingle(this, R.string.cache_retention_title, labels, checked, which -> { - CachePolicyStore.putRetentionDays(days[which]); - updatePolicyButtons(); - }); - } - - private void chooseTotalLimit() { - String[] labels = new String[CacheLimitOptions.BYTES.length]; - for (int index = 0; index < labels.length; index++) { - labels[index] = CacheLimitOptions.BYTES[index] <= 0 - ? getString(R.string.cache_limit_unlimited) - : FileUtil.byteCountToDisplaySize(CacheLimitOptions.BYTES[index]); - } - ChoiceDialog.showSingle(this, R.string.cache_total_limit_title, labels, - CacheLimitOptions.indexOf(CachePolicyStore.getTotalLimitBytes()), which -> { - CachePolicyStore.putTotalLimitBytes(CacheLimitOptions.BYTES[which]); - updatePolicyButtons(); - // The idle budget is derived from the total limit, so the summary line would - // keep promising the previous "shrinks to" figure. Converging here is what the - // new total asks for; during playback the release path converges it instead. - applyPlaybackLimit(); - }); - } - - private void updatePolicyButtons() { - binding.autoCleanup.setText(CachePolicyStore.isAutoCleanupEnabled() - ? R.string.cache_auto_on : R.string.cache_auto_off); - int days = CachePolicyStore.getRetentionDays(); - binding.retention.setText(getString(R.string.cache_retention_summary, - days >= 3650 ? getString(R.string.cache_retention_forever) : days + "d")); - long totalLimit = CachePolicyStore.getTotalLimitBytes(); - binding.totalLimit.setText(getString(R.string.cache_total_limit_summary, - totalLimit <= 0 ? getString(R.string.cache_limit_unlimited) - : FileUtil.byteCountToDisplaySize(totalLimit))); - } - - private void confirm(CacheCleanupMode mode, View returnFocus) { - int message = switch (mode) { - case LIGHT -> R.string.cache_cleanup_confirm_light; - case STANDARD -> R.string.cache_cleanup_confirm_standard; - default -> R.string.cache_cleanup_confirm_deep; - }; - AlertDialog dialog = new WebHtvAlertDialogBuilder(requireContext()) - .setTitle(R.string.cache_cleanup_confirm_title) - .setMessage(message) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, (ignored, which) -> startCleanup(mode)) - .create(); - focusNegativeOnShow(dialog); - restoreFocusOnDismiss(dialog, returnFocus); - dialog.show(); - } - - private void confirmModule(CacheModuleId id, View returnFocus) { - AlertDialog dialog = new WebHtvAlertDialogBuilder(requireContext()) - .setTitle(R.string.cache_cleanup_confirm_title) - .setMessage(getString(R.string.cache_cleanup_confirm_module, getModuleName(id))) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, (ignored, which) -> startCleanup( - com.fongmi.android.tv.cache.CachePolicyEngine.module(id))) - .create(); - focusNegativeOnShow(dialog); - restoreFocusOnDismiss(dialog, returnFocus); - dialog.show(); - } - - /** Restore the trigger after a confirmation closes, instead of letting geometry choose again. */ - private void restoreFocusOnDismiss(AlertDialog dialog, @Nullable View returnFocus) { - if (returnFocus == null) return; - dialog.setOnDismissListener(ignored -> returnFocus.post(() -> { - if (!returnFocus.isAttachedToWindow() || !returnFocus.isShown() - || !returnFocus.isEnabled() || !returnFocus.isFocusable()) return; - returnFocus.requestFocus(); - })); - } - - /** - * Design §15.1: cleanup confirmation dialogs must open with the focus on "cancel" so a - * single remote OK press can never delete cache data by accident. - */ - private void focusNegativeOnShow(AlertDialog dialog) { - dialog.setOnShowListener(ignored -> { - Button negative = dialog.getButton(AlertDialog.BUTTON_NEGATIVE); - if (negative == null) return; - negative.setFocusable(true); - negative.setFocusableInTouchMode(true); - // requestFocus() during onShow is too early: the dialog lays out afterwards and - // drops the focus again, leaving no button focused (a remote OK press then does - // nothing). Posting defers it until after layout. - negative.post(negative::requestFocus); - }); - } - - private void confirmDeep(View returnFocus) { - AlertDialog dialog = new WebHtvAlertDialogBuilder(requireContext()) - .setTitle(R.string.cache_cleanup_confirm_title) - .setMessage(R.string.cache_cleanup_confirm_deep) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.cache_cleanup_continue, null) - .create(); - dialog.setOnShowListener(ignored -> { - Button negative = dialog.getButton(AlertDialog.BUTTON_NEGATIVE); - if (negative != null) { - negative.setFocusable(true); - negative.setFocusableInTouchMode(true); - negative.post(negative::requestFocus); - } - Button positive = dialog.getButton(AlertDialog.BUTTON_POSITIVE); - positive.setOnClickListener(view -> { - // Keep the second confirmation in the same window. Creating a second alert here - // made one remote confirmation look like multiple stacked dialogs. - positive.setText(R.string.cache_cleanup_deep_start); - dialog.setMessage(getString(R.string.cache_cleanup_confirm_deep_final)); - positive.setOnClickListener(confirmed -> { - dialog.dismiss(); - startCleanup(CacheCleanupMode.DEEP); - }); - // The step-2 text replaces step-1, so re-seat focus on cancel to keep the same - // "safe default" rule for the irreversible action. - if (negative != null) negative.post(negative::requestFocus); - }); - }); - restoreFocusOnDismiss(dialog, returnFocus); - dialog.show(); - } - - private void startCleanup(CacheCleanupMode mode) { - if (CacheCleanupManager.isRunning()) return; - startCleanup(buildPlan(mode)); - } - - private void startCleanup(CacheCleanupPlan plan) { - if (CacheCleanupManager.isRunning()) return; - setCleanupInteractive(false); - setDismissableWhileIdle(false); - binding.cancel.setVisibility(android.view.View.VISIBLE); - CacheCleanupManager.execute(plan, this::renderProgress, this::renderResult); - } - - /** - * Keeps the panel on screen for the whole cleanup so the user always sees progress and the - * final outcome; BACK is still handled separately as a cancellation request. - */ - private void setDismissableWhileIdle(boolean dismissable) { - Dialog dialog = getDialog(); - if (dialog != null) dialog.setCanceledOnTouchOutside(dismissable); - } - - private CacheCleanupPlan buildPlan(CacheCleanupMode mode) { - return com.fongmi.android.tv.cache.CachePolicyEngine.plan(mode); - } - - private void renderProgress(CacheCleanupProgress progress) { - if (binding == null || !isAdded()) return; - binding.status.setText(getString(R.string.cache_cleanup_progress, - getModuleName(progress.moduleId()), progress.completedModules() + 1, progress.totalModules())); - } - - private void renderResult(CacheCleanupResult result) { - if (binding == null || !isAdded()) return; - setCleanupInteractive(true); - setDismissableWhileIdle(true); - binding.cancel.setVisibility(android.view.View.GONE); - String text = describe(result, getResources()); - // The cleanup already finished, so a modal confirmation here would force the user to - // dismiss an extra dialog for an action that is already done. Notify passively instead - // and keep the outcome visible in the panel's own status line. - Notify.show(text); - resultLine = text; - refresh(true); - } - - private void setCleanupInteractive(boolean enabled) { - setCleanupButtonInteractive(binding.cleanupLight, enabled); - setCleanupButtonInteractive(binding.cleanupStandard, enabled); - setCleanupButtonInteractive(binding.cleanupDeep, enabled); - } - - /** - * Keep cleanup buttons focusable while a cleanup runs, but prevent a second click. Disabling - * the trigger before the confirmation dialog dismisses removes the only safe focus target and - * makes Android TV move focus to an arbitrary neighbouring button. - */ - private void setCleanupButtonInteractive(MaterialButton button, boolean enabled) { - button.setClickable(enabled); - button.setAlpha(enabled ? 1f : 0.55f); - } - - private void refresh(boolean force) { - if (loading) return; - loading = true; - binding.status.setText(R.string.cache_management_scanning); - CacheCenter.get().requestSnapshot(force, this::render); - } - - private void render(CacheSnapshot snapshot) { - loading = false; - if (binding == null || !isAdded()) return; - long quota = snapshot.systemQuotaBytes(); - String total = FileUtil.byteCountToDisplaySize(snapshot.totalBytes()); - String summary = quota > 0 - ? getString(R.string.cache_management_summary_with_quota, total, FileUtil.byteCountToDisplaySize(quota)) - : getString(R.string.cache_management_summary, total); - binding.summary.setText(summary + "\n" + playbackBudgetLine(snapshot)); - if (resultLine != null) { - binding.status.setText(resultLine); - resultLine = null; - } else binding.status.setText(snapshot.warnings().isEmpty() - ? getString(R.string.cache_management_scanned_at, new SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(new Date())) - : getString(R.string.cache_management_partial, snapshot.warnings().size())); - renderModules(snapshot); - } - - /** - * States where the playback cache sits and what bounds it. - * - *

Without this line the panel showed a total the largest module could not be judged - * against: the playback cache is sized by a capacity, not by a post-hoc cleanup, and the total - * limit only reaches it through that capacity.

- */ - private String playbackBudgetLine(CacheSnapshot snapshot) { - return getString(R.string.cache_management_playback_budget, - FileUtil.byteCountToDisplaySize(snapshot.find(CacheModuleId.EXO).bytes()), - FileUtil.byteCountToDisplaySize(MediaSourceFactory.targetCapacityBytes())); - } - - private void renderModules(@Nullable CacheSnapshot snapshot) { - ModuleFocusTag focusedModule = focusedModuleTag(binding.getRoot().findFocus()); - binding.modules.removeAllViews(); - moduleButtons.clear(); - long totalBytes = snapshot == null ? 0 : snapshot.totalBytes(); - java.util.ArrayList ordered = snapshot == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(snapshot.modules()); - ordered.sort((left, right) -> { - int byBytes = Long.compare(right.bytes(), left.bytes()); - if (byBytes != 0) return byBytes; - return Integer.compare(left.id().ordinal(), right.id().ordinal()); - }); - if (snapshot == null) for (CacheModuleId id : MODULE_ORDER) addRow(id, null, 0); - else for (CacheMeasurement measurement : ordered) addRow(measurement.id(), measurement, totalBytes); - wireFocusOrder(); - restoreModuleFocus(focusedModule); - } - - @Nullable - private ModuleFocusTag focusedModuleTag(@Nullable View focused) { - Object tag = focused == null ? null : focused.getTag(); - return tag instanceof ModuleFocusTag moduleTag ? moduleTag : null; - } - - private void restoreModuleFocus(@Nullable ModuleFocusTag target) { - if (target == null) return; - for (MaterialButton button : moduleButtons) { - if (!target.equals(button.getTag())) continue; - button.post(() -> { - if (!button.isAttachedToWindow() || !button.isShown() || !button.isEnabled()) return; - button.requestFocus(); - }); - return; - } - } - - private void addRow(CacheModuleId id, @Nullable CacheMeasurement measurement, long totalBytes) { - boolean mobile = Util.isMobile(); - LinearLayout row = new LinearLayout(requireContext()); - row.setOrientation(mobile ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL); - row.setGravity(android.view.Gravity.CENTER_VERTICAL); - int padding = mobile ? ResUtil.dp2px(8) : 10; - row.setPadding(0, padding, 0, padding); - LinearLayout detailColumn = new LinearLayout(requireContext()); - detailColumn.setOrientation(LinearLayout.VERTICAL); - String title = getModuleName(id); - if (measurement != null) title += " " + CacheFormat.percent(measurement.bytes(), totalBytes); - detailColumn.addView(text(title, 18, themeColor(android.R.attr.textColorPrimary, 0xFF1F1F1F))); - String detail; - if (measurement == null) detail = getString(R.string.cache_management_scanning); - else detail = getString(R.string.cache_management_module_detail, - FileUtil.byteCountToDisplaySize(measurement.bytes()), - measurement.fileCount(), - formatTime(measurement.newestModifiedMs())); - detailColumn.addView(text(detail, 14, themeColor(android.R.attr.textColorSecondary, 0xFF5F6368))); - if (mobile) { - // Phone text must not compete with two minimum-width action buttons. - row.addView(detailColumn, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - LinearLayout actions = new LinearLayout(requireContext()); - actions.setOrientation(LinearLayout.HORIZONTAL); - MaterialButton[] buttons = {moduleButton(id), limitButton(id)}; - for (int index = 0; index < buttons.length; index++) { - MaterialButton button = buttons[index]; - button.setMinWidth(0); - button.setMinimumWidth(0); - button.setMinHeight(ResUtil.dp2px(48)); - button.setMinimumHeight(ResUtil.dp2px(48)); - button.setPaddingRelative(ResUtil.dp2px(8), button.getPaddingTop(), - ResUtil.dp2px(8), button.getPaddingBottom()); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); - if (index > 0) params.setMarginStart(ResUtil.dp2px(8)); - actions.addView(button, params); - } - row.addView(actions, new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - } else { - row.addView(detailColumn, new LinearLayout.LayoutParams(0, - LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); - row.addView(moduleButton(id)); - row.addView(limitButton(id)); - } - binding.modules.addView(row); - } - - private MaterialButton limitButton(CacheModuleId id) { - MaterialButton button = new MaterialButton(requireContext(), null, - com.google.android.material.R.attr.materialButtonOutlinedStyle); - button.setMinHeight(ResUtil.dp2px(44)); - button.setMinWidth(ResUtil.dp2px(96)); - button.setMinimumHeight(ResUtil.dp2px(44)); - button.setTextSize(14); - applyFocusRing(button, R.color.focus_ring_primary); - button.setText(R.string.cache_limit_button); - button.setEnabled(supportsLimit(id)); - if (supportsLimit(id)) { - button.setTag(new ModuleFocusTag(id, true)); - button.setOnClickListener(view -> chooseLimit(id)); - } - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - params.setMarginStart(8); - button.setLayoutParams(params); - moduleButtons.add(button); - return button; - } - - private boolean supportsLimit(CacheModuleId id) { - return switch (id) { - case EXO, GLIDE, LYRICS, KARAOKE, WEBHOME_EXT, EPG, PLUGIN_SCRIPTS, TEMP_FILES -> true; - default -> false; - }; - } - - private void chooseLimit(CacheModuleId id) { - String[] labels = new String[CacheLimitOptions.BYTES.length]; - for (int index = 0; index < labels.length; index++) { - labels[index] = CacheLimitOptions.BYTES[index] <= 0 - ? getString(R.string.cache_limit_unlimited) - : FileUtil.byteCountToDisplaySize(CacheLimitOptions.BYTES[index]); - } - ChoiceDialog.showSingle(this, id == CacheModuleId.EXO - ? R.string.cache_limit_title_exo : R.string.cache_limit_title, - labels, CacheLimitOptions.indexOf(CachePolicyStore.getLimit(id)), which -> { - CachePolicyStore.putLimit(id, CacheLimitOptions.BYTES[which]); - // The playback cache only converges when a player session ends, so a freshly - // chosen limit would otherwise look ignored until the next playback. - if (id == CacheModuleId.EXO) applyPlaybackLimit(); - else refresh(true); - }); - } - - /** - * Applies the playback limit off the UI thread. - * - *

Converging rebuilds the SimpleCache, which loads its index and evicts every span above the - * new capacity. That is disk work and must not sit between the click and the next frame.

- */ - private void applyPlaybackLimit() { - Thread worker = new Thread(() -> { - MediaSourceFactory.enforceCapacityNow(); - App.post(() -> { - if (binding == null || !isAdded()) return; - refresh(true); - }); - }, "exo-cache-apply"); - worker.start(); - } - - /** - * 焦点环走 MaterialButton 自带的描边通道,而不是 foreground ``。 - * - *

原因(2026-10-10 实测):按钮的圆角由 {@code ShapeAppearance.M3.Comp.Button.Small. - * Container.Shape.Round} → {@code Corner.Full} 决定,是 50%(52dp 高按钮 = 26dp 胶囊), - * 而 foreground 环写的是固定 dp,两者在几何上无法对齐:实测 foreground 环的外边界 - * (声明 28dp + w/2)画在按钮 padding 盒(72/30/72/30)上,而按钮填充又被 4dp inset, - * 圆角与盒位都不一致。改用 {@code app:strokeColor}/{@code app:strokeWidth} 后,Material - * 用同一个 {@code ShapeAppearanceModel} 同时画填充与描边,几何天然一致。 - * - *

环色按仓库既有规则取「焦点态实际填充色的配对 on-色」:tonal(secondaryContainer) - * 用 {@code focus_ring_secondary},outlined/text(无容器填充)用 {@code focus_ring_primary}。 - * 两者都由主题解析器从用户 seed 生成并做过对比度校验(浅色表 primary 对 secondaryContainer - * 4.91:1、on_secondary_container 对 secondaryContainer 13.18:1;夜间表 5.44:1 / 7.24:1)。 - */ - private void applyFocusRing(MaterialButton button, int strokeColorRes) { - button.setStrokeColor(androidx.core.content.ContextCompat.getColorStateList(requireContext(), strokeColorRes)); - button.setStrokeWidth(requireContext().getResources().getDimensionPixelSize(R.dimen.webhtv_focus_ring_width)); - } - - private MaterialButton moduleButton(CacheModuleId id) { - MaterialButton button = new MaterialButton(requireContext(), null, - com.google.android.material.R.attr.materialButtonTonalStyle); - button.setMinHeight(ResUtil.dp2px(44)); - button.setMinWidth(ResUtil.dp2px(112)); - button.setMinimumHeight(ResUtil.dp2px(44)); - button.setTextSize(14); - applyFocusRing(button, R.color.focus_ring_secondary); - // Read the restriction from the registry instead of hardcoding one module: an entry that - // declares allowManualCleanup=false must not be offered as directly cleanable. - boolean restricted = !CachePolicyEngine.manualCleanupAllowed(id, requireContext().getCacheDir()); - button.setEnabled(!restricted); - button.setText(restricted ? R.string.cache_cleanup_owner_managed : R.string.cache_cleanup_module); - if (!restricted) { - button.setTag(new ModuleFocusTag(id, false)); - button.setOnClickListener(view -> confirmModule(id, view)); - } - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - params.setMarginStart(8); - button.setLayoutParams(params); - moduleButtons.add(button); - return button; - } - - private record ModuleFocusTag(CacheModuleId id, boolean limitButton) { - } - - /** - * The default geometric focus search skipped whole rows (for example UP from "refresh" landed - * on the policy row instead of the cleanup row). Link the action area explicitly so remote - * focus always moves through policy row -> cleanup row -> footer without skipping a row. - */ - private void wireFocusOrder() { - // Mobile actions wrap; focus should follow their actual positions, not TV columns. - if (Util.isMobile()) return; - linkVertical(binding.autoCleanup, null, binding.cleanupLight); - linkVertical(binding.retention, null, binding.cleanupStandard); - linkVertical(binding.totalLimit, null, binding.cleanupDeep); - linkVertical(binding.cleanupLight, binding.autoCleanup, binding.refresh); - linkVertical(binding.cleanupStandard, binding.retention, binding.refresh); - linkVertical(binding.cleanupDeep, binding.totalLimit, binding.close); - linkVertical(binding.refresh, binding.cleanupStandard, null); - linkVertical(binding.close, binding.cleanupDeep, null); - binding.refresh.setNextFocusLeftId(binding.cancel.getVisibility() == View.VISIBLE - ? R.id.cancel : View.NO_ID); - binding.refresh.setNextFocusRightId(R.id.close); - binding.close.setNextFocusLeftId(R.id.refresh); - if (moduleButtons.size() >= 2) { - MaterialButton lastClean = moduleButtons.get(moduleButtons.size() - 2); - MaterialButton lastLimit = moduleButtons.get(moduleButtons.size() - 1); - lastClean.setNextFocusDownId(R.id.autoCleanup); - lastLimit.setNextFocusDownId(R.id.totalLimit); - // Close the loop upwards as well, so UP from the policy row returns to the module list - // instead of jumping to an unrelated neighbour chosen by geometry. - binding.autoCleanup.setNextFocusUpId(lastClean.getId()); - binding.retention.setNextFocusUpId(lastLimit.getId()); - binding.totalLimit.setNextFocusUpId(lastLimit.getId()); - } - } - - private void linkVertical(View view, @Nullable View up, @Nullable View down) { - if (view == null) return; - view.setNextFocusUpId(up == null ? View.NO_ID : up.getId()); - view.setNextFocusDownId(down == null ? View.NO_ID : down.getId()); - } - - private int themeColor(int attribute, int fallback) { - TypedValue value = new TypedValue(); - if (requireContext().getTheme().resolveAttribute(attribute, value, true)) { - if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { - return value.data; - } - if (value.resourceId != 0) { - return androidx.core.content.ContextCompat.getColor(requireContext(), value.resourceId); - } - } - return fallback; - } - - private android.widget.TextView text(CharSequence value, int size, int color) { - android.widget.TextView view = new android.widget.TextView(requireContext()); - view.setText(value); - view.setTextSize(size); - view.setTextColor(color); - return view; - } - - private String getModuleName(CacheModuleId id) { - return getString(switch (id) { - case EXO -> R.string.cache_module_exo; - case MPV_HLS -> R.string.cache_module_mpv_hls; - case MPV_DEMUXER -> R.string.cache_module_mpv_demuxer; - case MPV_RUNTIME -> R.string.cache_module_mpv_runtime; - case LYRICS -> R.string.cache_module_lyrics; - case KARAOKE -> R.string.cache_module_karaoke; - case WEBHOME_EXT -> R.string.cache_module_webhome_ext; - case WEBHOME_RAW -> R.string.cache_module_webhome_raw; - case EPG -> R.string.cache_module_epg; - case GLIDE -> R.string.cache_module_glide; - case PLUGIN_SCRIPTS -> R.string.cache_module_plugin_scripts; - case PLUGIN_AUDIT -> R.string.cache_module_plugin_audit; - case PLUGIN_HTTP_CACHE -> R.string.cache_module_plugin_http_cache; - case TEMP_FILES -> R.string.cache_module_temp_files; - case DIAGNOSTIC_LOGS -> R.string.cache_module_diagnostic_logs; - case LEGACY_FILES -> R.string.cache_module_legacy_files; - case UNCLASSIFIED -> R.string.cache_module_unclassified; - }); - } - - private String formatTime(long value) { - if (value <= 0) return getString(R.string.none); - return new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(new Date(value)); - } - -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java index 44a9e81937..e6453f43ce 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java @@ -13,7 +13,6 @@ import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.view.ViewParent; import android.view.Window; import android.view.WindowManager; import android.widget.LinearLayout; @@ -29,9 +28,6 @@ import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeEditorUi; -import com.fongmi.android.tv.theme.ThemeTokens; import com.fongmi.android.tv.ui.helper.TmdbSeasonResolver; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; @@ -242,7 +238,6 @@ public final class ChoiceDialog extends DialogFragment { Dialog dialog = new Dialog(requireContext()); dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); dialog.setContentView(createView(LayoutInflater.from(requireContext()))); - ThemeController.bindDialog(dialog); dialog.setCanceledOnTouchOutside(true); return dialog; } @@ -277,7 +272,7 @@ public final class ChoiceDialog extends DialogFragment { for (int offset = 0; offset < list.getChildCount(); offset++) { View child = list.getChildAt((start + offset) % list.getChildCount()); if (child.isEnabled() && child.isFocusable()) { - requestItemFocus(list, child); + child.requestFocus(); return; } } @@ -334,46 +329,11 @@ public final class ChoiceDialog extends DialogFragment { if (!(listView instanceof ViewGroup list)) return false; for (int index = position + direction; index >= 0 && index < list.getChildCount(); index += direction) { View child = list.getChildAt(index); - if (child.isEnabled() && child.isFocusable() && requestItemFocus(list, child)) return true; + if (child.isEnabled() && child.isFocusable() && child.requestFocus()) return true; } return direction > 0 ? focusFirstAction(root) : true; } - private boolean requestItemFocus(ViewGroup list, View child) { - if (!child.requestFocus()) return false; - ensureItemVisible(list, child); - return true; - } - - private void ensureItemVisible(View child) { - ViewParent parent = child.getParent(); - while (parent instanceof View) { - if (parent instanceof ViewGroup list && "choice_list".equals(list.getTag())) { - ensureItemVisible(list, child); - return; - } - parent = parent.getParent(); - } - } - - private void ensureItemVisible(ViewGroup list, View child) { - if (!(list.getParent() instanceof ScrollView scroll)) return; - int target = calculateItemScrollY(scroll.getScrollY(), scroll.getHeight(), list.getHeight(), child.getTop(), child.getBottom()); - if (target != scroll.getScrollY()) scroll.scrollTo(0, target); - } - - static int calculateItemScrollY(int currentScrollY, int viewportHeight, int contentHeight, int childTop, int childBottom) { - int current = Math.max(0, currentScrollY); - int viewport = Math.max(0, viewportHeight); - int content = Math.max(0, contentHeight); - int maxScroll = Math.max(0, content - viewport); - if (viewport == 0) return Math.min(current, maxScroll); - int target = current; - if (childTop < current) target = childTop; - else if (childBottom > current + viewport) target = childBottom - viewport; - return Math.max(0, Math.min(target, maxScroll)); - } - private boolean focusFirstAction(View root) { View actionView = root.findViewWithTag("choice_actions"); if (!(actionView instanceof ViewGroup actions)) return true; @@ -388,19 +348,14 @@ public final class ChoiceDialog extends DialogFragment { if (showCancel && !multi && items != null && items.length > 0 && negative == null) negative = getString(R.string.dialog_negative); LinearLayout root = new LinearLayout(requireContext()); root.setOrientation(LinearLayout.VERTICAL); - ThemeTokens tokens = ThemeController.current(); - root.setBackground(ThemeEditorUi.shape(requireContext(), tokens.colorSurfaceContainerHigh(), 0, 0, 22)); + root.setBackgroundResource(R.drawable.shape_shell_proxy_dialog); int vertical = dp(24); int horizontal = dp(actionCount() >= 3 ? 18 : 24); root.setPadding(horizontal, vertical, horizontal, vertical); MaterialTextView titleView = new MaterialTextView(requireContext()); titleView.setText(title); - // The panel is shape_shell_proxy_dialog (?attr/colorSurfaceContainerHigh), which on - // the TV flavour compiles to the dark table (#2A2F34). A fixed #202124 title was - // therefore invisible there - measured 1.00:1 on device - while the very same - // AppearanceDialog rows were being opened from this dialog. Use the active palette. - titleView.setTextColor(ThemeController.current().colorOnSurface()); + titleView.setTextColor(Color.parseColor("#202124")); titleView.setTextSize(18); titleView.setGravity(Gravity.CENTER_VERTICAL); titleView.setSingleLine(false); @@ -415,8 +370,7 @@ public final class ChoiceDialog extends DialogFragment { private void addMessage(LinearLayout root) { MaterialTextView messageView = new MaterialTextView(requireContext()); messageView.setText(message); - // Same panel, same reason as the title above. - messageView.setTextColor(ThemeController.current().colorOnSurfaceVariant()); + messageView.setTextColor(Color.parseColor("#5F6368")); messageView.setTextSize(14); messageView.setLineSpacing(dp(2), 1f); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -453,10 +407,7 @@ public final class ChoiceDialog extends DialogFragment { setItemEnabled(button, position); button.setText(itemText(position)); styleItem(button, position); - button.setOnFocusChangeListener((view, hasFocus) -> { - styleItem(button, position); - if (hasFocus && Util.isLeanback()) button.post(() -> ensureItemVisible(button)); - }); + button.setOnFocusChangeListener((view, hasFocus) -> styleItem(button, position)); button.setOnClickListener(view -> onItemClick(position)); button.setOnKeyListener((view, keyCode, event) -> { if (!Util.isLeanback() || event == null || event.getAction() != KeyEvent.ACTION_DOWN) return false; @@ -490,18 +441,17 @@ public final class ChoiceDialog extends DialogFragment { } private void styleItem(MaterialButton button, int position) { - ThemeTokens tokens = ThemeController.current(); if (!itemEnabled(position)) { - button.setTextColor(ColorStateList.valueOf(ThemeEditorUi.withAlpha(tokens.colorOnSurfaceVariant(), 0.45f))); - button.setBackgroundTintList(ColorStateList.valueOf(tokens.colorSurfaceContainerHighest())); - button.setStrokeColor(ColorStateList.valueOf(tokens.colorOutlineVariant())); + button.setTextColor(ColorStateList.valueOf(Color.parseColor("#9AA0A6"))); + button.setBackgroundTintList(ColorStateList.valueOf(Color.parseColor("#F1F3F4"))); + button.setStrokeColor(ColorStateList.valueOf(Color.parseColor("#E0E0E0"))); return; } boolean on = itemSelected(position); boolean focused = button.isFocused(); - int text = focused ? tokens.colorOnPrimary() : on ? tokens.colorOnPrimaryContainer() : tokens.colorOnSurface(); - int bg = focused ? tokens.colorPrimary() : on ? tokens.colorPrimaryContainer() : tokens.colorSurfaceContainer(); - int stroke = focused ? tokens.colorOnPrimary() : on ? tokens.colorPrimary() : tokens.colorOutline(); + int text = focused ? Color.WHITE : on ? Color.parseColor("#174EA6") : Color.parseColor("#202124"); + int bg = focused ? Color.parseColor("#1A73E8") : on ? Color.parseColor("#E8F0FE") : Color.WHITE; + int stroke = focused ? Color.parseColor("#174EA6") : on ? Color.parseColor("#8AB4F8") : Color.parseColor("#DADCE0"); button.setTextColor(ColorStateList.valueOf(text)); button.setBackgroundTintList(ColorStateList.valueOf(bg)); button.setStrokeColor(ColorStateList.valueOf(stroke)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/CodecCapabilityDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/CodecCapabilityDialog.java index 4d4bb8ddb4..51e774696b 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/CodecCapabilityDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/CodecCapabilityDialog.java @@ -189,7 +189,7 @@ public final class CodecCapabilityDialog { } private int getReportHeight() { - int screen = DialogSafeArea.height(activity); + int screen = ResUtil.getScreenHeight(activity); if (Util.isLeanback()) return Math.min(ResUtil.dp2px(430), Math.round(screen * 0.58f)); return Math.min(ResUtil.dp2px(330), Math.round(screen * (ResUtil.isLand(activity) ? 0.44f : 0.36f))); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ComicSourceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ComicSourceDialog.java index a238a3f1c7..7b0435014f 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ComicSourceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ComicSourceDialog.java @@ -13,9 +13,9 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.ComicSourceConfig; import com.fongmi.android.tv.bean.Site; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.List; @@ -70,7 +70,7 @@ public class ComicSourceDialog { manageBtn.setOnClickListener(v -> showSiteManage()); resetBtn.setOnClickListener(v -> resetToDefault()); - dialog = new WebHtvAlertDialogBuilder(activity) + dialog = new MaterialAlertDialogBuilder(activity) .setTitle(R.string.setting_comic_source) .setView(view) .setPositiveButton(R.string.dialog_positive, (d, w) -> onSave()) @@ -103,7 +103,7 @@ public class ComicSourceDialog { checked[i] = matchesRule(enabledRules, site); } - new WebHtvAlertDialogBuilder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(R.string.dialog_comic_site_manage) .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> checked[which] = isChecked) .setPositiveButton(R.string.dialog_positive, (d, w) -> applySiteManage(sites, enabledRules, checked)) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/CustomCspDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/CustomCspDialog.java index 32fd33545c..78c6f8c793 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/CustomCspDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/CustomCspDialog.java @@ -49,11 +49,6 @@ import com.fongmi.android.tv.databinding.AdapterCustomCspBinding; import com.fongmi.android.tv.databinding.DialogCustomCspBinding; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.setting.CustomCspSetting; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeEditorUi; -import com.fongmi.android.tv.theme.ThemeTokens; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.ui.custom.CustomNestedScrollView; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.ui.custom.SafeScrollEditText; import com.fongmi.android.tv.ui.custom.SettingClipboardOverlay; @@ -100,9 +95,6 @@ public class CustomCspDialog extends BaseAlertDialog { private boolean pendingFilesImport; private TextInputEditText pendingFileTarget; private Set initialItemIds = new HashSet<>(); - private ThemeTokens themeTokens() { - return ThemeController.current(); - } private final Set pendingDeleteIds = new HashSet<>(); private CspEditor editor; private TextInputEditText recognizeInput; @@ -143,7 +135,7 @@ public class CustomCspDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -154,9 +146,24 @@ public class CustomCspDialog extends BaseAlertDialog { getDialog().setCanceledOnTouchOutside(false); Window window = getDialog().getWindow(); if (window == null) return; + WindowManager.LayoutParams params = window.getAttributes(); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); + boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); - applyPageSizing(window, binding.root, binding.contentScroll); + params.width = (int) (screenWidth * (land ? 0.76f : 0.94f)); + params.height = land ? (int) (screenHeight * 0.98f) : WindowManager.LayoutParams.WRAP_CONTENT; + window.setAttributes(params); + window.setLayout(params.width, params.height); + ViewGroup.LayoutParams rootParams = binding.root.getLayoutParams(); + rootParams.height = land ? params.height : ViewGroup.LayoutParams.WRAP_CONTENT; + binding.root.setLayoutParams(rootParams); + LinearLayoutCompat.LayoutParams scrollParams = (LinearLayoutCompat.LayoutParams) binding.contentScroll.getLayoutParams(); + scrollParams.height = land ? 0 : ViewGroup.LayoutParams.WRAP_CONTENT; + scrollParams.weight = land ? 1 : 0; + binding.contentScroll.setLayoutParams(scrollParams); + binding.contentScroll.setMaxHeight(land ? 0 : (int) (screenHeight * 0.58f)); binding.enabled.requestFocus(); if (clipboardOverlay == null) clipboardOverlay = SettingClipboardOverlay.attach(this, binding.getRoot()); getDialog().setOnKeyListener((dialog, keyCode, event) -> { @@ -168,60 +175,6 @@ public class CustomCspDialog extends BaseAlertDialog { }); } - /** - * 站点注入页面的窗口/内容尺寸策略:**两种朝向都铺满整页**,拆成静态方法便于 JVM 测量测试 - * 锁定“底部按钮区不被压扁”与“页面确实铺满”。 - * - *

旧策略两种朝向都不铺满:竖屏按比例手算高度({@code WRAP_CONTENT} 窗口 + 滚动区 - * {@code wrap_content} + {@code 0.58H} 上限 + 无 weight),横屏是 {@code 0.76×0.98} 居中 - * 比例弹窗。前者在条目多时把底部按钮区挤出窗口下沿裁掉(设备实测取消/确定 从 40dp 变 - * 34.9dp、末张卡片操作行只剩 9.1dp);后者在 1080×1920/1920×1080 设备上左右各空出 - * 一大块背景,用户实测反馈“没有全屏”。这个页面内容多,应按用户要求统一改成全屏整页。 - * - *

铺满后窗口高度交给系统,固定高的按钮区永远保住自然高度,剩余空间全部由权重滚动区 - * 吃掉;页面左右边缘由 XML 的 20dp padding 保证正文不贴边。 - * - *

铺满后窗口自身没有多余空间躲屏幕键盘,必须显式 ADJUST_RESIZE,否则底部按钮和列表会被 - * 键盘盖住(与 AboutDialog 全屏弹窗同一处理)。 - */ - static void applyPageSizing(Window window, LinearLayoutCompat root, CustomNestedScrollView scroll) { - WindowManager.LayoutParams params = window.getAttributes(); - params.width = WindowManager.LayoutParams.MATCH_PARENT; - params.height = WindowManager.LayoutParams.MATCH_PARENT; - params.gravity = Gravity.CENTER; - window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); - window.setAttributes(params); - window.setLayout(params.width, params.height); - ViewGroup.LayoutParams rootParams = root.getLayoutParams(); - rootParams.height = ViewGroup.LayoutParams.MATCH_PARENT; - root.setLayoutParams(rootParams); - expandToWindow(root); - // 滚动区吃权重、不再用 maxHeight 手算上限,剩余空间全部由它吃掉。 - LinearLayoutCompat.LayoutParams scrollParams = (LinearLayoutCompat.LayoutParams) scroll.getLayoutParams(); - scrollParams.height = 0; - scrollParams.weight = 1; - scroll.setLayoutParams(scrollParams); - scroll.setMaxHeight(0); - } - - /** - * 铺满整页:把 root 之上的 Material 弹窗面板({@code @id/custom} → customPanel → parentPanel) - * 一并改成 match_parent。 - * - *

它们默认 wrap_content,内容比窗口短时会让 root 的 match_parent 退化成“按内容高度”, - * 页面底部留出空隙、底部按钮区不贴底。改成 match_parent 后窗口高度一路直达 root, - * 剩余空间全部由权重滚动区吃掉;DecorView 的参数不是 MarginLayoutParams,循环到那里自然结束。 - */ - private static void expandToWindow(View root) { - for (ViewParent parent = root.getParent(); parent instanceof ViewGroup; parent = parent.getParent()) { - ViewGroup group = (ViewGroup) parent; - ViewGroup.LayoutParams params = group.getLayoutParams(); - if (!(params instanceof ViewGroup.MarginLayoutParams)) break; - params.height = ViewGroup.LayoutParams.MATCH_PARENT; - group.setLayoutParams(params); - } - } - @Override protected void initView() { registry = CustomCspSetting.load(); @@ -262,13 +215,6 @@ public class CustomCspDialog extends BaseAlertDialog { if (checkedId == R.id.uiMode && !showTextMode(false)) binding.modeGroup.check(R.id.textMode); }); setupScrollableText(binding.jsonText); - binding.siteSearch.addTextChangedListener(new CustomTextListener() { - @Override - public void afterTextChanged(Editable editable) { - adapter.setSearchQuery(editable == null ? "" : editable.toString()); - updateModeVisibility(); - } - }); binding.add.setOnClickListener(view -> addItem()); binding.recognize.setOnClickListener(view -> showRecognizePanel()); binding.sort.setOnClickListener(view -> setSortMode(!sortMode)); @@ -362,11 +308,8 @@ public class CustomCspDialog extends BaseAlertDialog { private void updateModeVisibility() { boolean listMode = !textMode && !editMode; - boolean searchMode = listMode && !sortMode; - boolean mobileSort = Util.isMobile() && listMode && !adapter.hasSearchQuery(); + boolean mobileSort = Util.isMobile() && listMode; binding.recycler.setVisibility(listMode ? View.VISIBLE : View.GONE); - binding.searchLayout.setVisibility(searchMode ? View.VISIBLE : View.GONE); - binding.searchEmpty.setVisibility(searchMode && adapter.hasSearchQuery() && adapter.getItemCount() == 0 ? View.VISIBLE : View.GONE); binding.jsonLayout.setVisibility(textMode && !editMode ? View.VISIBLE : View.GONE); binding.editPanel.setVisibility(editMode ? View.VISIBLE : View.GONE); binding.add.setVisibility(listMode && !sortMode ? View.VISIBLE : View.GONE); @@ -381,7 +324,7 @@ public class CustomCspDialog extends BaseAlertDialog { } private void setSortMode(boolean sort) { - if (sort && (!Util.isMobile() || textMode || editMode || adapter.hasSearchQuery())) return; + if (sort && (!Util.isMobile() || textMode || editMode)) return; if (sortMode == sort) { updateModeVisibility(); return; @@ -685,7 +628,7 @@ public class CustomCspDialog extends BaseAlertDialog { LinearLayoutCompat container = new LinearLayoutCompat(requireContext()); container.setOrientation(LinearLayoutCompat.VERTICAL); container.setPadding(0, dp(4), 0, 0); - MaterialTextView title = text(getString(R.string.setting_custom_csp_recognize_title), 18, themeTokens().colorOnSurface(), true); + MaterialTextView title = text(getString(R.string.setting_custom_csp_recognize_title), 18, Color.BLACK, true); container.addView(title, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.topMargin = dp(10); @@ -1006,8 +949,8 @@ public class CustomCspDialog extends BaseAlertDialog { TextInputEditText input = new TextInputEditText(requireContext()); input.setSelectAllOnFocus(false); input.setSingleLine(!multiline); - input.setTextColor(themeTokens().colorOnSurface()); - input.setHintTextColor(themeTokens().colorOnSurfaceVariant()); + input.setTextColor(Color.BLACK); + input.setHintTextColor(Color.parseColor("#666666")); input.setInputType(multiline ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS : InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); input.setGravity(multiline ? Gravity.START | Gravity.TOP : Gravity.CENTER_VERTICAL); return input; @@ -1023,11 +966,10 @@ public class CustomCspDialog extends BaseAlertDialog { container.setPadding(ResUtil.dp2px(20), ResUtil.dp2px(8), ResUtil.dp2px(20), 0); TextInputLayout layout = new TextInputLayout(requireContext()); layout.setHint(hint); - ThemeTokens tokens = themeTokens(); layout.setBoxBackgroundMode(TextInputLayout.BOX_BACKGROUND_OUTLINE); - layout.setBoxBackgroundColor(tokens.colorSurfaceContainerHighest()); - layout.setBoxStrokeColor(tokens.colorOutline()); - layout.setHintTextColor(ColorStateList.valueOf(tokens.colorOnSurfaceVariant())); + layout.setBoxBackgroundColor(Color.WHITE); + layout.setBoxStrokeColor(ResUtil.getColor(R.color.dialog_outlined_button_stroke)); + layout.setHintTextColor(ColorStateList.valueOf(Color.parseColor("#5F6368"))); layout.addView(input, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); container.addView(layout, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return container; @@ -1037,7 +979,7 @@ public class CustomCspDialog extends BaseAlertDialog { LinearLayoutCompat container = new LinearLayoutCompat(requireContext()); container.setOrientation(LinearLayoutCompat.VERTICAL); container.setPadding(ResUtil.dp2px(20), ResUtil.dp2px(8), ResUtil.dp2px(20), 0); - MaterialTextView label = text(getString(R.string.setting_custom_csp_other_fields), 12, themeTokens().colorOnSurfaceVariant(), true); + MaterialTextView label = text(getString(R.string.setting_custom_csp_other_fields), 12, Color.parseColor("#5F6368"), true); container.addView(label, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); LinearLayoutCompat.LayoutParams buttonParams = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(40)); buttonParams.topMargin = dp(4); @@ -1061,11 +1003,10 @@ public class CustomCspDialog extends BaseAlertDialog { private TextInputLayout createOtherValueLayout(TextInputEditText input) { TextInputLayout layout = new TextInputLayout(requireContext()); - ThemeTokens tokens = themeTokens(); layout.setBoxBackgroundMode(TextInputLayout.BOX_BACKGROUND_OUTLINE); - layout.setBoxBackgroundColor(tokens.colorSurfaceContainerHighest()); - layout.setBoxStrokeColor(tokens.colorOutline()); - layout.setHintTextColor(ColorStateList.valueOf(tokens.colorOnSurfaceVariant())); + layout.setBoxBackgroundColor(Color.WHITE); + layout.setBoxStrokeColor(ResUtil.getColor(R.color.dialog_outlined_button_stroke)); + layout.setHintTextColor(ColorStateList.valueOf(Color.parseColor("#5F6368"))); layout.addView(input, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return layout; } @@ -1265,32 +1206,24 @@ public class CustomCspDialog extends BaseAlertDialog { } private int statusColor(CustomCspSetting.Item item) { - ThemeTokens tokens = themeTokens(); - if (!effectiveEnabled(item)) return tokens.colorOnSurfaceVariant(); - return item.isValid() && !item.hasInvalidExtensions() ? tokens.colorSuccess() : tokens.colorError(); + if (!effectiveEnabled(item)) return Color.parseColor("#6F7378"); + return item.isValid() && !item.hasInvalidExtensions() ? Color.parseColor("#137333") : Color.parseColor("#B3261E"); } private Drawable rowBackground(CustomCspSetting.Item item) { - ThemeTokens tokens = themeTokens(); boolean disabled = !effectiveEnabled(item); - int focusFill = tokens.colorPrimaryContainer(); - int focusStroke = ThemeEditorUi.readable(tokens.colorPrimary(), focusFill); - int normalFill = disabled ? tokens.colorSurfaceContainerHighest() - : item.isValid() ? tokens.colorSurfaceContainerHigh() : tokens.colorErrorContainer(); - int normalStroke = disabled ? tokens.colorOutlineVariant() - : item.isValid() ? tokens.colorOutlineVariant() : tokens.colorError(); StateListDrawable drawable = new StateListDrawable(); - drawable.addState(new int[]{android.R.attr.state_focused}, rowShape(focusFill, focusStroke, 2, 8)); - drawable.addState(new int[]{android.R.attr.state_pressed}, rowShape(focusFill, focusStroke, 2, 8)); - drawable.addState(new int[]{android.R.attr.state_activated}, rowShape(focusFill, focusStroke, 2, 8)); - drawable.addState(new int[]{}, rowShape(normalFill, normalStroke, 1, 6)); + drawable.addState(new int[]{android.R.attr.state_focused}, rowShape("#E8F0FE", "#1A73E8", 2, 8)); + drawable.addState(new int[]{android.R.attr.state_pressed}, rowShape("#E8F0FE", "#1A73E8", 2, 8)); + drawable.addState(new int[]{android.R.attr.state_activated}, rowShape("#E8F0FE", "#1A73E8", 2, 8)); + drawable.addState(new int[]{}, rowShape(disabled ? "#ECEFF1" : item.isValid() ? "#F5F6F7" : "#FFF7F7", disabled ? "#D5D9DE" : item.isValid() ? "#DADCE0" : "#F1C9C6", 1, 6)); return drawable; } - private GradientDrawable rowShape(int color, int stroke, int strokeDp, int radiusDp) { + private GradientDrawable rowShape(String color, String stroke, int strokeDp, int radiusDp) { GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(color); - drawable.setStroke(dp(strokeDp), stroke); + drawable.setColor(Color.parseColor(color)); + drawable.setStroke(dp(strokeDp), Color.parseColor(stroke)); drawable.setCornerRadius(dp(radiusDp)); return drawable; } @@ -1311,7 +1244,7 @@ public class CustomCspDialog extends BaseAlertDialog { view.setSingleLine(true); view.setPadding(dp(8), dp(3), dp(8), dp(3)); GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(themeTokens().colorSurfaceContainerHighest()); + drawable.setColor(Color.WHITE); drawable.setStroke(dp(1), color); drawable.setCornerRadius(dp(6)); view.setBackground(drawable); @@ -1323,12 +1256,11 @@ public class CustomCspDialog extends BaseAlertDialog { } private int titleColor(boolean enabled) { - ThemeTokens tokens = themeTokens(); - return enabled ? tokens.colorOnSurface() : tokens.colorOnSurfaceVariant(); + return Color.parseColor(enabled ? "#202124" : "#8A8D91"); } private int detailColor(boolean disabled) { - return themeTokens().colorOnSurfaceVariant(); + return Color.parseColor(disabled ? "#9AA0A6" : "#5F6368"); } private void addDetail(LinearLayoutCompat root, String value) { @@ -1351,7 +1283,7 @@ public class CustomCspDialog extends BaseAlertDialog { button.setMinimumHeight(dp(36)); button.setPadding(dp(6), 0, dp(6), 0); ColorStateList bg = ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_bg : R.color.dialog_outlined_button_bg); - ColorStateList fg = danger ? ColorStateList.valueOf(themeTokens().colorError()) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); + ColorStateList fg = danger ? ColorStateList.valueOf(Color.parseColor("#B3261E")) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); button.setBackgroundTintList(bg); button.setTextColor(fg); if (!tonal) { @@ -1371,7 +1303,7 @@ public class CustomCspDialog extends BaseAlertDialog { AppCompatImageButton button = new AppCompatImageButton(requireContext()); button.setBackgroundResource(R.drawable.selector_dialog_switch); button.setImageResource(icon); - button.setColorFilter(themeTokens().colorOnSurfaceVariant()); + button.setColorFilter(Color.parseColor("#5F6368")); button.setContentDescription(getString(contentDescription)); button.setFocusable(true); button.setPadding(dp(8), dp(8), dp(8), dp(8)); @@ -1396,60 +1328,36 @@ public class CustomCspDialog extends BaseAlertDialog { private class CspAdapter extends RecyclerView.Adapter { private final List items; - private final List visibleIndices = new ArrayList<>(); private boolean reverseOrder; private boolean sortMode; - private String searchQuery = ""; CspAdapter(List items) { this.items = items; - refreshVisibleIndices(); } List getItems() { return items; } - boolean hasSearchQuery() { - return !TextUtils.isEmpty(searchQuery.trim()); - } - - void setSearchQuery(String query) { - String value = query == null ? "" : query; - if (TextUtils.equals(searchQuery, value)) return; - searchQuery = value; - refreshVisibleIndices(); - notifyDataSetChanged(); - } - - private void refreshVisibleIndices() { - visibleIndices.clear(); - for (int i = 0; i < items.size(); i++) { - if (!hasSearchQuery() || CustomCspSetting.matchesSearch(items.get(i), searchQuery)) visibleIndices.add(i); - } - } - int add(CustomCspSetting.Item item) { items.add(item); - refreshVisibleIndices(); markJsonDirty(); - notifyDataSetChanged(); - return displayPosition(items.size() - 1); + int position = displayPosition(items.size() - 1); + notifyItemInserted(position); + return position; } void replace(int position, CustomCspSetting.Item item) { if (position < 0 || position >= items.size()) return; CustomCspSetting.Item old = items.set(position, item); if (!old.isLive() && old.site().getKey().equals(registry.getHomeKey())) registry.setHomeKey(item.isLive() ? "" : item.site().getKey()); - refreshVisibleIndices(); markJsonDirty(); - notifyDataSetChanged(); + notifyItemChanged(displayPosition(position)); } void setItems(List items) { this.items.clear(); this.items.addAll(items); - refreshVisibleIndices(); markJsonDirty(); notifyDataSetChanged(); } @@ -1476,18 +1384,17 @@ public class CustomCspDialog extends BaseAlertDialog { } int moveDisplay(int fromPosition, int toPosition) { - if (hasSearchQuery() || fromPosition < 0 || toPosition < 0 || fromPosition >= getItemCount() || toPosition >= getItemCount()) return -1; + if (fromPosition < 0 || toPosition < 0 || fromPosition >= items.size() || toPosition >= items.size()) return -1; return moveItemToIndex(itemIndex(fromPosition), itemIndex(toPosition)); } int moveItemToIndex(int fromIndex, int toIndex) { - if (hasSearchQuery() || fromIndex < 0 || toIndex < 0 || fromIndex >= items.size() || toIndex >= items.size()) return -1; + if (fromIndex < 0 || toIndex < 0 || fromIndex >= items.size() || toIndex >= items.size()) return -1; if (fromIndex == toIndex) return displayPosition(toIndex); int fromPosition = displayPosition(fromIndex); int toPosition = displayPosition(toIndex); CustomCspSetting.Item item = items.remove(fromIndex); items.add(toIndex, item); - refreshVisibleIndices(); markJsonDirty(); notifyItemMoved(fromPosition, toPosition); notifyItemRangeChanged(Math.min(fromPosition, toPosition), Math.abs(fromPosition - toPosition) + 1); @@ -1495,30 +1402,23 @@ public class CustomCspDialog extends BaseAlertDialog { } void remove(int position, View removed) { - if (position < 0 || position >= getItemCount()) return; + if (position < 0 || position >= items.size()) return; int index = itemIndex(position); - if (index < 0 || index >= items.size()) return; focusBeforeRemove(removed); CustomCspSetting.Item item = items.remove(index); if (!item.isLive() && item.site().getKey().equals(registry.getHomeKey())) registry.setHomeKey(""); if (!TextUtils.isEmpty(item.getId())) pendingDeleteIds.add(item.getId()); - refreshVisibleIndices(); markJsonDirty(); notifyDataSetChanged(); - updateModeVisibility(); } int itemIndex(int position) { - if (position < 0 || position >= visibleIndices.size()) return -1; - int visiblePosition = reverseOrder ? visibleIndices.size() - 1 - position : position; - return visibleIndices.get(visiblePosition); + return reverseOrder ? items.size() - 1 - position : position; } int displayPosition(int index) { if (index < 0 || index >= items.size()) return -1; - int visiblePosition = visibleIndices.indexOf(index); - if (visiblePosition < 0) return -1; - return reverseOrder ? visibleIndices.size() - 1 - visiblePosition : visiblePosition; + return reverseOrder ? items.size() - 1 - index : index; } void setHome(CustomCspSetting.Item item) { @@ -1531,7 +1431,7 @@ public class CustomCspDialog extends BaseAlertDialog { @Override public int getItemCount() { - return visibleIndices.size(); + return items.size(); } @NonNull @@ -1549,7 +1449,6 @@ public class CustomCspDialog extends BaseAlertDialog { @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { int index = itemIndex(position); - if (index < 0 || index >= items.size()) return; holder.bind(items.get(index), index); } @@ -1599,11 +1498,6 @@ public class CustomCspDialog extends BaseAlertDialog { } else { AppCompatImageButton up = iconButton(R.drawable.ic_subtitle_up, R.string.setting_custom_csp_up, view -> move(getBindingAdapterPosition(), getBindingAdapterPosition() - 1)); AppCompatImageButton down = iconButton(R.drawable.ic_subtitle_down, R.string.setting_custom_csp_down, view -> move(getBindingAdapterPosition(), getBindingAdapterPosition() + 1)); - boolean canMove = !hasSearchQuery(); - up.setEnabled(canMove); - down.setEnabled(canMove); - up.setAlpha(canMove ? 1.0f : 0.45f); - down.setAlpha(canMove ? 1.0f : 0.45f); linkCardFocus(root, up); linkCardFocus(root, down); header.addView(up, iconLayout(8)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuDialog.java index 88766408e2..1a1a1a3ac8 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuDialog.java @@ -14,7 +14,6 @@ import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.viewbinding.ViewBinding; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Danmaku; import com.fongmi.android.tv.databinding.DialogDanmakuBinding; import com.fongmi.android.tv.player.PlayerManager; @@ -33,8 +32,6 @@ public final class DanmakuDialog extends BaseBottomSheetDialog implements Danmak private String vodId; private String rawTitle; private String episodeName; - private int tmdbId; - private int tmdbSeasonNumber; public interface Host { @@ -62,12 +59,6 @@ public final class DanmakuDialog extends BaseBottomSheetDialog implements Danmak return this; } - public DanmakuDialog tmdb(int tmdbId, int tmdbSeasonNumber) { - this.tmdbId = tmdbId; - this.tmdbSeasonNumber = tmdbSeasonNumber; - return this; - } - public void show(FragmentActivity activity) { for (Fragment f : activity.getSupportFragmentManager().getFragments()) if (f instanceof DanmakuDialog) return; show(activity.getSupportFragmentManager(), null); @@ -95,12 +86,6 @@ public final class DanmakuDialog extends BaseBottomSheetDialog implements Danmak protected void initEvent() { binding.search.setOnClickListener(this::onSearch); binding.load.setOnCheckedChangeListener((button, checked) -> onLoadChanged(checked)); - // 焦点由 loadHost 承载(开关自身不聚焦,否则宿主的焦点环不会出现), - // 所以点击/遥控确定的切换必须由宿主转给开关。 - // loadHost 只在 leanback 布局中存在:main 里声明 @id/loadHost 后按 id 查找, - // mobile 布局查不到会返回 null,整段跳过,mobile 行为与改动前完全一致。 - View loadHost = binding.getRoot().findViewById(R.id.loadHost); - if (loadHost != null && loadHost != binding.load) loadHost.setOnClickListener(view -> binding.load.toggle()); binding.choose.setOnClickListener(this::onChoose); binding.setting.setOnClickListener(this::onSetting); } @@ -120,10 +105,8 @@ public final class DanmakuDialog extends BaseBottomSheetDialog implements Danmak FragmentActivity activity = getActivity(); if (activity == null) return; dismissAllowingStateLoss(); - if (shouldUseInputDialog(activity)) - DanmakuSearchInputDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).tmdb(tmdbId, tmdbSeasonNumber).restoreParent(true).show(activity); - else - DanmakuSearchDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).tmdb(tmdbId, tmdbSeasonNumber).restoreParent(true).show(activity); + if (shouldUseInputDialog(activity)) DanmakuSearchInputDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).restoreParent(true).show(activity); + else DanmakuSearchDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).restoreParent(true).show(activity); } private boolean shouldUseInputDialog(FragmentActivity activity) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchDialog.java index 94833d41fc..f9f2f6d1fd 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchDialog.java @@ -89,9 +89,6 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac private String vodId; private String rawTitle; private String episodeName; - private int tmdbId; - private int tmdbSeasonNumber; - private final DanmakuSearchIntent searchIntent = new DanmakuSearchIntent(); public static DanmakuSearchDialog create() { return new DanmakuSearchDialog(); @@ -136,12 +133,6 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac return this; } - public DanmakuSearchDialog tmdb(int tmdbId, int tmdbSeasonNumber) { - this.tmdbId = tmdbId; - this.tmdbSeasonNumber = tmdbSeasonNumber; - return this; - } - public void show(FragmentActivity activity) { for (Fragment f : activity.getSupportFragmentManager().getFragments()) if (f instanceof DanmakuSearchDialog) return; show(activity.getSupportFragmentManager(), null); @@ -187,7 +178,6 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac public void onDestroyView() { super.onDestroyView(); activeCall = null; - searchIntent.cancel(); DanmakuApi.cancel(); } @@ -196,7 +186,7 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac super.onDismiss(dialog); FragmentActivity activity = getActivity(); if (!restoreParent || activity == null || activity.isFinishing()) return; - DanmakuDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).tmdb(tmdbId, tmdbSeasonNumber).show(activity); + DanmakuDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).show(activity); } private LinearLayout createContentView() { @@ -361,10 +351,7 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac private void rememberManualDanmaku(Danmaku item) { if (TextUtils.isEmpty(siteKey) || TextUtils.isEmpty(vodId) || item == null || item.isEmpty()) return; DanmakuMatchCache cache = Setting.getDanmakuMatchCache(); - String keyword = searchIntent.getResultKeyword(); - MediaTitleLearningExample example = cache.put(siteKey, vodId, first(episodeName, getEpisode().toString().trim()), keyword, first(rawTitle, getTitle().toString()), item); - cache.putSeries(siteKey, vodId, keyword, first(rawTitle, getTitle().toString()), item); - cache.putTmdbSeason(tmdbId, tmdbSeasonNumber, keyword, first(rawTitle, getTitle().toString()), item); + MediaTitleLearningExample example = cache.put(siteKey, vodId, first(episodeName, getEpisode().toString().trim()), getKeywordText(), first(rawTitle, getTitle().toString()), item); Setting.putDanmakuMatchCache(cache); if (example != null) MediaTitleLearningStore.load().put(example); } @@ -414,26 +401,19 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac } private void search() { - String keyword = getKeywordText(); showProgress(); activeCall = null; - searchIntent.cancel(); Util.hideKeyboard(keywordView); - Call call = DanmakuApi.newCall(keyword, getEpisode().toString().trim()); + Call call = DanmakuApi.newCall(getKeywordText(), getEpisode().toString().trim()); if (call == null) { hideProgress(true); Notify.show(R.string.danmaku_api_invalid); return; } - searchIntent.begin(call, keyword); activeCall = call; call.enqueue(this); } - private boolean isCurrentResult(Call call) { - return call == activeCall && searchIntent.isCurrent(call); - } - private void onSuccess(List items) { syncCurrentState(items); buildGroups(items); @@ -608,27 +588,21 @@ public final class DanmakuSearchDialog extends DialogFragment implements Callbac @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - if (call != activeCall || !searchIntent.complete(call)) return; + if (call != activeCall) return; try { String body = response.body() == null ? "" : response.body().string(); List items = DanmakuApi.arrayFrom(body); if (items.isEmpty()) throw new Exception(ResUtil.getString(R.string.error_empty)); - App.post(() -> { - if (isCurrentResult(call)) onSuccess(items); - }); + App.post(() -> onSuccess(items)); } catch (Exception e) { - App.post(() -> { - if (isCurrentResult(call)) onError(e); - }); + App.post(() -> onError(e)); } } @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { - if (call != activeCall || !searchIntent.complete(call)) return; - App.post(() -> { - if (isCurrentResult(call)) onError(e); - }); + if (call != activeCall) return; + App.post(() -> onError(e)); } private int dp(int value) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchInputDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchInputDialog.java index 99ebcbc219..2f3109f219 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchInputDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchInputDialog.java @@ -88,9 +88,6 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca private String vodId; private String rawTitle; private String episodeName; - private int tmdbId; - private int tmdbSeasonNumber; - private final DanmakuSearchIntent searchIntent = new DanmakuSearchIntent(); public static DanmakuSearchInputDialog create() { return new DanmakuSearchInputDialog(); @@ -119,12 +116,6 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca return this; } - public DanmakuSearchInputDialog tmdb(int tmdbId, int tmdbSeasonNumber) { - this.tmdbId = tmdbId; - this.tmdbSeasonNumber = tmdbSeasonNumber; - return this; - } - public void show(FragmentActivity activity) { for (Fragment f : activity.getSupportFragmentManager().getFragments()) if (f instanceof DanmakuSearchInputDialog) return; show(activity.getSupportFragmentManager(), null); @@ -167,14 +158,13 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca super.onDismiss(dialog); FragmentActivity activity = getActivity(); if (selected || !restoreParent || activity == null || activity.isFinishing()) return; - DanmakuDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).tmdb(tmdbId, tmdbSeasonNumber).show(activity); + DanmakuDialog.create().player(player).identity(siteKey, vodId, rawTitle, episodeName).show(activity); } @Override public void onDestroyView() { super.onDestroyView(); activeCall = null; - searchIntent.cancel(); DanmakuApi.cancel(); } @@ -189,10 +179,7 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca private void rememberManualDanmaku(Danmaku item) { if (TextUtils.isEmpty(siteKey) || TextUtils.isEmpty(vodId) || item == null || item.isEmpty()) return; DanmakuMatchCache cache = Setting.getDanmakuMatchCache(); - String keyword = searchIntent.getResultKeyword(); - MediaTitleLearningExample example = cache.put(siteKey, vodId, first(episodeName, getEpisode()), keyword, first(rawTitle, getTitle()), item); - cache.putSeries(siteKey, vodId, keyword, first(rawTitle, getTitle()), item); - cache.putTmdbSeason(tmdbId, tmdbSeasonNumber, keyword, first(rawTitle, getTitle()), item); + MediaTitleLearningExample example = cache.put(siteKey, vodId, first(episodeName, getEpisode()), getKeyword(), first(rawTitle, getTitle()), item); Setting.putDanmakuMatchCache(cache); if (example != null) MediaTitleLearningStore.load().put(example); } @@ -366,22 +353,16 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca input.setError(null); showProgress(); activeCall = null; - searchIntent.cancel(); Util.hideKeyboard(input); Call call = DanmakuApi.newCall(keyword, getEpisode()); if (call == null) { showError(getString(R.string.danmaku_api_invalid)); return; } - searchIntent.begin(call, keyword); activeCall = call; call.enqueue(this); } - private boolean isCurrentResult(Call call) { - return call == activeCall && searchIntent.isCurrent(call); - } - private String getEpisode() { CharSequence episode = player == null || player.getMetadata() == null ? "" : player.getMetadata().artist; return episode == null ? "" : episode.toString().trim(); @@ -578,27 +559,21 @@ public final class DanmakuSearchInputDialog extends DialogFragment implements Ca @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - if (call != activeCall || !searchIntent.complete(call)) return; + if (call != activeCall) return; try { String body = response.body() == null ? "" : response.body().string(); List items = DanmakuApi.arrayFrom(body); if (items.isEmpty()) throw new Exception(ResUtil.getString(R.string.error_empty)); - App.post(() -> { - if (isCurrentResult(call)) onSuccess(items); - }); + App.post(() -> onSuccess(items)); } catch (Exception e) { - App.post(() -> { - if (isCurrentResult(call)) showError(e.getMessage()); - }); + App.post(() -> showError(e.getMessage())); } } @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { - if (call != activeCall || !searchIntent.complete(call)) return; - App.post(() -> { - if (isCurrentResult(call)) showError(e.getMessage()); - }); + if (call != activeCall) return; + App.post(() -> showError(e.getMessage())); } private int dp(int value) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchIntent.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchIntent.java deleted file mode 100644 index ccb7838805..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchIntent.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -final class DanmakuSearchIntent { - - private Object currentRequest; - private String submittedKeyword = ""; - private String resultKeyword = ""; - - synchronized void begin(Object request, String keyword) { - currentRequest = request; - submittedKeyword = clean(keyword); - resultKeyword = ""; - } - - synchronized boolean complete(Object request) { - if (request == null || request != currentRequest) return false; - resultKeyword = submittedKeyword; - return true; - } - - synchronized boolean isCurrent(Object request) { - return request != null && request == currentRequest; - } - - synchronized void cancel() { - currentRequest = null; - submittedKeyword = ""; - resultKeyword = ""; - } - - synchronized String getResultKeyword() { - return resultKeyword; - } - - private static String clean(String value) { - return value == null ? "" : value.trim(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DebugLogDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DebugLogDialog.java index 984037f3a2..bb453b4617 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DebugLogDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DebugLogDialog.java @@ -6,14 +6,13 @@ import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.net.Uri; +import android.graphics.Color; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; import com.fongmi.android.tv.server.Server; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Notify; import com.github.catvod.crawler.SpiderDebug; @@ -36,45 +35,10 @@ public final class DebugLogDialog { String message = activity.getString(R.string.debug_log_dialog_message, lanUrl, localUrl); MaterialTextView content = new MaterialTextView(activity); content.setText(message); - // 说明文字是次要前景:跟随调色板的 onSurfaceVariant。原先硬编码 #5F6368, - // 而承载它的 shape_shell_proxy_dialog 面板是 ?attr/colorSurfaceContainerHigh - // (日间浅色、夜间深色),于是夜间变成深底深字(实测 2.23:1)。 - content.setTextColor(ThemeController.current().colorOnSurfaceVariant()); + content.setTextColor(Color.parseColor("#5F6368")); content.setTextSize(14); content.setLineSpacing(ResUtil.dp2px(2), 1f); - android.widget.LinearLayout panel = new android.widget.LinearLayout(activity); panel.setOrientation(android.widget.LinearLayout.VERTICAL); - panel.addView(content); - for (com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category category : com.github.catvod.crawler.diagnostics.DiagnosticCategories.Category.values()) { - androidx.appcompat.widget.SwitchCompat toggle = new androidx.appcompat.widget.SwitchCompat(activity); - toggle.setText(category.title); - // 开关标签是主要前景:跟随调色板的 onSurface(原先硬编码 #202124,夜间仅 1.19:1)。 - toggle.setTextColor(ThemeController.current().colorOnSurface()); - toggle.setPadding(0, ResUtil.dp2px(8), 0, ResUtil.dp2px(8)); - toggle.setFocusable(true); - toggle.setChecked(com.github.catvod.crawler.diagnostics.DiagnosticCategories.accepts(com.github.catvod.crawler.DebugLogStore.categories(), category)); - toggle.setOnCheckedChangeListener((button, checked) -> com.github.catvod.crawler.DebugLogStore.setCategory(category, checked)); - panel.addView(toggle); - } - MaterialTextView captureNote = new MaterialTextView(activity); - captureNote.setText("标准日志按容量轮转;深度统计只保留数值,不保存画面或声音。"); - captureNote.setTextSize(14); panel.addView(captureNote); - android.widget.Button mark = new android.widget.Button(activity); mark.setText("标记此刻故障"); mark.setFocusable(true); panel.addView(mark); - mark.setOnClickListener(v -> new WebHtvAlertDialogBuilder(activity).setTitle("选择当前现象") - .setItems(com.fongmi.android.tv.player.DiagnosticControls.SYMPTOMS, (d, which) -> { - try { com.fongmi.android.tv.player.DiagnosticControls.mark(com.fongmi.android.tv.player.DiagnosticControls.SYMPTOMS[which]); Notify.show("已标记,继续记录后 15 秒"); } - catch (RuntimeException error) { Notify.show(error.getMessage()); } - }).show()); - android.widget.Button depth = new android.widget.Button(activity); depth.setText("深度统计 60 秒"); depth.setFocusable(true); panel.addView(depth); - depth.setOnClickListener(v -> new WebHtvAlertDialogBuilder(activity).setTitle("限时深度统计") - .setMessage("对当前播放做少量低分辨率画面和 PCM 数值统计,不保存图像或声音。到期、切换播放或关闭诊断自动停止。") - .setNegativeButton("取消", null).setPositiveButton("开启 60 秒", (d, which) -> { - try { com.fongmi.android.tv.player.DiagnosticControls.startDepth(60); Notify.show("限时统计已开启"); } - catch (RuntimeException error) { Notify.show(error.getMessage()); } - }).show()); - android.widget.Button stop = new android.widget.Button(activity); stop.setText("停止深度统计"); stop.setFocusable(true); panel.addView(stop); - stop.setOnClickListener(v -> { com.github.catvod.crawler.diagnostics.DiagnosticCapture.stop("user-stopped"); Notify.show("深度统计已停止"); }); - android.widget.ScrollView scroll = new android.widget.ScrollView(activity); scroll.addView(panel); - android.app.Dialog dialog = LightDialog.create(activity, activity.getString(R.string.setting_debug_log), scroll, activity.getString(R.string.debug_log_open_browser), v -> open(activity, localUrl), activity.getString(R.string.dialog_negative), null, activity.getString(R.string.debug_log_copy_url), v -> copy(activity, lanUrl)); + android.app.Dialog dialog = LightDialog.create(activity, activity.getString(R.string.setting_debug_log), content, activity.getString(R.string.debug_log_open_browser), v -> open(activity, localUrl), activity.getString(R.string.dialog_negative), null, activity.getString(R.string.debug_log_copy_url), v -> copy(activity, lanUrl)); dialog.show(); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DialogSafeArea.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DialogSafeArea.java deleted file mode 100644 index 870c82f718..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DialogSafeArea.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Activity; -import android.content.Context; -import android.content.ContextWrapper; -import android.view.View; -import android.view.Window; - -import androidx.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; - -import com.fongmi.android.tv.utils.ResUtil; - -import java.util.WeakHashMap; - -/** - * The screen area a floating dialog may occupy without colliding with the system bars. - * - *

{@code ResUtil.getScreenWidth/Height} return the whole display, not the area the window - * manager actually offers a floating dialog: this app draws edge to edge, so on phones the value - * still includes the status bar and the navigation/gesture bar. A dialog window sized (or capped) - * from that value is taller than the bar-safe area, and its bottom row - a fixed-height action - * button - ends up painted under the navigation bar. The theme colour editor and the playback - * health report were both clipped that way; every dialog that sizes its window or an inner - * scroll/list cap from the screen now goes through this class instead. - * - *

The bar-safe area is the display minus the dialog window's own insets, falling back to the - * host activity's root insets and then to the space the host content view already gave up. An - * all-zero inset report is treated as unknown rather than as "no bars", because a floating dialog - * window on API 28 keeps reporting zeros while the navigation bar is on screen. The math itself - * stays in {@link ThemeDialogLayout} so every dialog shares one implementation. - * - *

On a television, or in any fullscreen/immersive host, the resolved insets are zero and this - * class returns exactly the old display-based numbers, so no television geometry changes. - */ -final class DialogSafeArea { - - private static final WeakHashMap OBSERVED = new WeakHashMap<>(); - - private DialogSafeArea() { - } - - /** Normalises one platform inset report into the four edges a dialog must keep clear of. */ - static ThemeDialogLayout.Insets of(WindowInsetsCompat insets) { - Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); - Insets gestures = insets.getInsets(WindowInsetsCompat.Type.systemGestures()); - // Only the bottom edge takes the gesture area into account: some OEM gesture-navigation - // builds under-report systemBars().bottom while the handle still covers the window, but - // letting the left/right gesture regions into the max would needlessly narrow dialogs on - // three-button-navigation phones. - return new ThemeDialogLayout.Insets(bars.left, bars.top, bars.right, Math.max(bars.bottom, gestures.bottom)); - } - - /** The bar-safe area for a dialog window, or for its host when the window is unknown. */ - static ThemeDialogLayout.Area area(Activity activity, Window window) { - ThemeDialogLayout.Insets insets = window == null ? null : OBSERVED.get(window.getDecorView()); - if (insets == null || insets.isEmpty()) insets = hostInsets(activity); - int displayWidth = ResUtil.getScreenWidth(activity); - int displayHeight = ResUtil.getScreenHeight(activity); - if (insets.isEmpty()) { - insets = ThemeDialogLayout.Insets.ofContent(displayWidth, displayHeight, contentWidth(activity), contentHeight(activity)); - } - return ThemeDialogLayout.safeArea(displayWidth, displayHeight, insets); - } - - static ThemeDialogLayout.Area area(Activity activity) { - return area(activity, null); - } - - /** Bar-safe width for the given dialog window (never below one pixel). */ - static int width(Activity activity, Window window) { - return Math.max(1, area(activity, window).width()); - } - - /** Bar-safe height for the given dialog window (never below one pixel). */ - static int height(Activity activity, Window window) { - return Math.max(1, area(activity, window).height()); - } - - /** Bar-safe width for a context that may not be an Activity; falls back to the display. */ - static int width(Context context) { - Activity activity = activityOf(context); - return activity == null ? ResUtil.getScreenWidth(context) : width(activity, null); - } - - /** Bar-safe height for a context that may not be an Activity; falls back to the display. */ - static int height(Context context) { - Activity activity = activityOf(context); - return activity == null ? ResUtil.getScreenHeight(context) : height(activity, null); - } - - /** - * Re-runs {@code resize} whenever the system-bar insets change, so a window sized before the - * platform reported its insets - or before a rotation - converges on the bar-safe area instead - * of staying clipped. The listener only fires when the reported insets really change, so it - * cannot loop with the resize that {@code window.setLayout} triggers. - */ - static void follow(Activity activity, Window window, Runnable resize) { - if (activity == null || window == null) return; - View decorView = window.getDecorView(); - ViewCompat.setOnApplyWindowInsetsListener(decorView, (view, insets) -> { - ThemeDialogLayout.Insets next = of(insets); - ThemeDialogLayout.Insets cached = OBSERVED.get(view); - if (!next.equals(cached)) { - OBSERVED.put(view, next); - resize.run(); - } - return insets; - }); - ViewCompat.requestApplyInsets(decorView); - } - - private static ThemeDialogLayout.Insets hostInsets(Activity activity) { - if (activity == null) return ThemeDialogLayout.Insets.none(); - try { - WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(activity.getWindow().getDecorView()); - return insets == null ? ThemeDialogLayout.Insets.none() : of(insets); - } catch (RuntimeException error) { - return ThemeDialogLayout.Insets.none(); - } - } - - private static int contentWidth(Activity activity) { - View content = activity == null ? null : activity.findViewById(android.R.id.content); - return content == null ? 0 : content.getWidth(); - } - - private static int contentHeight(Activity activity) { - View content = activity == null ? null : activity.findViewById(android.R.id.content); - return content == null ? 0 : content.getHeight(); - } - - private static Activity activityOf(Context context) { - Context current = context; - while (current instanceof ContextWrapper) { - if (current instanceof Activity activity) return activity; - current = ((ContextWrapper) current).getBaseContext(); - } - return current instanceof Activity activity ? activity : null; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DiscMenuDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DiscMenuDialog.java deleted file mode 100644 index 3d9192b304..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DiscMenuDialog.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Dialog; -import android.os.Bundle; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; -import android.widget.FrameLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentActivity; -import androidx.media3.mpvplayer.MpvPlayer; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.databinding.DialogDiscMenuBinding; -import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Util; -import com.google.android.material.bottomsheet.BottomSheetDialog; - -public final class DiscMenuDialog extends BaseBottomSheetDialog { - - private static final String TAG = "disc-menu-controls"; - - private DialogDiscMenuBinding binding; - private MpvPlayer player; - - public static void show(FragmentActivity activity, MpvPlayer player) { - if (activity.getSupportFragmentManager().isStateSaved() - || activity.getSupportFragmentManager().findFragmentByTag(TAG) != null) return; - DiscMenuDialog dialog = new DiscMenuDialog(); - dialog.player = player; - dialog.show(activity.getSupportFragmentManager(), TAG); - } - - @Override - protected ViewBinding getBinding(@NonNull LayoutInflater inflater, @Nullable ViewGroup container) { - return binding = DialogDiscMenuBinding.inflate(inflater, container, false); - } - - @Override - protected void initView() { - if (player == null || !player.isDiscMenuAvailable()) { - dismiss(); - return; - } - binding.up.setOnClickListener(view -> player.sendDiscNav("up")); - binding.down.setOnClickListener(view -> player.sendDiscNav("down")); - binding.left.setOnClickListener(view -> player.sendDiscNav("left")); - binding.right.setOnClickListener(view -> player.sendDiscNav("right")); - binding.select.setOnClickListener(view -> activate("select")); - binding.rootMenu.setOnClickListener(view -> activate("menu")); - binding.popup.setOnClickListener(view -> activate("popup")); - binding.previous.setOnClickListener(view -> activate("prev")); - binding.close.setOnClickListener(view -> dismiss()); - } - - private void activate(String action) { - if (player.sendDiscNav(action)) dismiss(); - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - Dialog dialog = super.onCreateDialog(savedInstanceState); - Window window = dialog.getWindow(); - if (window != null) { - window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - window.setDimAmount(0f); - } - return dialog; - } - - @Override - protected void setBehavior(BottomSheetDialog dialog) { - super.setBehavior(dialog); - FrameLayout sheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); - if (sheet != null) { - sheet.setBackgroundColor(android.graphics.Color.TRANSPARENT); - ViewGroup.LayoutParams params = sheet.getLayoutParams(); - params.width = Math.min(ResUtil.dp2px(384), - getResources().getDisplayMetrics().widthPixels - ResUtil.dp2px(24)); - if (params instanceof CoordinatorLayout.LayoutParams layout) { - layout.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; - layout.bottomMargin = ResUtil.dp2px(8); - } - sheet.setLayoutParams(params); - } - Window window = dialog.getWindow(); - if (window != null && getActivity() != null && Util.isFullscreen(getActivity())) - Util.hideSystemUI(window); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DisplayDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DisplayDialog.java index 0f21774fb6..1d81c2a4db 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DisplayDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DisplayDialog.java @@ -73,8 +73,6 @@ public class DisplayDialog extends BaseAlertDialog { initDisplayView(); binding.displayParams.setVisibility(View.VISIBLE); binding.displayParams.setSelected(PlayerSetting.isOsdDiagnostics()); - binding.displayMediaBadges.setVisibility(View.VISIBLE); - binding.displayMediaBadges.setSelected(PlayerSetting.isDisplayMediaBadges() && PlayerSetting.isDisplayMediaBadgesPersistent()); } @Override @@ -103,7 +101,6 @@ public class DisplayDialog extends BaseAlertDialog { PlayerSetting.putOsdDiagnostics(selected); if (callback != null) callback.run(); }); - binding.displayMediaBadges.setOnClickListener(v -> setDisplay(binding.displayMediaBadges, Display.MEDIA_BADGES)); } private void setDisplay(TextView view, Display display) { @@ -128,11 +125,6 @@ public class DisplayDialog extends BaseAlertDialog { case TITLE: PlayerSetting.putDisplayTitle(selected); break; - case MEDIA_BADGES: - // 勾上 = 打开总开关并常驻;取消 = 只取消常驻(唤醒控制栏时仍可见) - if (selected) PlayerSetting.putDisplayMediaBadges(true); - PlayerSetting.putDisplayMediaBadgesPersistent(selected); - break; } if (callback != null) callback.run(); } @@ -145,7 +137,7 @@ public class DisplayDialog extends BaseAlertDialog { } private enum Display { - TIME, TRAFFIC, SIZE, PROGRESS, MINI, TITLE, MEDIA_BADGES + TIME, TRAFFIC, SIZE, PROGRESS, MINI, TITLE } private enum Mode { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/GitCloudDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/GitCloudDialog.java index 4ba9cd2517..2189daf4cc 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/GitCloudDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/GitCloudDialog.java @@ -36,7 +36,6 @@ import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.appcompat.widget.LinearLayoutCompat; import androidx.core.content.ContextCompat; -import androidx.core.view.ViewCompat; import androidx.core.widget.NestedScrollView; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; @@ -63,7 +62,6 @@ import com.fongmi.android.tv.gitcloud.drive.JGitDriveEngine; import com.fongmi.android.tv.gitcloud.provider.GitCloudProvider; import com.fongmi.android.tv.gitcloud.provider.GitCloudProviders; import com.fongmi.android.tv.gitcloud.secure.GitCloudTokenStore; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.SafeScrollEditText; import com.fongmi.android.tv.ui.custom.SettingClipboardOverlay; import com.fongmi.android.tv.utils.AppBackup; @@ -170,7 +168,7 @@ public class GitCloudDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -180,8 +178,8 @@ public class GitCloudDialog extends BaseAlertDialog { if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); boolean land = ResUtil.isLand(requireContext()); - params.width = (int) (DialogSafeArea.width(requireActivity(), window) * (land ? 0.72f : 0.94f)); - params.height = (int) (DialogSafeArea.height(requireActivity(), window) * 0.86f); + params.width = (int) (ResUtil.getScreenWidth(requireContext()) * (land ? 0.72f : 0.94f)); + params.height = (int) (ResUtil.getScreenHeight(requireContext()) * 0.86f); params.gravity = Gravity.TOP | Gravity.CENTER_HORIZONTAL; params.y = dp(18); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); @@ -2161,7 +2159,7 @@ public class GitCloudDialog extends BaseAlertDialog { edit.setTextColor(Color.parseColor("#202124")); edit.setHintTextColor(Color.parseColor("#5F6368")); edit.setSelectAllOnFocus(false); - ViewCompat.setImportantForAutofill(edit, View.IMPORTANT_FOR_AUTOFILL_NO); + edit.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO); if (password) edit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); layout.addView(edit, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return new TextInput(layout, edit); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/GroupRuleDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/GroupRuleDialog.java index cc89a005ee..64457437dc 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/GroupRuleDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/GroupRuleDialog.java @@ -33,7 +33,6 @@ import com.fongmi.android.tv.setting.GroupRuleConfig; import com.fongmi.android.tv.setting.GroupRuleStore; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.setting.SiteNameStore; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; @@ -511,8 +510,8 @@ public final class GroupRuleDialog { private void configureAiPreviewWindow(AlertDialog dialog, NestedScrollView contentScroll) { Window window = dialog.getWindow(); if (window == null) return; - int screenWidth = DialogSafeArea.width(activity, window); - int screenHeight = DialogSafeArea.height(activity, window); + int screenWidth = ResUtil.getScreenWidth(activity); + int screenHeight = ResUtil.getScreenHeight(activity); boolean landscape = screenWidth >= screenHeight; AiGroupRulePreviewSizing.Size size = AiGroupRulePreviewSizing.calculate( screenWidth, screenHeight, dp(landscape ? 24 : 12), dp(landscape ? 170 : 190)); @@ -593,7 +592,7 @@ public final class GroupRuleDialog { } private MaterialAlertDialogBuilder builder() { - return new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog); + return new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog); } private static void wireDpadFocus(View view, View up, View down, View left, View right) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java index 4101401c4f..5695bd6121 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java @@ -21,9 +21,6 @@ import androidx.core.content.ContextCompat; import androidx.core.widget.TextViewCompat; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeEditorUi; -import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.button.MaterialButton; @@ -38,32 +35,11 @@ public final class LightDialog { if (dialog == null) return; Window window = dialog.getWindow(); if (window == null) return; - TouchOptimizationHelper.sync(dialog); - // The shell keeps the opaque semantic fill on purpose: dialogOpacity is applied by - // bindWindowBackground below, which is the one documented owner of that slot. Baking - // the alpha in here scaled it twice (0.70 -> 0.49) and, because a translucent fill no - // longer matches its baseline role, also stopped the shell from following a user - // surfaceContainerHigh override. - Drawable background = shell(dialog.getContext()); + Drawable background = ContextCompat.getDrawable(dialog.getContext(), R.drawable.shape_shell_proxy_dialog); + if (background == null) return; int verticalInset = (int) (dialog.getContext().getResources().getDisplayMetrics().density * 24); window.setBackgroundDrawable(new InsetDrawable(background, 0, verticalInset, 0, verticalInset)); if (Util.isLeanback()) applyAlertWindow(dialog, window); - // AlertController installs the message and button views during show(), so the - // binder root is registered here and re-walked by descendant-count changes. - ThemeController.bindDialog(dialog); - ThemeController.bindWindowBackground(background); - } - - /** - * The one dialog shell shape, shared by the AlertDialog and the custom-Dialog paths. - * - *

Public so a test can assert the two halves of the shell contract on the real - * drawable: it ships opaque (so {@code dialogOpacity} has a single owner), and its fill - * is the semantic {@code colorSurfaceContainerHigh} value (so the binder's exact-match - * rewrite can still find the role). - */ - public static Drawable shell(Context context) { - return ThemeEditorUi.shape(context, ThemeController.current().colorSurfaceContainerHigh(), 0, 0, 22); } static int resolveAlertWidth(int screenWidth, int screenHeight) { @@ -148,11 +124,7 @@ public final class LightDialog { dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); dialog.setContentView(root(context, title, content, positive, listener(dialog, onPositive), negative, listener(dialog, onNegative), neutral, listener(dialog, onNeutral), heightPx > 0)); dialog.setCanceledOnTouchOutside(true); - dialog.setOnShowListener(d -> { - applyWindow(dialog, context, landFactor, portFactor, maxDp, heightPx); - TouchOptimizationHelper.sync(dialog); - ThemeController.bindDialog(dialog); - }); + dialog.setOnShowListener(d -> applyWindow(dialog, context, landFactor, portFactor, maxDp, heightPx)); return dialog; } @@ -166,7 +138,7 @@ public final class LightDialog { private static View root(Context context, CharSequence title, View content, String positive, View.OnClickListener onPositive, String negative, View.OnClickListener onNegative, String neutral, View.OnClickListener onNeutral, boolean fillHeight) { LinearLayout root = new LinearLayout(context); root.setOrientation(LinearLayout.VERTICAL); - root.setBackground(shell(context)); + root.setBackgroundResource(R.drawable.shape_shell_proxy_dialog); int actionCount = (positive == null ? 0 : 1) + (negative == null ? 0 : 1) + (neutral == null ? 0 : 1); int vertical = ResUtil.dp2px(24); int horizontal = ResUtil.dp2px(actionCount >= 3 ? 18 : 24); @@ -175,7 +147,7 @@ public final class LightDialog { if (title != null) { MaterialTextView titleView = new MaterialTextView(context); titleView.setText(title); - titleView.setTextColor(ThemeController.current().colorOnSurface()); + titleView.setTextColor(Color.parseColor("#202124")); titleView.setTextSize(18); titleView.setGravity(Gravity.CENTER_VERTICAL); root.addView(titleView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStateLearnDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStateLearnDialog.java index 2ed9114b5e..2d3d684856 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStateLearnDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStateLearnDialog.java @@ -19,7 +19,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogLoginStateLearnBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.LoginStateSync; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; @@ -58,7 +57,7 @@ public final class LoginStateLearnDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -105,7 +104,7 @@ public final class LoginStateLearnDialog extends BaseAlertDialog { private void setScrollHeight(FragmentActivity activity) { ViewGroup.LayoutParams params = binding.contentScroll.getLayoutParams(); - params.height = Math.max(ResUtil.dp2px(190), Math.min(ResUtil.dp2px(300), (int) (DialogSafeArea.height(activity) * 0.34f))); + params.height = Math.max(ResUtil.dp2px(190), Math.min(ResUtil.dp2px(300), (int) (ResUtil.getScreenHeight(activity) * 0.34f))); binding.contentScroll.setLayoutParams(params); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStatePathDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStatePathDialog.java index 22aa84b23f..6bbd694bfb 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStatePathDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStatePathDialog.java @@ -29,8 +29,6 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterLoginStateTreeBinding; import com.fongmi.android.tv.databinding.DialogLoginStatePathBinding; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.ui.custom.SafeScrollEditText; import com.fongmi.android.tv.utils.FileUtil; @@ -102,7 +100,7 @@ public class LoginStatePathDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -138,8 +136,8 @@ public class LoginStatePathDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = DialogSafeArea.width(requireActivity(), window); - int screenHeight = DialogSafeArea.height(requireActivity(), window); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); @@ -449,7 +447,7 @@ public class LoginStatePathDialog extends BaseAlertDialog { container.setPadding(ResUtil.dp2px(20), ResUtil.dp2px(8), ResUtil.dp2px(20), 0); TextView label = new TextView(requireContext()); label.setText(preview.getPath()); - label.setTextColor(ThemeController.current().colorOnSurfaceVariant()); + label.setTextColor(Color.parseColor("#5F6368")); label.setTextSize(12); label.setSingleLine(true); label.setEllipsize(TextUtils.TruncateAt.MIDDLE); @@ -457,12 +455,12 @@ public class LoginStatePathDialog extends BaseAlertDialog { if (preview.isTruncated() || !preview.isEditable()) { TextView meta = new TextView(requireContext()); meta.setText(preview.isTruncated() ? R.string.login_state_preview_truncated : R.string.login_state_preview_readonly); - meta.setTextColor(ThemeController.current().colorWarning()); + meta.setTextColor(Color.parseColor("#8A4B00")); meta.setTextSize(12); meta.setPadding(0, ResUtil.dp2px(6), 0, 0); container.addView(meta, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); } - LinearLayoutCompat.LayoutParams inputParams = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (int) (DialogSafeArea.height(requireActivity()) * 0.52f)); + LinearLayoutCompat.LayoutParams inputParams = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (int) (ResUtil.getScreenHeight(requireContext()) * 0.52f)); inputParams.topMargin = ResUtil.dp2px(8); SafeScrollEditText input = null; if (payload.listPreview) { @@ -488,7 +486,6 @@ public class LoginStatePathDialog extends BaseAlertDialog { private SafeScrollEditText editText(LoginStateSync.TextPreview preview) { SafeScrollEditText input = new SafeScrollEditText(requireContext()); input.setText(preview.getContent(), TextView.BufferType.EDITABLE); - input.setTextColor(ThemeController.current().colorOnSurface()); input.setSelectAllOnFocus(false); input.setSingleLine(false); input.setHorizontallyScrolling(true); @@ -512,9 +509,9 @@ public class LoginStatePathDialog extends BaseAlertDialog { private GradientDrawable editorBackground() { GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(ThemeController.current().colorSurfaceContainerHigh()); + drawable.setColor(Color.parseColor("#F8FAFD")); drawable.setCornerRadius(ResUtil.dp2px(8)); - drawable.setStroke(1, ThemeController.current().colorOutlineVariant()); + drawable.setStroke(1, Color.parseColor("#DADCE0")); return drawable; } @@ -579,15 +576,15 @@ public class LoginStatePathDialog extends BaseAlertDialog { private int stateColor(Row row) { String path = row.item.getPath(); - if (isMissing(path)) return ThemeController.current().colorError(); - if (isPending(path) || (row.item.isDir() && hasPendingChild(path))) return ThemeController.current().colorWarning(); - return ThemeController.current().colorPrimary(); + if (isMissing(path)) return Color.parseColor("#B3261E"); + if (isPending(path) || (row.item.isDir() && hasPendingChild(path))) return Color.parseColor("#B06000"); + return Color.parseColor("#174EA6"); } private int nameColor(Row row) { - if (isPending(row.item.getPath()) || (row.item.isDir() && hasPendingChild(row.item.getPath()))) return ThemeController.current().colorWarning(); - if (isMissing(row.item.getPath())) return ThemeController.current().colorError(); - return ThemeController.current().colorOnSurface(); + if (isPending(row.item.getPath()) || (row.item.isDir() && hasPendingChild(row.item.getPath()))) return Color.parseColor("#8A4B00"); + if (isMissing(row.item.getPath())) return Color.parseColor("#B3261E"); + return Color.parseColor("#202124"); } private int iconFor(LoginStateSync.TreeItem item) { @@ -758,7 +755,7 @@ public class LoginStatePathDialog extends BaseAlertDialog { @Override public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { TextView text = new TextView(parent.getContext()); - text.setTextColor(ThemeController.current().colorOnSurface()); + text.setTextColor(Color.parseColor("#202124")); text.setTextSize(12); text.setTypeface(Typeface.MONOSPACE); text.setLineSpacing(0, 1.08f); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ManagePageDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ManagePageDialog.java index dcc51dc7eb..d62b714490 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ManagePageDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ManagePageDialog.java @@ -89,7 +89,7 @@ public final class ManagePageDialog { private static void setScrollHeight(FragmentActivity activity, View view, float screenFactor, int maxDp) { ViewGroup.LayoutParams params = view.getLayoutParams(); - params.height = Math.min(ResUtil.dp2px(maxDp), Math.round(DialogSafeArea.height(activity) * screenFactor)); + params.height = Math.min(ResUtil.dp2px(maxDp), Math.round(ResUtil.getScreenHeight(activity) * screenFactor)); view.setLayoutParams(params); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigCreateDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigCreateDialog.java index 5c74f6daeb..e07961b4f8 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigCreateDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigCreateDialog.java @@ -6,8 +6,6 @@ import android.content.Intent; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.text.TextUtils; -import android.text.Editable; -import android.text.TextWatcher; import android.view.View; import android.view.Window; import android.view.WindowManager; @@ -22,37 +20,23 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogMpvConfigCreateBinding; import com.fongmi.android.tv.player.mpv.MpvConfigStore; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.button.MaterialButtonToggleGroup; -import com.google.android.material.textfield.TextInputEditText; -import com.google.android.material.switchmaterial.SwitchMaterial; public class MpvConfigCreateDialog extends BaseAlertDialog { public interface Listener { - void onText(String name, boolean buttonEnabled, String trigger); + void onText(String name); - void onImport(String name, String path, boolean buttonEnabled, String trigger); + void onImport(String name, String path); } private DialogMpvConfigCreateBinding binding; private Listener listener; private String target; - private boolean scriptButtonMode; - private boolean scriptSettingsMode; - private String scriptId; - private String scriptContent; - private MpvConfigStore.CustomButton sourceButton; - private Runnable buttonCallback; - private String shortCode; - private String longCode; - private String startupCode; - private int triggerId; public static void show(FragmentManager manager, String target, Listener listener) { MpvConfigCreateDialog dialog = new MpvConfigCreateDialog(); @@ -61,28 +45,6 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { dialog.show(manager, "mpv-config-create"); } - public static void showScriptButton(FragmentManager manager, @Nullable MpvConfigStore.CustomButton source, Runnable callback) { - MpvConfigCreateDialog dialog = new MpvConfigCreateDialog(); - dialog.target = MpvConfigStore.TARGET_SCRIPTS; - dialog.scriptButtonMode = true; - dialog.sourceButton = source; - dialog.buttonCallback = callback; - dialog.show(manager, "mpv-script-button"); - } - - public static void showScriptSettings(FragmentManager manager, String scriptId, String title, - String content, @Nullable MpvConfigStore.CustomButton source, - Runnable callback) { - MpvConfigCreateDialog dialog = new MpvConfigCreateDialog(); - dialog.target = MpvConfigStore.TARGET_SCRIPTS; - dialog.scriptSettingsMode = true; - dialog.scriptId = scriptId; - dialog.scriptContent = content == null ? "" : content; - dialog.sourceButton = source; - dialog.buttonCallback = callback; - dialog.show(manager, "mpv-script-settings"); - } - @Override protected ViewBinding getBinding() { return binding = DialogMpvConfigCreateBinding.inflate(getLayoutInflater()); @@ -90,46 +52,18 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override protected void initView() { - if (scriptSettingsMode) setupScriptSettings(); - else if (scriptButtonMode) setupScriptButton(); - else if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) setupScriptCreation(); - else setupTvFocus(); + setupTvFocus(); } @Override protected void initEvent() { binding.close.setOnClickListener(view -> dismiss()); - binding.triggerGroup.addOnButtonCheckedListener(this::onTriggerChecked); - if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) { - updateScriptTrigger(binding.buttonEnabled.isChecked(), triggerId); - binding.buttonEnabled.setOnCheckedChangeListener((button, enabled) -> - updateScriptTrigger(enabled, enabled ? R.id.triggerClick : R.id.triggerStartup)); - } - binding.scriptCode.addTextChangedListener(new TextWatcher() { - @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } - @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - updateScriptStats(s == null ? "" : s.toString()); - } - @Override public void afterTextChanged(Editable s) { } - }); - binding.buttonCancel.setOnClickListener(view -> { - if (scriptButtonMode && sourceButton == null) showScriptCreation(); - else dismissAllowingStateLoss(); - }); - binding.buttonSave.setOnClickListener(view -> { - if (scriptSettingsMode) saveScriptSettings(); - else saveScriptButton(); - }); - binding.scriptEdit.setOnClickListener(view -> openScriptEditor()); binding.textOption.setOnClickListener(view -> createText()); - if (scriptButtonMode) { - return; - } binding.urlOption.setOnClickListener(view -> showUrlInput()); binding.importOption.setOnClickListener(view -> chooseFile()); binding.urlBack.setOnClickListener(view -> showOptions()); @@ -157,22 +91,8 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { tvFocusable(binding.urlImport); binding.close.setNextFocusDownId(R.id.name); binding.name.setNextFocusUpId(R.id.close); - if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) { - tvFocusable(binding.buttonEnabled); - tvFocusable(binding.triggerClick); - tvFocusable(binding.triggerLong); - tvFocusable(binding.triggerStartup); - binding.name.setNextFocusDownId(R.id.buttonEnabled); - binding.buttonEnabled.setNextFocusUpId(R.id.name); - binding.buttonEnabled.setNextFocusDownId(R.id.triggerClick); - binding.triggerClick.setNextFocusUpId(R.id.buttonEnabled); - binding.triggerClick.setNextFocusDownId(R.id.textOption); - binding.triggerLong.setNextFocusDownId(R.id.textOption); - binding.triggerStartup.setNextFocusDownId(R.id.textOption); - } else { - binding.name.setNextFocusDownId(R.id.textOption); - } - binding.textOption.setNextFocusUpId(MpvConfigStore.TARGET_SCRIPTS.equals(target) ? R.id.triggerClick : R.id.name); + binding.name.setNextFocusDownId(R.id.textOption); + binding.textOption.setNextFocusUpId(R.id.name); binding.textOption.setNextFocusDownId(R.id.urlOption); binding.urlOption.setNextFocusUpId(R.id.textOption); binding.urlOption.setNextFocusDownId(R.id.importOption); @@ -185,263 +105,6 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { binding.urlImport.setNextFocusLeftId(R.id.urlBack); } - private void setupScriptButton() { - binding.createTitle.setText(getString(sourceButton == null ? R.string.mpv_config_custom_button_new : R.string.mpv_config_custom_button_edit)); - binding.chooseAction.setText(R.string.mpv_config_custom_button_new_desc); - binding.chooseAction.setVisibility(View.VISIBLE); - binding.nameLabel.setText(R.string.mpv_config_custom_button_name); - binding.name.setHint(R.string.mpv_config_custom_button_name_hint); - binding.name.setText(sourceButton == null ? "" : sourceButton.title); - binding.methodLabel.setVisibility(View.GONE); - binding.textOption.setVisibility(View.GONE); - binding.urlOption.setVisibility(View.GONE); - binding.importOption.setVisibility(View.GONE); - binding.urlPanel.setVisibility(View.GONE); - binding.scriptSettingsPanel.setVisibility(View.VISIBLE); - binding.scriptButtonPanel.setVisibility(View.VISIBLE); - binding.buttonEnabled.setChecked(sourceButton == null || sourceButton.enabled); - shortCode = sourceButton == null ? "" : value(sourceButton.content); - longCode = sourceButton == null ? "" : value(sourceButton.longPressContent); - startupCode = sourceButton == null ? "" : value(sourceButton.onStartup); - if (triggerId == 0) triggerId = R.id.triggerClick; - binding.triggerGroup.check(triggerId); - binding.scriptCode.setText(shortCode); - binding.scriptCode.setSelection(binding.scriptCode.length()); - updateScriptStats(shortCode); - if (Util.isLeanback()) { - tvFocusable(binding.close); - tvFocusable(binding.name); - tvFocusable(binding.buttonEnabled); - tvFocusable(binding.triggerClick); - tvFocusable(binding.triggerLong); - tvFocusable(binding.triggerStartup); - tvFocusable(binding.scriptCode); - tvFocusable(binding.buttonCancel); - tvFocusable(binding.buttonSave); - binding.close.setNextFocusDownId(R.id.name); - binding.name.setNextFocusUpId(R.id.close); - binding.name.setNextFocusDownId(R.id.buttonEnabled); - binding.buttonEnabled.setNextFocusUpId(R.id.name); - binding.buttonEnabled.setNextFocusDownId(R.id.triggerClick); - binding.triggerClick.setNextFocusUpId(R.id.buttonEnabled); - binding.triggerClick.setNextFocusDownId(R.id.scriptCode); - binding.triggerLong.setNextFocusDownId(R.id.scriptCode); - binding.triggerStartup.setNextFocusDownId(R.id.scriptCode); - binding.scriptCode.setNextFocusUpId(R.id.triggerClick); - binding.scriptCode.setNextFocusDownId(R.id.buttonCancel); - binding.buttonCancel.setNextFocusUpId(R.id.scriptCode); - binding.buttonCancel.setNextFocusRightId(R.id.buttonSave); - binding.buttonSave.setNextFocusLeftId(R.id.buttonCancel); - } - } - - private void setupScriptSettings() { - binding.createTitle.setText(R.string.mpv_config_script_settings_title); - binding.chooseAction.setText(R.string.mpv_config_script_settings_desc); - binding.chooseAction.setVisibility(View.VISIBLE); - binding.nameLabel.setText(R.string.mpv_config_custom_button_name); - binding.name.setHint(R.string.mpv_config_custom_button_name_hint); - binding.name.setText(sourceButton == null ? scriptButtonDefaultName(scriptId) : sourceButton.title); - binding.methodLabel.setVisibility(View.GONE); - binding.textOption.setVisibility(View.GONE); - binding.urlOption.setVisibility(View.GONE); - binding.importOption.setVisibility(View.GONE); - binding.urlPanel.setVisibility(View.GONE); - binding.scriptSettingsPanel.setVisibility(View.VISIBLE); - binding.scriptButtonPanel.setVisibility(View.VISIBLE); - binding.scriptCode.setVisibility(View.GONE); - binding.scriptStats.setVisibility(View.GONE); - binding.scriptEdit.setVisibility(View.VISIBLE); - binding.scriptEnabledRow.setVisibility(View.VISIBLE); - binding.scriptEnabled.setChecked(sourceButton == null || sourceButton.scriptEnabled); - binding.buttonEnabled.setChecked(sourceButton != null && sourceButton.enabled); - triggerId = triggerIdFor(MpvConfigStore.normalizeScriptTrigger(binding.buttonEnabled.isChecked(), - sourceButton == null ? null : sourceButton.trigger)); - binding.triggerGroup.check(triggerId); - if (Util.isLeanback()) { - tvFocusable(binding.close); - tvFocusable(binding.name); - tvFocusable(binding.scriptEnabled); - tvFocusable(binding.buttonEnabled); - tvFocusable(binding.triggerClick); - tvFocusable(binding.triggerLong); - tvFocusable(binding.triggerStartup); - tvFocusable(binding.scriptEdit); - tvFocusable(binding.buttonCancel); - tvFocusable(binding.buttonSave); - binding.close.setNextFocusDownId(R.id.name); - binding.name.setNextFocusUpId(R.id.close); - binding.name.setNextFocusDownId(R.id.scriptEnabled); - binding.scriptEnabled.setNextFocusUpId(R.id.name); - binding.scriptEnabled.setNextFocusDownId(R.id.buttonEnabled); - binding.buttonEnabled.setNextFocusUpId(R.id.scriptEnabled); - binding.buttonEnabled.setNextFocusDownId(R.id.triggerClick); - binding.triggerClick.setNextFocusUpId(R.id.buttonEnabled); - binding.triggerClick.setNextFocusDownId(R.id.scriptEdit); - binding.triggerLong.setNextFocusDownId(R.id.scriptEdit); - binding.triggerStartup.setNextFocusDownId(R.id.scriptEdit); - binding.scriptEdit.setNextFocusUpId(R.id.triggerClick); - binding.scriptEdit.setNextFocusDownId(R.id.buttonCancel); - binding.buttonCancel.setNextFocusUpId(R.id.scriptEdit); - binding.buttonCancel.setNextFocusRightId(R.id.buttonSave); - binding.buttonSave.setNextFocusLeftId(R.id.buttonCancel); - } - } - - private void setupScriptCreation() { - binding.createTitle.setText(R.string.mpv_config_script_new); - binding.chooseAction.setText(R.string.mpv_config_script_create_desc); - binding.nameLabel.setText(R.string.mpv_config_name_optional); - binding.name.setHint(R.string.mpv_config_custom_button_name_hint); - binding.textOptionTitle.setText(R.string.mpv_config_script_text_edit); - binding.textOptionDesc.setText(R.string.mpv_config_script_text_edit_desc); - binding.urlOptionTitle.setText(R.string.mpv_config_script_import_url); - binding.urlOptionDesc.setText(R.string.mpv_config_script_import_url_desc); - binding.importOptionTitle.setText(R.string.mpv_config_script_import_file); - binding.importOptionDesc.setText(R.string.mpv_config_script_import_file_desc); - shortCode = ""; - longCode = ""; - startupCode = ""; - triggerId = R.id.triggerClick; - binding.buttonEnabled.setChecked(true); - binding.triggerGroup.check(triggerId); - showScriptCreation(); - setupTvFocus(); - } - - private static String scriptButtonDefaultName(String scriptId) { - if (TextUtils.isEmpty(scriptId)) return ""; - if (scriptId.regionMatches(true, scriptId.length() - 4, ".lua", 0, 4) - || scriptId.regionMatches(true, scriptId.length() - 3, ".js", 0, 3)) { - return scriptId.substring(0, scriptId.lastIndexOf('.')); - } - return scriptId; - } - - private void showScriptCreation() { - scriptButtonMode = false; - binding.createTitle.setText(R.string.mpv_config_script_new); - binding.chooseAction.setVisibility(View.VISIBLE); - binding.nameLabel.setVisibility(View.VISIBLE); - binding.nameLayout.setVisibility(View.VISIBLE); - binding.scriptSettingsPanel.setVisibility(View.VISIBLE); - binding.buttonEnabled.setVisibility(View.VISIBLE); - binding.methodLabel.setVisibility(View.VISIBLE); - binding.textOption.setVisibility(View.VISIBLE); - binding.urlOption.setVisibility(View.VISIBLE); - binding.importOption.setVisibility(View.VISIBLE); - binding.urlPanel.setVisibility(View.GONE); - binding.scriptButtonPanel.setVisibility(View.GONE); - } - - private void enterScriptButtonEditor() { - scriptButtonMode = true; - setupScriptButton(); - binding.name.requestFocus(); - } - - private void onTriggerChecked(MaterialButtonToggleGroup group, int checkedId, boolean isChecked) { - if (!MpvConfigStore.TARGET_SCRIPTS.equals(target) || !isChecked || checkedId == triggerId) return; - if (!binding.buttonEnabled.isChecked() && checkedId != R.id.triggerStartup) { - group.check(R.id.triggerStartup); - return; - } - if (scriptButtonMode) saveCurrentCode(); - triggerId = checkedId; - if (scriptButtonMode) { - binding.scriptCode.setText(codeForTrigger(checkedId)); - binding.scriptCode.setSelection(binding.scriptCode.length()); - } - updateScriptTriggerFocus(); - } - - private void updateScriptTrigger(boolean enabled, int selectedId) { - // Some Material versions ignore setChecked on disabled buttons. - binding.triggerClick.setEnabled(true); - binding.triggerLong.setEnabled(true); - binding.triggerGroup.check(enabled ? selectedId : R.id.triggerStartup); - binding.triggerClick.setEnabled(enabled); - binding.triggerLong.setEnabled(enabled); - updateScriptTriggerFocus(); - } - - private void updateScriptTriggerFocus() { - if (!Util.isLeanback()) return; - boolean enabled = binding.buttonEnabled.isChecked(); - binding.triggerClick.setFocusable(enabled); - binding.triggerLong.setFocusable(enabled); - binding.buttonEnabled.setNextFocusDownId(triggerId); - binding.triggerClick.setNextFocusUpId(R.id.buttonEnabled); - binding.triggerLong.setNextFocusUpId(R.id.buttonEnabled); - binding.triggerStartup.setNextFocusUpId(R.id.buttonEnabled); - binding.textOption.setNextFocusUpId(triggerId); - binding.scriptCode.setNextFocusUpId(triggerId); - binding.scriptEdit.setNextFocusUpId(triggerId); - } - - private void saveCurrentCode() { - if (binding == null || binding.scriptCode == null) return; - String code = value(binding.scriptCode); - if (triggerId == R.id.triggerLong) longCode = code; - else if (triggerId == R.id.triggerStartup) startupCode = code; - else shortCode = code; - } - - private void updateScriptStats(String text) { - if (binding == null || binding.scriptStats == null) return; - int lines = text.isEmpty() ? 1 : text.split("\\r?\\n", -1).length; - binding.scriptStats.setText(getString(R.string.mpv_config_stats, lines, text.length())); - } - - private String codeForTrigger(int id) { - if (id == R.id.triggerLong) return longCode; - if (id == R.id.triggerStartup) return startupCode; - return shortCode; - } - - private void saveScriptButton() { - saveCurrentCode(); - try { - MpvConfigStore.saveCustomButton(sourceButton == null ? "" : sourceButton.id, name(), shortCode, longCode, startupCode, binding.buttonEnabled.isChecked()); - Notify.show(R.string.mpv_config_custom_button_saved); - if (buttonCallback != null) buttonCallback.run(); - dismissAllowingStateLoss(); - } catch (Throwable error) { - Notify.show(message(error)); - } - } - - private void openScriptEditor() { - MpvConfigEditorDialog.show(getChildFragmentManager(), name(), scriptContent, false, text -> { - scriptContent = text == null ? "" : text; - return true; - }); - } - - private void saveScriptSettings() { - try { - String savedId = MpvConfigStore.saveScriptSettings(scriptId, name(), scriptContent, - binding.buttonEnabled.isChecked(), triggerName(triggerId), binding.scriptEnabled.isChecked()); - if (buttonCallback != null) buttonCallback.run(); - Notify.show(R.string.mpv_config_profile_saved); - dismissAllowingStateLoss(); - } catch (Throwable error) { - Notify.show(message(error)); - } - } - - private static int triggerIdFor(String trigger) { - if ("long".equals(trigger)) return R.id.triggerLong; - if ("startup".equals(trigger)) return R.id.triggerStartup; - return R.id.triggerClick; - } - - private static String triggerName(int id) { - if (id == R.id.triggerLong) return "long"; - if (id == R.id.triggerStartup) return "startup"; - return "click"; - } - private static void tvFocusable(View view) { view.setFocusable(true); view.setFocusableInTouchMode(true); @@ -453,11 +116,9 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { private void createText() { String name = name(); - boolean enabled = binding.buttonEnabled.isChecked(); - String trigger = triggerName(triggerId); dismissAllowingStateLoss(); App.post(() -> { - if (listener != null) listener.onText(name, enabled, trigger); + if (listener != null) listener.onText(name); }); } @@ -467,7 +128,6 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { } private void showUrlInput() { - binding.scriptSettingsPanel.setVisibility(View.GONE); binding.chooseAction.setVisibility(View.GONE); binding.textOption.setVisibility(View.GONE); binding.urlOption.setVisibility(View.GONE); @@ -480,7 +140,6 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { binding.urlLayout.setError(null); binding.urlPanel.setVisibility(View.GONE); binding.chooseAction.setVisibility(View.VISIBLE); - if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) binding.scriptSettingsPanel.setVisibility(View.VISIBLE); binding.textOption.setVisibility(View.VISIBLE); binding.urlOption.setVisibility(View.VISIBLE); binding.importOption.setVisibility(View.VISIBLE); @@ -496,11 +155,9 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { } binding.urlLayout.setError(null); String name = name(); - boolean enabled = binding.buttonEnabled.isChecked(); - String trigger = triggerName(triggerId); dismissAllowingStateLoss(); App.post(() -> { - if (listener != null) listener.onImport(name, url, enabled, trigger); + if (listener != null) listener.onImport(name, url); }); } @@ -517,11 +174,9 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { return; } String name = binding == null ? "" : name(); - boolean enabled = binding != null && binding.buttonEnabled.isChecked(); - String trigger = triggerName(triggerId); dismissAllowingStateLoss(); App.post(() -> { - if (listener != null) listener.onImport(name, path, enabled, trigger); + if (listener != null) listener.onImport(name, path); }); }); @@ -542,21 +197,6 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); window.setAttributes(params); window.setLayout(params.width, WindowManager.LayoutParams.WRAP_CONTENT); - if (Util.isLeanback()) { - if (scriptButtonMode || scriptSettingsMode) binding.name.post(() -> binding.name.requestFocus()); - else binding.textOption.post(() -> binding.textOption.requestFocus()); - } - } - - private static String value(String value) { - return value == null ? "" : value; - } - - private static String value(TextInputEditText edit) { - return edit.getText() == null ? "" : edit.getText().toString(); - } - - private static String message(Throwable error) { - return TextUtils.isEmpty(error.getMessage()) ? error.getClass().getSimpleName() : error.getMessage(); + if (Util.isLeanback()) binding.textOption.post(() -> binding.textOption.requestFocus()); } } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigDialog.java index 6afc51bb63..b741301dbb 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigDialog.java @@ -25,8 +25,6 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogMpvConfigManagerBinding; import com.fongmi.android.tv.player.mpv.MpvConfigStore; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.MpvConfigProfileAdapter; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; @@ -64,7 +62,7 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -86,12 +84,8 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile private void setupTabs() { binding.tabs.setTabMode(TabLayout.MODE_FIXED); binding.tabs.setTabGravity(TabLayout.GRAVITY_FILL); - // Tab 是对话框面板上的前景:未选中用次要前景,选中用强调色。 - // 原先硬编码 #5F6368 / #1A73E8,夜间在深色面板上只有 2.23:1。 - binding.tabs.setSelectedTabIndicatorColor(ThemeController.current().colorPrimary()); - binding.tabs.setTabTextColors( - ThemeController.current().colorOnSurfaceVariant(), - ThemeController.current().colorPrimary()); + binding.tabs.setSelectedTabIndicatorColor(Color.parseColor("#1A73E8")); + binding.tabs.setTabTextColors(Color.parseColor("#5F6368"), Color.parseColor("#1A73E8")); binding.tabs.setTabRippleColor(android.content.res.ColorStateList.valueOf(Color.TRANSPARENT)); binding.tabs.setUnboundedRipple(false); for (String label : targets()) binding.tabs.addTab(binding.tabs.newTab().setText(label)); @@ -206,7 +200,7 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile popup.dismiss(); openEditor(profile); })); - if (!profile.isDefault() && !profile.isCustomButton() && !MpvConfigStore.TARGET_SCRIPTS.equals(target)) content.addView(actionItem(R.string.mpv_config_rename, R.drawable.ic_mpv_rename, false, () -> { + if (!profile.isDefault()) content.addView(actionItem(R.string.mpv_config_rename, R.drawable.ic_mpv_rename, false, () -> { popup.dismiss(); showRename(profile); })); @@ -245,9 +239,7 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile item.setPadding(ResUtil.dp2px(14), 0, ResUtil.dp2px(14), 0); item.setCompoundDrawablePadding(ResUtil.dp2px(12)); item.setCompoundDrawablesRelativeWithIntrinsicBounds(iconRes, 0, 0, 0); - // 菜单底色是 ?attr/colorSurfaceContainerLowest(日间白 / 夜间近黑), - // 所以文字必须跟着调色板走:普通项用 onSurface,删除项用 error。 - int color = danger ? ThemeController.current().colorError() : ThemeController.current().colorOnSurface(); + int color = Color.parseColor(danger ? "#C5221F" : "#202124"); item.setTextColor(color); TextViewCompat.setCompoundDrawableTintList(item, android.content.res.ColorStateList.valueOf(color)); TypedValue value = new TypedValue(); @@ -285,27 +277,21 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile } @Override - public void onText(String name, boolean buttonEnabled, String trigger) { + public void onText(String name) { String template; if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) template = "-- WebHTV MPV script\n\n"; else if (MpvConfigStore.TARGET_INPUT_CONF.equals(target)) template = "# WebHTV input.conf\n\n"; else template = "# WebHTV mpv.conf\n\n"; String displayName = TextUtils.isEmpty(name) ? getString(R.string.mpv_config_untitled) : name; - showEditor(null, displayName, template, true, buttonEnabled, trigger); - } - - private void onScriptButtonSaved() { - reload(); - notifyChanged(); + showEditor(null, displayName, template, true); } @Override - public void onImport(String name, String path, boolean buttonEnabled, String trigger) { + public void onImport(String name, String path) { String selectedTarget = target; runAsync(() -> { String id = MpvConfigStore.importProfile(selectedTarget, path, name); - if (MpvConfigStore.TARGET_SCRIPTS.equals(selectedTarget)) MpvConfigStore.saveScriptTrigger(id, buttonEnabled, trigger); - else MpvConfigStore.selectProfile(selectedTarget, id); + if (!MpvConfigStore.TARGET_SCRIPTS.equals(selectedTarget)) MpvConfigStore.selectProfile(selectedTarget, id); return id; }, id -> { if (TextUtils.equals(target, selectedTarget)) reload(); @@ -321,13 +307,8 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile String content = MpvConfigStore.profileContent(target, profile.id); App.post(() -> { Notify.dismiss(); - if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) { - MpvConfigCreateDialog.showScriptSettings(getChildFragmentManager(), profile.id, - profile.name, content, MpvConfigStore.scriptButton(profile.id), this::onScriptButtonSaved); - } else { - String name = profile.isDefault() ? getString(R.string.mpv_config_default_copy) : profile.name; - showEditor(profile.id, name, content, profile.isDefault(), false, "startup"); - } + String name = profile.isDefault() ? getString(R.string.mpv_config_default_copy) : profile.name; + showEditor(profile.id, name, content, profile.isDefault()); }); } catch (Throwable e) { App.post(() -> { @@ -338,15 +319,12 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile }); } - private void showEditor(String id, String name, String content, boolean creating, boolean buttonEnabled, String trigger) { + private void showEditor(String id, String name, String content, boolean creating) { MpvConfigEditorDialog.show(getChildFragmentManager(), name, content, creating, text -> { try { String savedId = MpvConfigStore.saveTextProfile(target, id, name, text); - if (creating) { - if (MpvConfigStore.TARGET_SCRIPTS.equals(target)) MpvConfigStore.saveScriptTrigger(savedId, buttonEnabled, trigger); - else MpvConfigStore.selectProfile(target, savedId); - } - if (binding != null) binding.getRoot().post(this::reload); + if (creating && !MpvConfigStore.TARGET_SCRIPTS.equals(target)) MpvConfigStore.selectProfile(target, savedId); + reload(); Notify.show(R.string.mpv_config_profile_saved); notifyChanged(); return true; @@ -392,8 +370,8 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile boolean land = ResUtil.isLand(requireContext()); WindowManager.LayoutParams params = window.getAttributes(); float width = Util.isLeanback() ? 0.68f : land ? 0.62f : 0.94f; - params.width = Math.min((int) (DialogSafeArea.width(requireActivity(), window) * width), ResUtil.dp2px(760)); - params.height = (int) (DialogSafeArea.height(requireActivity(), window) * (land ? 0.88f : 0.82f)); + params.width = Math.min((int) (ResUtil.getScreenWidth(requireContext()) * width), ResUtil.dp2px(760)); + params.height = (int) (ResUtil.getScreenHeight(requireContext()) * (land ? 0.88f : 0.82f)); params.dimAmount = 0.58f; window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigEditorDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigEditorDialog.java index 2f241d19b3..0570802100 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigEditorDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigEditorDialog.java @@ -14,7 +14,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogMpvConfigEditorBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -47,7 +46,7 @@ public class MpvConfigEditorDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -106,8 +105,8 @@ public class MpvConfigEditorDialog extends BaseAlertDialog { if (window == null) return; boolean land = ResUtil.isLand(requireContext()); WindowManager.LayoutParams params = window.getAttributes(); - params.width = (int) (DialogSafeArea.width(requireActivity(), window) * (land ? 0.72f : 0.96f)); - params.height = (int) (DialogSafeArea.height(requireActivity(), window) * (land ? 0.9f : 0.88f)); + params.width = (int) (ResUtil.getScreenWidth(requireContext()) * (land ? 0.72f : 0.96f)); + params.height = (int) (ResUtil.getScreenHeight(requireContext()) * (land ? 0.9f : 0.88f)); params.dimAmount = 0.58f; window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigHistoryDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigHistoryDialog.java index 94f0d9adb3..63c14c500b 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigHistoryDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigHistoryDialog.java @@ -12,7 +12,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogHistoryBinding; import com.fongmi.android.tv.player.mpv.MpvConfigStore; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.MpvConfigHistoryAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.ResUtil; @@ -47,7 +46,7 @@ public class MpvConfigHistoryDialog extends BaseAlertDialog implements MpvConfig @Override protected MaterialAlertDialogBuilder getBuilder() { - MaterialAlertDialogBuilder builder = Util.isLeanback() ? builder() : new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog); + MaterialAlertDialogBuilder builder = Util.isLeanback() ? builder() : new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog); return builder.setView(getBinding().getRoot()); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigRenameDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigRenameDialog.java index 3f68930749..9541542a9d 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigRenameDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigRenameDialog.java @@ -12,7 +12,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogMpvConfigRenameBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -41,7 +40,7 @@ public class MpvConfigRenameDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvCustomButtonDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvCustomButtonDialog.java deleted file mode 100644 index 07f15d3189..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvCustomButtonDialog.java +++ /dev/null @@ -1,452 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Dialog; -import android.content.res.ColorStateList; -import android.graphics.Color; -import android.graphics.Typeface; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.GradientDrawable; -import android.net.Uri; -import android.text.InputType; -import android.text.TextUtils; -import android.util.Xml; -import android.view.Gravity; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; -import android.widget.CheckBox; -import android.widget.EditText; -import android.widget.LinearLayout; -import android.widget.ScrollView; -import android.widget.TextView; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.AppCompatImageButton; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.FragmentManager; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.player.mpv.MpvConfigStore; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Util; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.textfield.TextInputEditText; - -import org.xmlpull.v1.XmlPullParser; - -import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; - -public class MpvCustomButtonDialog extends DialogFragment { - - private static final int INITIAL_SLOT_COUNT = 8; - private Runnable callback; - private LinearLayout list; - - private final ActivityResultLauncher importLauncher = registerForActivityResult(new ActivityResultContracts.OpenDocument(), this::importButtons); - private final ActivityResultLauncher exportLauncher = registerForActivityResult(new ActivityResultContracts.CreateDocument("text/xml"), this::exportButtons); - - public static void show(FragmentManager manager, Runnable callback) { - MpvCustomButtonDialog dialog = new MpvCustomButtonDialog(); - dialog.callback = callback; - dialog.show(manager, "mpv-custom-buttons"); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable android.os.Bundle savedInstanceState) { - LinearLayout root = column(requireContext(), 16); - root.setBackgroundResource(R.drawable.shape_shell_proxy_dialog); - - LinearLayout header = row(requireContext()); - LinearLayout heading = column(requireContext(), 0); - TextView title = text(requireContext(), getString(R.string.mpv_config_custom_button_title), 21, true); - TextView subtitle = text(requireContext(), getString(R.string.mpv_config_custom_button_subtitle), 12, false); - subtitle.setTextColor(Color.rgb(95, 99, 104)); - heading.addView(title); - heading.addView(subtitle); - header.addView(heading, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); - AppCompatImageButton close = iconButton(R.drawable.ic_dialog_close, R.string.mpv_config_close); - close.setOnClickListener(view -> dismissAllowingStateLoss()); - header.addView(close, new LinearLayout.LayoutParams(ResUtil.dp2px(44), ResUtil.dp2px(40))); - root.addView(header, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ResUtil.dp2px(58))); - - ScrollView scroll = new ScrollView(requireContext()); - list = column(requireContext(), 0); - scroll.addView(list, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - root.addView(scroll, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); - refresh(); - return new WebHtvAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_WebHTV_Dialog).setView(root).create(); - } - - private void refresh() { - if (list == null) return; - list.removeAllViews(); - List buttons = MpvConfigStore.customButtons(); - // Keep the familiar initial layout, then append one empty slot so the - // list remains extensible instead of imposing a button-count cap. - int slotCount = Math.max(INITIAL_SLOT_COUNT, buttons.size() + 1); - for (int index = 0; index < slotCount; index++) addSlot(index, index < buttons.size() ? buttons.get(index) : null); - addImportExportCard(); - } - - private void addSlot(int index, @Nullable MpvConfigStore.CustomButton item) { - String slot = index < 4 ? "L" + (index + 1) : "R" + (index - 3); - LinearLayout row = row(requireContext()); - row.setMinimumHeight(ResUtil.dp2px(72)); - row.setPadding(ResUtil.dp2px(6), ResUtil.dp2px(6), ResUtil.dp2px(6), ResUtil.dp2px(6)); - row.setFocusable(true); - row.setFocusableInTouchMode(Util.isLeanback()); - if (item != null) row.setBackgroundResource(R.drawable.selector_mpv_profile_card); - - TextView badge = text(requireContext(), slot, 12, false); - badge.setGravity(Gravity.CENTER); - badge.setTextColor(Color.rgb(83, 101, 132)); - badge.setBackground(badgeBackground(index < 4 ? 0xFFE7EEF9 : 0xFFF0EAF4)); - row.addView(badge, new LinearLayout.LayoutParams(ResUtil.dp2px(48), ResUtil.dp2px(48))); - - TextView name = text(requireContext(), item == null ? getString(R.string.mpv_config_custom_button_empty_slot) : item.title, 15, item != null); - name.setTextColor(item == null ? Color.rgb(128, 134, 139) : Color.rgb(32, 33, 36)); - row.addView(name, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); - - if (item != null) { - AppCompatImageButton delete = iconButton(R.drawable.ic_action_delete, R.string.mpv_config_delete); - delete.setColorFilter(Color.rgb(197, 34, 31)); - delete.setBackgroundTintList(ColorStateList.valueOf(0xFFFFE3E0)); - delete.setOnClickListener(view -> confirmDelete(item)); - row.addView(delete, new LinearLayout.LayoutParams(ResUtil.dp2px(44), ResUtil.dp2px(44))); - } - AppCompatImageButton drag = iconButton(R.drawable.ic_action_drag, R.string.mpv_config_custom_button_drag); - drag.setAlpha(item == null ? 0.35f : 0.75f); - row.addView(drag, new LinearLayout.LayoutParams(ResUtil.dp2px(44), ResUtil.dp2px(44))); - row.setOnClickListener(view -> openEditor(item)); - list.addView(row, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ResUtil.dp2px(72))); - } - - private void addImportExportCard() { - LinearLayout card = column(requireContext(), 16); - card.setBackgroundResource(R.drawable.selector_mpv_profile_card); - TextView title = text(requireContext(), getString(R.string.mpv_config_custom_button_import_export), 16, true); - TextView desc = text(requireContext(), getString(R.string.mpv_config_custom_button_import_export_desc), 12, false); - desc.setTextColor(Color.rgb(95, 99, 104)); - card.addView(title); - card.addView(desc); - LinearLayout actions = row(requireContext()); - MaterialButton importButton = actionButton(R.string.mpv_config_custom_button_import, R.drawable.ic_git_cloud_download, false); - importButton.setOnClickListener(view -> importLauncher.launch(new String[]{"text/xml", "application/xml", "text/*"})); - MaterialButton exportButton = actionButton(R.string.mpv_config_custom_button_export, R.drawable.ic_git_cloud_upload, true); - exportButton.setOnClickListener(view -> exportLauncher.launch("custom_buttons_" + System.currentTimeMillis() + ".xml")); - actions.addView(importButton, new LinearLayout.LayoutParams(0, ResUtil.dp2px(44), 1)); - actions.addView(exportButton, new LinearLayout.LayoutParams(0, ResUtil.dp2px(44), 1)); - card.addView(actions, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ResUtil.dp2px(52))); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.topMargin = ResUtil.dp2px(12); - params.bottomMargin = ResUtil.dp2px(8); - list.addView(card, params); - } - - private void openEditor(@Nullable MpvConfigStore.CustomButton button) { - Editor.show(getChildFragmentManager(), button, this::refresh); - } - - private void confirmDelete(MpvConfigStore.CustomButton button) { - new WebHtvAlertDialogBuilder(requireContext()) - .setTitle(R.string.mpv_config_custom_button_delete) - .setMessage(getString(R.string.mpv_config_custom_button_delete_message, button.title)) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.mpv_config_delete, (dialog, which) -> { - try { - MpvConfigStore.deleteCustomButton(button.id); - refresh(); - if (callback != null) callback.run(); - } catch (Throwable error) { - Notify.show(message(error)); - } - }).show(); - } - - private void importButtons(@Nullable Uri uri) { - if (uri == null) return; - try (InputStream input = requireContext().getContentResolver().openInputStream(uri)) { - if (input == null) throw new IllegalStateException(getString(R.string.mpv_config_custom_button_file_invalid)); - List buttons = parseXml(input); - for (MpvConfigStore.CustomButton button : buttons) { - MpvConfigStore.saveCustomButton(button.id, button.title, button.content, button.longPressContent, button.onStartup, button.enabled); - } - refresh(); - if (callback != null) callback.run(); - Notify.show(getString(R.string.mpv_config_custom_button_imported, buttons.size())); - } catch (Throwable error) { - Notify.show(message(error)); - } - } - - private void exportButtons(@Nullable Uri uri) { - if (uri == null) return; - try (OutputStream output = requireContext().getContentResolver().openOutputStream(uri)) { - if (output == null) throw new IllegalStateException(getString(R.string.mpv_config_custom_button_file_invalid)); - output.write(buildXml(MpvConfigStore.customButtons()).getBytes(java.nio.charset.StandardCharsets.UTF_8)); - Notify.show(R.string.mpv_config_custom_button_exported); - } catch (Throwable error) { - Notify.show(message(error)); - } - } - - private static String buildXml(List buttons) { - StringBuilder xml = new StringBuilder("\n\n"); - for (MpvConfigStore.CustomButton button : buttons) { - xml.append(" \n"); - } - return xml.append("\n").toString(); - } - - private static List parseXml(InputStream input) throws Exception { - List result = new ArrayList<>(); - XmlPullParser parser = Xml.newPullParser(); - parser.setInput(input, "UTF-8"); - MpvConfigStore.CustomButton current = null; - String field = null; - StringBuilder value = null; - int event; - while ((event = parser.next()) != XmlPullParser.END_DOCUMENT) { - if (event == XmlPullParser.START_TAG) { - if ("button".equals(parser.getName())) { - current = new MpvConfigStore.CustomButton(); - current.id = parser.getAttributeValue(null, "id"); - current.title = parser.getAttributeValue(null, "title"); - current.enabled = !"false".equalsIgnoreCase(parser.getAttributeValue(null, "enabled")); - } else if (current != null && ("short".equals(parser.getName()) || "longPress".equals(parser.getName()) || "startup".equals(parser.getName()))) { - field = parser.getName(); - value = new StringBuilder(); - } - } else if (event == XmlPullParser.TEXT && value != null) { - value.append(parser.getText()); - } else if (event == XmlPullParser.END_TAG) { - if (current != null && value != null && parser.getName().equals(field)) { - if ("short".equals(field)) current.content = value.toString(); - else if ("longPress".equals(field)) current.longPressContent = value.toString(); - else current.onStartup = value.toString(); - field = null; - value = null; - } else if ("button".equals(parser.getName()) && current != null) { - if (!TextUtils.isEmpty(current.title)) result.add(current); - current = null; - } - } - } - return result; - } - - private static String cdata(String tag, String value) { - String text = value == null ? "" : value.replace("]]>", "]]]]>"); - return " <" + tag + ">\n"; - } - - private static String attribute(String value) { - if (value == null) return ""; - return value.replace("&", "&").replace("\"", """).replace("<", "<").replace(">", ">"); - } - - @Override - public void onStart() { - super.onStart(); - Dialog dialog = getDialog(); - Window window = dialog == null ? null : dialog.getWindow(); - if (window == null) return; - WindowManager.LayoutParams params = window.getAttributes(); - params.width = Math.min((int) (DialogSafeArea.width(requireActivity(), window) * (Util.isLeanback() ? 0.68f : 0.94f)), ResUtil.dp2px(760)); - params.height = (int) (DialogSafeArea.height(requireActivity(), window) * (Util.isLeanback() ? 0.88f : 0.9f)); - params.dimAmount = 0.58f; - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.getDecorView().setPadding(0, 0, 0, 0); - window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - window.setAttributes(params); - window.setLayout(params.width, params.height); - } - - private AppCompatImageButton iconButton(int icon, int description) { - AppCompatImageButton view = new AppCompatImageButton(requireContext()); - view.setBackgroundResource(R.drawable.selector_mpv_icon_button); - view.setContentDescription(getString(description)); - view.setPadding(ResUtil.dp2px(10), ResUtil.dp2px(10), ResUtil.dp2px(10), ResUtil.dp2px(10)); - view.setImageResource(icon); - view.setColorFilter(Color.rgb(95, 99, 104)); - return view; - } - - private MaterialButton actionButton(int text, int icon, boolean primary) { - MaterialButton view = button(requireContext(), getString(text)); - view.setIconResource(icon); - view.setIconPadding(ResUtil.dp2px(6)); - view.setMinHeight(0); - view.setMinWidth(0); - if (!primary) { - view.setBackgroundTintList(ColorStateList.valueOf(Color.TRANSPARENT)); - view.setTextColor(Color.rgb(60, 64, 67)); - view.setStrokeWidth(ResUtil.dp2px(1)); - view.setStrokeColor(ColorStateList.valueOf(0xFFB7B9BE)); - } - return view; - } - - private static GradientDrawable badgeBackground(int color) { - GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(color); - drawable.setShape(GradientDrawable.OVAL); - return drawable; - } - - private static LinearLayout row(android.content.Context context) { - LinearLayout view = new LinearLayout(context); - view.setOrientation(LinearLayout.HORIZONTAL); - view.setGravity(Gravity.CENTER_VERTICAL); - return view; - } - - private static LinearLayout column(android.content.Context context, int padding) { - LinearLayout view = new LinearLayout(context); - view.setOrientation(LinearLayout.VERTICAL); - if (padding > 0) view.setPadding(ResUtil.dp2px(padding), ResUtil.dp2px(padding), ResUtil.dp2px(padding), ResUtil.dp2px(padding)); - return view; - } - - private static TextView text(android.content.Context context, String value, int size, boolean bold) { - TextView view = new TextView(context); - view.setText(value); - view.setTextColor(Color.rgb(32, 33, 36)); - view.setTextSize(size); - view.setTypeface(view.getTypeface(), bold ? Typeface.BOLD : Typeface.NORMAL); - return view; - } - - private static MaterialButton button(android.content.Context context, String value) { - MaterialButton view = new MaterialButton(context); - view.setText(value); - view.setMinHeight(0); - view.setMinWidth(0); - return view; - } - - private static String message(Throwable error) { - return TextUtils.isEmpty(error.getMessage()) ? error.getClass().getSimpleName() : error.getMessage(); - } - - public static final class Editor extends DialogFragment { - - private MpvConfigStore.CustomButton source; - private Runnable callback; - private TextInputEditText title; - private TextInputEditText shortCode; - private TextInputEditText longCode; - private TextInputEditText startupCode; - private CheckBox enabled; - - static void show(FragmentManager manager, MpvConfigStore.CustomButton source, Runnable callback) { - Editor dialog = new Editor(); - dialog.source = source; - dialog.callback = callback; - dialog.show(manager, "mpv-custom-button-editor"); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable android.os.Bundle savedInstanceState) { - LinearLayout root = column(requireContext(), 16); - root.setBackgroundResource(R.drawable.shape_shell_proxy_dialog); - TextView heading = text(requireContext(), getString(source == null ? R.string.mpv_config_custom_button_new : R.string.mpv_config_custom_button_edit), 19, true); - root.addView(heading, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ResUtil.dp2px(42))); - title = input(requireContext(), getString(R.string.mpv_config_custom_button_name), source == null ? "" : source.title, false); - root.addView(title, inputParams()); - enabled = new CheckBox(requireContext()); - enabled.setText(R.string.mpv_config_custom_button_enabled); - enabled.setChecked(source == null || source.enabled); - root.addView(enabled, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - TextView lua = text(requireContext(), getString(R.string.mpv_config_custom_button_lua_section), 14, true); - lua.setTextColor(Color.rgb(60, 64, 67)); - root.addView(lua, inputParams()); - shortCode = input(requireContext(), getString(R.string.mpv_config_custom_button_short), source == null ? "" : source.content, true); - longCode = input(requireContext(), getString(R.string.mpv_config_custom_button_long), source == null ? "" : source.longPressContent, true); - startupCode = input(requireContext(), getString(R.string.mpv_config_custom_button_startup), source == null ? "" : source.onStartup, true); - root.addView(shortCode, inputParams()); - root.addView(longCode, inputParams()); - root.addView(startupCode, inputParams()); - LinearLayout actions = row(requireContext()); - MaterialButton cancel = button(requireContext(), getString(R.string.mpv_config_close)); - cancel.setOnClickListener(view -> dismissAllowingStateLoss()); - MaterialButton save = button(requireContext(), getString(R.string.mpv_config_save)); - save.setOnClickListener(view -> save()); - actions.addView(cancel, new LinearLayout.LayoutParams(0, ResUtil.dp2px(46), 1)); - actions.addView(save, new LinearLayout.LayoutParams(0, ResUtil.dp2px(46), 1)); - root.addView(actions, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - ScrollView scroll = new ScrollView(requireContext()); - scroll.addView(root, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - return new WebHtvAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_WebHTV_Dialog).setView(scroll).create(); - } - - private void save() { - try { - String id = source == null ? "" : source.id; - MpvConfigStore.saveCustomButton(id, value(title), value(shortCode), value(longCode), value(startupCode), enabled.isChecked()); - Notify.show(R.string.mpv_config_custom_button_saved); - if (callback != null) callback.run(); - dismissAllowingStateLoss(); - } catch (Throwable error) { - Notify.show(message(error)); - } - } - - private static TextInputEditText input(android.content.Context context, String hint, String value, boolean multiline) { - TextInputEditText edit = new TextInputEditText(context); - edit.setHint(hint); - edit.setText(value); - edit.setTextSize(14); - edit.setInputType(multiline ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE : InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); - edit.setSingleLine(!multiline); - if (multiline) { - edit.setMinLines(3); - edit.setGravity(Gravity.TOP | Gravity.START); - edit.setBackgroundResource(R.drawable.shape_mpv_editor); - edit.setPadding(ResUtil.dp2px(12), ResUtil.dp2px(10), ResUtil.dp2px(12), ResUtil.dp2px(10)); - } - return edit; - } - - private static LinearLayout.LayoutParams inputParams() { - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.topMargin = ResUtil.dp2px(8); - return params; - } - - private static String value(EditText edit) { - return edit.getText() == null ? "" : edit.getText().toString(); - } - - @Override - public void onStart() { - super.onStart(); - Dialog dialog = getDialog(); - Window window = dialog == null ? null : dialog.getWindow(); - if (window == null) return; - WindowManager.LayoutParams params = window.getAttributes(); - params.width = Math.min((int) (DialogSafeArea.width(requireActivity(), window) * (Util.isLeanback() ? 0.68f : 0.94f)), ResUtil.dp2px(760)); - params.height = (int) (DialogSafeArea.height(requireActivity(), window) * (Util.isLeanback() ? 0.9f : 0.92f)); - params.dimAmount = 0.58f; - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.getDecorView().setPadding(0, 0, 0, 0); - window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - window.setAttributes(params); - window.setLayout(params.width, params.height); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MultiThreadProxyDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MultiThreadProxyDialog.java index 3d9265dd8c..426dc2030d 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MultiThreadProxyDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MultiThreadProxyDialog.java @@ -22,7 +22,6 @@ import com.fongmi.android.tv.server.proxy.ProxyDomainRuleSet; import com.fongmi.android.tv.server.proxy.ProxyRuntimeConfig; import com.fongmi.android.tv.server.proxy.ProxyRuntimeConfigValidator; import com.fongmi.android.tv.setting.MultiThreadProxySetting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -73,7 +72,7 @@ public class MultiThreadProxyDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog) + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog) .setView(getBinding().getRoot()); } @@ -84,8 +83,8 @@ public class MultiThreadProxyDialog extends BaseAlertDialog { if (dialog == null) return; Window window = dialog.getWindow(); if (window == null) return; - int width = DialogSafeArea.width(requireActivity(), window); - int height = DialogSafeArea.height(requireActivity(), window); + int width = ResUtil.getScreenWidth(requireContext()); + int height = ResUtil.getScreenHeight(requireContext()); boolean land = ResUtil.isLand(requireContext()); WindowManager.LayoutParams params = window.getAttributes(); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); @@ -223,7 +222,7 @@ public class MultiThreadProxyDialog extends BaseAlertDialog { private void showApplyChoice(ProxyRuntimeConfig next, ProxyDomainRuleSet rules) { FragmentActivity activity = requireActivity(); dismiss(); - new WebHtvAlertDialogBuilder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(R.string.multi_thread_proxy_apply_title) .setMessage(R.string.multi_thread_proxy_apply_message) .setNegativeButton(R.string.multi_thread_proxy_apply_later, diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/NovelSourceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/NovelSourceDialog.java index 1526cc63e5..b0e96778df 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/NovelSourceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/NovelSourceDialog.java @@ -13,9 +13,9 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.NovelSourceConfig; import com.fongmi.android.tv.bean.Site; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.List; @@ -70,7 +70,7 @@ public class NovelSourceDialog { manageBtn.setOnClickListener(v -> showSiteManage()); resetBtn.setOnClickListener(v -> resetToDefault()); - dialog = new WebHtvAlertDialogBuilder(activity) + dialog = new MaterialAlertDialogBuilder(activity) .setTitle(R.string.setting_novel_source) .setView(view) .setPositiveButton(R.string.dialog_positive, (d, w) -> onSave()) @@ -103,7 +103,7 @@ public class NovelSourceDialog { checked[i] = matchesRule(enabledRules, site); } - new WebHtvAlertDialogBuilder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(R.string.dialog_novel_site_manage) .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> checked[which] = isChecked) .setPositiveButton(R.string.dialog_positive, (d, w) -> applySiteManage(sites, enabledRules, checked)) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/OneKeySyncDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/OneKeySyncDialog.java index 9dd8e2afa7..c4408318cd 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/OneKeySyncDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/OneKeySyncDialog.java @@ -23,15 +23,12 @@ import com.fongmi.android.tv.databinding.DialogOneKeySyncBinding; import com.fongmi.android.tv.remote.RemoteStore; import com.fongmi.android.tv.server.Server; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeTokens; import com.fongmi.android.tv.ui.adapter.SyncDeviceAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.NsdDeviceDiscovery; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.LoginStateSync; -import com.fongmi.android.tv.utils.MpvConfigSync; import com.fongmi.android.tv.utils.ProgressRequestBody; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.ScanTask; @@ -40,7 +37,6 @@ import com.fongmi.android.tv.utils.Task; import com.github.catvod.net.OkHttp; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.bottomsheet.BottomSheetDialog; -import com.google.android.material.card.MaterialCardView; import com.google.android.material.checkbox.MaterialCheckBox; import java.io.File; @@ -72,18 +68,6 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic private NsdDeviceDiscovery discovery; private Device selected; private Call syncCall; - /** - * 同步代际:每次 {@link #startSync()} 自增,回调执行前比对。 - * - *

取消/失败的回调都是异步投递的(OkHttp 线程 → {@code App.post})。若用户在上一条 - * post 尚未执行时就重新点了「开始同步」,旧回调会把**新一次同步**的界面复位:进度清零、 - * 按钮文案回「开始同步」、方向按钮被重新启用。代际不相等即丢弃陈旧回调。 - * - *

写入只在主线程({@code startSync()} 由点击回调触发),但读取发生在回调线程: - * {@code Task.execute} 的固定线程池、OkHttp 的 Dispatcher 线程。因此必须 volatile—— - * 同文件的 {@code syncing} 出于同样的原因已经是 volatile。 - */ - private volatile long syncGeneration; private long progressStart; private long prepareStart; private long prepareUpdate; @@ -126,10 +110,7 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic @Override protected void initEvent() { - binding.directionGroup.addOnButtonCheckedListener((group, checkedId, isChecked) -> { - if (!isChecked) return; - setToRemote(checkedId == R.id.directionPush); - }); + binding.modeButton.setOnClickListener(v -> toggleMode()); binding.refresh.setOnClickListener(v -> refresh()); binding.changeDevice.setOnClickListener(v -> changeDevice()); binding.selectAll.setOnClickListener(v -> toggleSelection()); @@ -184,17 +165,11 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic setScanning(false); } - private void setToRemote(boolean value) { - if (toRemote == value) return; - toRemote = value; + private void toggleMode() { + toRemote = !toRemote; updateDirection(); } - private void updateDirectionGroup() { - int checked = toRemote ? R.id.directionPush : R.id.directionPull; - if (binding.directionGroup.getCheckedButtonId() != checked) binding.directionGroup.check(checked); - } - private void toggleSelection() { select(!allSelected()); } @@ -211,7 +186,7 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic } private MaterialCheckBox[] boxes() { - return new MaterialCheckBox[]{binding.config, binding.spider, binding.mpvConfig, binding.loginState, binding.webHome, binding.search, binding.history, binding.keep, binding.follow, binding.settings, binding.remoteRelay}; + return new MaterialCheckBox[]{binding.config, binding.spider, binding.loginState, binding.webHome, binding.search, binding.history, binding.keep, binding.settings, binding.remoteRelay}; } private void updateSyncPathSummary() { @@ -226,13 +201,11 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic return SyncOptions.defaults() .config(binding.config.isChecked()) .spider(binding.spider.isChecked()) - .mpvConfig(binding.mpvConfig.isChecked()) .loginState(binding.loginState.isChecked()) .webHome(binding.webHome.isChecked()) .search(binding.search.isChecked()) .history(binding.history.isChecked()) .keep(binding.keep.isChecked()) - .follow(binding.follow.isChecked()) .settings(binding.settings.isChecked()) .remoteRelay(binding.remoteRelay.isChecked()) .paths(Setting.getSyncPaths()); @@ -282,7 +255,7 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic binding.selected.setText(toRemote ? R.string.sync_mode_to_remote : R.string.sync_mode_from_remote); binding.mode.setText(toRemote ? R.string.sync_push : R.string.sync_pull); binding.mode.setCompoundDrawablesWithIntrinsicBounds(0, toRemote ? R.drawable.ic_sync_arrow_right_blue : R.drawable.ic_sync_arrow_left_blue, 0, 0); - updateDirectionGroup(); + binding.modeButton.setText(toRemote ? R.string.sync_push : R.string.sync_pull); updateDirectionCard(binding.localCard, binding.localRole, toRemote); updateDirectionCard(binding.remoteCard, binding.remoteRole, !toRemote); binding.localRole.setText(toRemote ? R.string.sync_sender : R.string.sync_receiver); @@ -293,20 +266,11 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic binding.selectAll.setText(allSelected() ? R.string.sync_select_none : R.string.sync_select_all); } - /** - * 高亮"发送方"卡片,另一张退回布局的静态样式。 - * - *

颜色必须走主题 token,不能用裸 hex:布局里两张卡片本来就是 - * {@code ?attr/colorSurfaceContainerLow} + {@code ?attr/colorOutlineVariant}, - * 用固定浅色 hex 覆盖会让深色/自定义主题下的方向卡仍是浅底深字。 - * 这里用 M3 的成对语义色(容器色 + 其 on 色),对比度由主题契约保证。 - */ - private void updateDirectionCard(MaterialCardView card, TextView role, boolean sender) { - ThemeTokens tokens = ThemeController.current(); - card.setCardBackgroundColor(sender ? tokens.colorPrimaryContainer() : tokens.colorSurfaceContainerLow()); - card.setStrokeColor(sender ? Color.TRANSPARENT : tokens.colorOutlineVariant()); - card.setStrokeWidth(sender ? 0 : ResUtil.dp2px(1)); - role.setTextColor(sender ? tokens.colorOnPrimaryContainer() : tokens.colorOnSurfaceVariant()); + private void updateDirectionCard(com.google.android.material.card.MaterialCardView card, TextView role, boolean sender) { + card.setCardBackgroundColor(Color.parseColor(sender ? "#E8F0FE" : "#F8FAFD")); + card.setStrokeColor(Color.TRANSPARENT); + card.setStrokeWidth(0); + role.setTextColor(Color.parseColor(sender ? "#174EA6" : "#5F6368")); } @Override @@ -361,12 +325,6 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic return; } SyncOptions options = options(); - // 方向必须在入口快照:toRemote 由主线程的方向选择器改写,而下面的工作线程要读它 - // 决定请求体。若不快照,同步中切换方向会让 URL 的 mode 与请求体(backup/device) - // 指向相反方向。 - final boolean toRemote = this.toRemote; - // 代际同样在入口快照:陈旧回调靠它在执行前丢弃,不会复位新一次同步的界面。 - final long generation = ++syncGeneration; String mode = toRemote ? "1" : "2"; String url = String.format(Locale.getDefault(), "%s/action?do=sync&mode=%s&type=backup", selected.getIp(), mode); binding.start.setEnabled(false); @@ -376,25 +334,19 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic prepareUpdate = 0; Task.execute(() -> { try { - SyncFiles.Archive archive = toRemote && SyncFiles.hasPaths(options) ? SyncFiles.createArchive(SyncFiles.getPaths(options), () -> syncing && generation == syncGeneration, (count, size) -> onPrepareProgress(generation, count, size)) : null; - MpvConfigSync.Archive mpvArchive = toRemote && options.isMpvConfig() ? MpvConfigSync.createArchive() : null; + SyncFiles.Archive archive = toRemote && SyncFiles.hasPaths(options) ? SyncFiles.createArchive(SyncFiles.getPaths(options), () -> syncing, this::onPrepareProgress) : null; LoginStateSync.Archive loginArchive = toRemote && options.isLoginState() ? LoginStateSync.createArchive() : null; - RequestBody body = buildBody(options, toRemote, archive, mpvArchive, loginArchive, generation); - // 代际必须一起判:取消后立刻重开时 syncing 已是新一次同步的 true, - // 只判 syncing 会让这条陈旧的准备线程继续把上一轮的 body 真发出去, - // 并在 request() 里覆盖 syncCall(此后点「取消」取消的是错的 Call)。 - if (!syncing || generation != syncGeneration) { + RequestBody body = buildBody(options, archive, loginArchive); + if (!syncing) { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); return; } App.post(() -> binding.start.setEnabled(true)); - request(url, body, archive, mpvArchive, loginArchive, 0, generation); + request(url, body, archive, loginArchive, 0); } catch (Exception e) { - if (!syncing || generation != syncGeneration) return; + if (!syncing) return; App.post(() -> { - if (generation != syncGeneration) return; setSyncing(false); Notify.show(getString(R.string.sync_failed_with_reason, e.getMessage())); }); @@ -402,8 +354,8 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic }); } - private RequestBody buildBody(SyncOptions options, boolean toRemote, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, long generation) { - if (archive == null && mpvArchive == null && loginArchive == null) { + private RequestBody buildBody(SyncOptions options, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive) { + if (archive == null && loginArchive == null) { FormBody.Builder body = new FormBody.Builder(); body.add("options", options.toString()); body.add("force", "false"); @@ -412,14 +364,13 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic addRemoteRelay(body, options, toRemote); return body.build(); } - App.post(() -> updatePrepare(generation, archive, loginArchive)); + App.post(() -> updatePrepare(archive, loginArchive)); MultipartBody.Builder body = new MultipartBody.Builder().setType(MultipartBody.FORM); body.addFormDataPart("options", options.toString()); body.addFormDataPart("force", "false"); body.addFormDataPart("backup", Backup.create(options).toString()); if (options.isRemoteRelay()) body.addFormDataPart("remoteRelay", RemoteStore.exportRelayConfig()); - if (archive != null) body.addFormDataPart(SyncFiles.PART_NAME, archive.getFile().getName(), new ProgressRequestBody(archive.getFile(), ZIP, (written, total) -> App.post(() -> updateUpload(generation, archive, written, total)))); - if (mpvArchive != null) body.addFormDataPart(MpvConfigSync.PART_NAME, mpvArchive.getFile().getName(), new ProgressRequestBody(mpvArchive.getFile(), ZIP, null)); + if (archive != null) body.addFormDataPart(SyncFiles.PART_NAME, archive.getFile().getName(), new ProgressRequestBody(archive.getFile(), ZIP, (written, total) -> App.post(() -> updateUpload(archive, written, total)))); if (loginArchive != null) body.addFormDataPart(LoginStateSync.PART_NAME, loginArchive.getFile().getName(), new ProgressRequestBody(loginArchive.getFile(), ZIP, null)); return body.build(); } @@ -428,76 +379,56 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic if (options.isRemoteRelay() && includeLocal) body.add("remoteRelay", RemoteStore.exportRelayConfig()); } - private void request(String url, RequestBody body, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, int retry, long generation) { + private void request(String url, RequestBody body, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive, int retry) { syncCall = OkHttp.newCall(client, url, body); - syncCall.enqueue(callback(url, body, archive, mpvArchive, loginArchive, retry, generation)); + syncCall.enqueue(callback(url, body, archive, loginArchive, retry)); } - private void retry(String url, RequestBody body, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, int retry, long generation, String msg) { - if (generation != syncGeneration || !syncing) { + private void retry(String url, RequestBody body, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive, int retry, String msg) { + if (!syncing) { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); return; } if (retry >= MAX_RETRY) { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); App.post(() -> { - if (generation != syncGeneration) return; setSyncing(false); Notify.show(getString(R.string.sync_failed_with_reason, msg)); }); } else { - Task.schedule(() -> { - // 延迟重试也要复核代际:这段时间里用户可能已取消并重新开始同步, - // 此时重发上一轮的请求会与新同步并发,并劫持 syncCall。 - if (generation != syncGeneration || !syncing) { - if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); - if (loginArchive != null) loginArchive.delete(); - return; - } - request(url, body, archive, mpvArchive, loginArchive, retry + 1, generation); - }, RETRY_DELAY, TimeUnit.MILLISECONDS); + Task.schedule(() -> request(url, body, archive, loginArchive, retry + 1), RETRY_DELAY, TimeUnit.MILLISECONDS); } } - private Callback callback(String url, RequestBody body, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, int retry, long generation) { + private Callback callback(String url, RequestBody body, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive, int retry) { return new Callback() { @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { if (call.isCanceled() || !syncing) { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); App.post(() -> { - if (generation != syncGeneration) return; setSyncing(false); Notify.show(R.string.sync_canceled); }); return; } - retry(url, body, archive, mpvArchive, loginArchive, retry, generation, e.getMessage()); + retry(url, body, archive, loginArchive, retry, e.getMessage()); } @Override public void onResponse(@NonNull Call call, @NonNull Response response) { try (Response res = response) { - if (res.isSuccessful()) { - // 临时归档必须无条件清理:陈旧回调也要删,否则会留下本次同步的文件。 + if (res.isSuccessful()) App.post(() -> { if (archive != null) archive.delete(); - if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); - App.post(() -> { - if (generation != syncGeneration) return; - setSyncing(false); - Notify.show(R.string.sync_success); - dismiss(); - }); - } - else retry(url, body, archive, mpvArchive, loginArchive, retry, generation, res.message()); + setSyncing(false); + Notify.show(R.string.sync_success); + dismiss(); + }); + else retry(url, body, archive, loginArchive, retry, res.message()); } } }; @@ -509,11 +440,6 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic binding.progressBox.setVisibility(syncing ? View.VISIBLE : View.GONE); binding.start.setText(syncing ? R.string.sync_cancel : R.string.sync_start); binding.start.setEnabled(true); - // 同步中锁住方向:请求的方向已在 startSync 入口快照,此时翻方向不会改变 - // 正在跑的这次同步,只会让界面显示的方向与实际请求相反。 - // 注意 ViewGroup.setEnabled 不会传播到子 View,必须逐个按钮设。 - binding.directionPush.setEnabled(!syncing); - binding.directionPull.setEnabled(!syncing); if (!syncing) { syncCall = null; binding.progress.setProgress(0); @@ -530,8 +456,8 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic } } - private void updatePrepare(long generation, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive) { - if (!syncing || generation != syncGeneration || binding == null) return; + private void updatePrepare(SyncFiles.Archive archive, LoginStateSync.Archive loginArchive) { + if (!syncing || binding == null) return; progressStart = System.currentTimeMillis(); binding.progress.setProgress(100); int count = (archive == null ? 0 : archive.getCount()) + (loginArchive == null ? 0 : loginArchive.getCount()); @@ -540,23 +466,23 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic binding.progressText.setText(getString(R.string.sync_upload_prepare, count, FileUtil.byteCountToDisplaySize(rawSize), FileUtil.byteCountToDisplaySize(zipSize))); } - private void onPrepareProgress(long generation, int count, long size) { + private void onPrepareProgress(int count, long size) { long now = System.currentTimeMillis(); if (now - prepareUpdate < 350) return; prepareUpdate = now; long elapsed = Math.max(now - prepareStart, 1); long speed = size * 1000 / elapsed; - App.post(() -> updatePrepareProgress(generation, count, size, speed)); + App.post(() -> updatePrepareProgress(count, size, speed)); } - private void updatePrepareProgress(long generation, int count, long size, long speed) { - if (!syncing || generation != syncGeneration || binding == null) return; + private void updatePrepareProgress(int count, long size, long speed) { + if (!syncing || binding == null) return; binding.progress.setProgress(0); binding.progressText.setText(getString(R.string.sync_prepare_progress, count, FileUtil.byteCountToDisplaySize(size), FileUtil.byteCountToDisplaySize(speed))); } - private void updateUpload(long generation, SyncFiles.Archive archive, long written, long total) { - if (!syncing || generation != syncGeneration || binding == null) return; + private void updateUpload(SyncFiles.Archive archive, long written, long total) { + if (!syncing || binding == null) return; long elapsed = Math.max(System.currentTimeMillis() - progressStart, 1); long speed = written * 1000 / elapsed; int percent = total <= 0 ? 0 : (int) Math.min(100, written * 100 / total); @@ -587,9 +513,6 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic @Override public void onDestroyView() { - // 关掉对话框同样作废代际:在途回调随后会走到 App.post 里,而那时 binding 已经销毁, - // 继续 setSyncing(...) 会碰已解绑的视图。代际不等即丢弃,归档仍照常清理。 - ++syncGeneration; if (syncCall != null) syncCall.cancel(); syncing = false; super.onDestroyView(); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PanNetworkDiagnosticDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PanNetworkDiagnosticDialog.java index 48b8cdf02e..dba341109a 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PanNetworkDiagnosticDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PanNetworkDiagnosticDialog.java @@ -39,14 +39,13 @@ import com.fongmi.android.tv.player.diagnostic.PanDiagnosticVerdict; import com.fongmi.android.tv.player.diagnostic.PanEndpoint; import com.fongmi.android.tv.player.diagnostic.PanEndpointParser; import com.fongmi.android.tv.player.diagnostic.PanNetworkDiagnosticRunner; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.ui.helper.TvFocusScale; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.button.MaterialButton; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.progressindicator.LinearProgressIndicator; import com.google.android.material.tabs.TabLayout; @@ -123,7 +122,7 @@ public final class PanNetworkDiagnosticDialog extends DialogFragment implements contentScroll.setOverScrollMode(View.OVER_SCROLL_NEVER); content = vertical(0); contentScroll.addView(content, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - int screenHeight = DialogSafeArea.height(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); int contentHeight = Util.isLeanback() ? Math.min(dp(460), Math.max(dp(400), screenHeight - dp(300))) : Math.min(dp(560), Math.max(dp(350), screenHeight * 5 / 8)); @@ -136,7 +135,7 @@ public final class PanNetworkDiagnosticDialog extends DialogFragment implements root.addView(resultFooter, footerParams); resolveEndpoint(); showConfig(); - Dialog dialog = new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(root).create(); + Dialog dialog = new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(root).create(); dialog.setCanceledOnTouchOutside(false); return dialog; } @@ -443,7 +442,7 @@ public final class PanNetworkDiagnosticDialog extends DialogFragment implements private void confirmHighThread(List threads, long bytes) { int maxThreads = threads.get(threads.size() - 1); - new WebHtvAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_WebHTV_Dialog) + new MaterialAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_WebHTV_LightDialog) .setTitle("确认高流量诊断") .setMessage("将测试到 " + maxThreads + " 线程,预计流量上限约 " + PanNetworkDiagnosticRunner.formatBytes(bytes) + ",已计入探测、预热、重复、直链对照、App DataSource、配对验证和各可重试阶段的一次瞬态断流重试。高线程可能增加耗电、内存占用,并触发源站或网盘限流、风控。") @@ -915,7 +914,7 @@ public final class PanNetworkDiagnosticDialog extends DialogFragment implements button.setStrokeColor(ColorStateList.valueOf(primary ? BLUE : Color.rgb(138, 180, 248))); button.setStrokeWidth(dp(1)); button.setOnFocusChangeListener((v, focused) -> { - TvFocusScale.applyCompact(v, focused); + v.animate().scaleX(focused ? 1.035f : 1f).scaleY(focused ? 1.035f : 1f).setDuration(100).start(); if (!primary) { button.setTextColor(ColorStateList.valueOf(focused ? Color.WHITE : BLUE_DARK)); button.setBackgroundTintList(ColorStateList.valueOf(focused ? BLUE : Color.WHITE)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceDialog.java index 233e350810..8f8dfe8fd6 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceDialog.java @@ -19,15 +19,12 @@ import android.widget.ScrollView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; -import androidx.core.view.OneShotPreDrawListener; import androidx.core.widget.TextViewCompat; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeTokens; import com.fongmi.android.tv.App; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.setting.PlaybackPerformanceCatalog; @@ -74,7 +71,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { PlaybackPerformanceSetting.ensureInitialized(); - Dialog dialog = new Dialog(requireActivity(), R.style.Theme_WebHTV_Dialog); + Dialog dialog = new Dialog(requireActivity(), R.style.Theme_WebHTV_LightDialog); dialog.setContentView(createView(LayoutInflater.from(requireContext()))); return dialog; } @@ -106,7 +103,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { MaterialTextView title = new MaterialTextView(requireContext()); title.setText(getString(R.string.player_performance) + " · " + playerName()); - title.setTextColor(ThemeController.current().colorOnSurface()); + title.setTextColor(Color.parseColor("#202124")); title.setTextSize(18); title.setGravity(Gravity.CENTER_VERTICAL); titleBar.addView(title, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); @@ -136,7 +133,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { list = new LinearLayout(requireContext()); list.setOrientation(LinearLayout.VERTICAL); scroll.addView(list, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Math.min(dp(460), Math.max(dp(300), DialogSafeArea.height(requireContext()) * 2 / 3))); + LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Math.min(dp(460), Math.max(dp(300), ResUtil.getScreenHeight(requireContext()) * 2 / 3))); scrollParams.topMargin = dp(16); root.addView(scroll, scrollParams); refreshRows(); @@ -157,7 +154,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { MaterialTextView title = new MaterialTextView(requireContext()); title.setText(getString(R.string.player_performance_help_title) + " · " + playerName()); - title.setTextColor(ThemeController.current().colorOnSurface()); + title.setTextColor(Color.parseColor("#202124")); title.setTextSize(18); title.setTypeface(Typeface.DEFAULT, Typeface.BOLD); title.setGravity(Gravity.CENTER_VERTICAL); @@ -179,7 +176,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { content.setOrientation(LinearLayout.VERTICAL); content.setPadding(dp(4), dp(12), dp(4), dp(8)); scroll.addView(content, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Math.min(dp(560), DialogSafeArea.height(requireContext()) * 3 / 5)); + LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Math.min(dp(560), ResUtil.getScreenHeight(requireContext()) * 3 / 5)); scrollParams.topMargin = dp(10); root.addView(scroll, scrollParams); @@ -204,7 +201,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { addHelpItem(content, option.title(), option.description()); } - Dialog dialog = new Dialog(requireContext(), R.style.Theme_WebHTV_Dialog); + Dialog dialog = new Dialog(requireContext(), R.style.Theme_WebHTV_LightDialog); dialog.setContentView(root); dialog.setCanceledOnTouchOutside(true); dialog.setOnShowListener(ignored -> resizeHelpDialog(dialog)); @@ -241,7 +238,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void addHelpIntro(LinearLayout content, String text) { MaterialTextView intro = new MaterialTextView(requireContext()); intro.setText(text); - intro.setTextColor(ThemeController.current().colorOnSurfaceVariant()); + intro.setTextColor(Color.parseColor("#3C4043")); intro.setTextSize(13); intro.setLineSpacing(dp(3), 1f); content.addView(intro, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); @@ -250,7 +247,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void addHelpSection(LinearLayout content, String text) { MaterialTextView section = new MaterialTextView(requireContext()); section.setText(text); - section.setTextColor(ThemeController.current().colorPrimary()); + section.setTextColor(Color.parseColor("#174EA6")); section.setTextSize(15); section.setTypeface(Typeface.DEFAULT, Typeface.BOLD); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -262,14 +259,14 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void addHelpItem(LinearLayout content, String title, String description) { MaterialTextView name = new MaterialTextView(requireContext()); name.setText(title); - name.setTextColor(ThemeController.current().colorOnSurface()); + name.setTextColor(Color.parseColor("#202124")); name.setTextSize(14); name.setTypeface(Typeface.DEFAULT, Typeface.BOLD); content.addView(name, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); MaterialTextView detail = new MaterialTextView(requireContext()); detail.setText(description); - detail.setTextColor(ThemeController.current().colorOnSurfaceVariant()); + detail.setTextColor(Color.parseColor("#5F6368")); detail.setTextSize(13); detail.setLineSpacing(dp(3), 1f); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -301,9 +298,9 @@ public final class PlaybackPerformanceDialog extends DialogFragment { button.setFocusable(true); button.setFocusableInTouchMode(Util.isLeanback()); button.setCornerRadius(dp(6)); - button.setTextColor(ColorStateList.valueOf(ThemeController.current().colorPrimary())); - button.setBackgroundTintList(ColorStateList.valueOf(ThemeController.current().colorSurfaceContainerLowest())); - button.setStrokeColor(ColorStateList.valueOf(ThemeController.current().colorOutline())); + button.setTextColor(ColorStateList.valueOf(Color.parseColor("#174EA6"))); + button.setBackgroundTintList(ColorStateList.valueOf(Color.WHITE)); + button.setStrokeColor(ColorStateList.valueOf(Color.parseColor("#8AB4F8"))); button.setStrokeWidth(dp(1)); button.setOnFocusChangeListener((view, hasFocus) -> styleAction(button, hasFocus)); button.setOnClickListener(listener); @@ -327,16 +324,15 @@ public final class PlaybackPerformanceDialog extends DialogFragment { button.setFocusable(true); button.setFocusableInTouchMode(Util.isLeanback()); button.setBackgroundTintList(ContextCompat.getColorStateList(requireContext(), R.color.dialog_outlined_button_bg)); - button.setTextColor(ThemeController.current().colorOnSurfaceVariant()); + button.setTextColor(Color.parseColor("#5F6368")); button.setOnClickListener(listener); return button; } private void styleAction(MaterialButton button, boolean focused) { - ThemeTokens tokens = ThemeController.current(); - button.setTextColor(ColorStateList.valueOf(focused ? tokens.colorOnPrimary() : tokens.colorPrimary())); - button.setBackgroundTintList(ColorStateList.valueOf(focused ? tokens.colorPrimary() : tokens.colorSurfaceContainerLowest())); - button.setStrokeColor(ColorStateList.valueOf(focused ? tokens.colorPrimary() : tokens.colorOutline())); + button.setTextColor(ColorStateList.valueOf(Color.parseColor(focused ? "#FFFFFF" : "#174EA6"))); + button.setBackgroundTintList(ColorStateList.valueOf(Color.parseColor(focused ? "#1A73E8" : "#FFFFFF"))); + button.setStrokeColor(ColorStateList.valueOf(Color.parseColor(focused ? "#1A73E8" : "#8AB4F8"))); button.setStrokeWidth(dp(1)); } @@ -366,8 +362,8 @@ public final class PlaybackPerformanceDialog extends DialogFragment { tabs.setBackgroundColor(Color.TRANSPARENT); tabs.setTabMode(TabLayout.MODE_FIXED); tabs.setTabGravity(TabLayout.GRAVITY_FILL); - tabs.setSelectedTabIndicatorColor(ThemeController.current().colorPrimary()); - tabs.setTabTextColors(ThemeController.current().colorOnSurfaceVariant(), ThemeController.current().colorPrimary()); + tabs.setSelectedTabIndicatorColor(Color.parseColor("#1A73E8")); + tabs.setTabTextColors(Color.parseColor("#5F6368"), Color.parseColor("#1A73E8")); tabs.setTabRippleColor(ColorStateList.valueOf(Color.TRANSPARENT)); tabs.setUnboundedRipple(false); tabs.setFocusable(false); @@ -474,10 +470,6 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void refreshRows() { if (list == null) return; - View focused = list.findFocus(); - Object focusedOption = focused == null ? null : focused.getTag(); - ScrollView scroll = (ScrollView) list.getParent(); - int scrollY = scroll.getScrollY(); list.removeAllViews(); PlaybackPerformanceUiPolicy.Split split = optionSplit(); addHeader(getString(R.string.player_performance_common_section)); @@ -494,13 +486,6 @@ public final class PlaybackPerformanceDialog extends DialogFragment { addRow(option.id(), option.title(), optionValue(option.id()), optionAction(option.id())); } - // Row indices can change when a setting reveals another option. - if (focusedOption != null) { - View row = list.findViewWithTag(focusedOption); - if (row != null) row.requestFocus(); - } - // Restore after layout so ScrollView's focus handling cannot move us. - OneShotPreDrawListener.add(scroll, () -> scroll.scrollTo(0, scrollY)); } private void showConfirmDialog( @@ -567,12 +552,11 @@ public final class PlaybackPerformanceDialog extends DialogFragment { case PlaybackPerformanceCatalog.DECODER_FALLBACK -> onOff(PlaybackPerformanceSetting.isDecoderFallbackEnabled()); case PlaybackPerformanceCatalog.DV7_HDR10_FALLBACK -> PlayerSetting.getPlayer() == PlayerSetting.MPV - ? PlaybackPerformanceSetting.getMpvDv7HandlingText() + ? onOff(PlaybackPerformanceSetting.isDv7Hdr10FallbackEnabled()) : PlaybackPerformanceSetting.getDv7HandlingText(); case PlaybackPerformanceCatalog.DEFERRED_CUES -> onOff(PlaybackPerformanceSetting.isDeferredCuesEnabled()); case PlaybackPerformanceCatalog.SOFT_VIDEO_TUNE -> onOff(PlaybackPerformanceSetting.isSoftVideoTuneEnabled()); case PlaybackPerformanceCatalog.AUDIO_PASSTHROUGH -> onOff(PlayerSetting.isAudioPassThrough()); - case PlaybackPerformanceCatalog.MPV_MULTICHANNEL_AUDIO -> MpvPerformanceSetting.getMultichannelAudioText(); case PlaybackPerformanceCatalog.PREFER_AAC -> onOff(PlayerSetting.isPreferAAC()); case PlaybackPerformanceCatalog.AUDIO_SOFT_PREFER -> onOff(PlayerSetting.isAudioPrefer()); case PlaybackPerformanceCatalog.VIDEO_SOFT_PREFER -> onOff(PlayerSetting.isVideoPrefer()); @@ -588,6 +572,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { case PlaybackPerformanceCatalog.MPV_FRAME_DROP -> MpvPerformanceSetting.getFrameDropText(); case PlaybackPerformanceCatalog.MPV_INTERPOLATION -> onOff(MpvPerformanceSetting.isInterpolation()); case PlaybackPerformanceCatalog.MPV_SOFT_TUNE -> MpvPerformanceSetting.getSoftTuneText(); + case PlaybackPerformanceCatalog.MPV_VERBOSE_LOG -> MpvPerformanceSetting.isVerboseLog() ? "详细" : "正常"; case PlaybackPerformanceCatalog.IJK_SCENE -> IjkPerformanceSetting.getSceneText(); case PlaybackPerformanceCatalog.IJK_BUFFER -> ijkBufferText(); case PlaybackPerformanceCatalog.IJK_PACKET_BUFFERING -> onOff(IjkPerformanceSetting.isPacketBuffering()); @@ -649,10 +634,8 @@ public final class PlaybackPerformanceDialog extends DialogFragment { case PlaybackPerformanceCatalog.DECODER_FALLBACK -> () -> toggle(PlaybackPerformanceSetting::isDecoderFallbackEnabled, PlaybackPerformanceSetting::putDecoderFallbackEnabled); case PlaybackPerformanceCatalog.DV7_HDR10_FALLBACK -> () -> { if (PlayerSetting.getPlayer() == PlayerSetting.MPV) { - int mode = PlaybackPerformanceSetting.getMpvDv7HandlingMode(); - PlaybackPerformanceSetting.putMpvDv7HandlingMode( - PlaybackPerformanceSetting.nextMpvDv7HandlingMode(mode)); - refresh(); + toggle(PlaybackPerformanceSetting::isDv7Hdr10FallbackEnabled, + PlaybackPerformanceSetting::putDv7Hdr10FallbackEnabled); return; } int mode = PlaybackPerformanceSetting.getDv7HandlingMode(); @@ -665,13 +648,6 @@ public final class PlaybackPerformanceDialog extends DialogFragment { case PlaybackPerformanceCatalog.DEFERRED_CUES -> () -> toggle(PlaybackPerformanceSetting::isDeferredCuesEnabled, PlaybackPerformanceSetting::putDeferredCuesEnabled); case PlaybackPerformanceCatalog.SOFT_VIDEO_TUNE -> () -> toggle(PlaybackPerformanceSetting::isSoftVideoTuneEnabled, PlaybackPerformanceSetting::putSoftVideoTuneEnabled); case PlaybackPerformanceCatalog.AUDIO_PASSTHROUGH -> () -> togglePlayer(id, PlayerSetting::isAudioPassThrough, PlayerSetting::putAudioPassThrough); - case PlaybackPerformanceCatalog.MPV_MULTICHANNEL_AUDIO -> () -> { - MpvPerformanceSetting.putMultichannelAudioMode( - MpvPerformanceSetting.isMultichannelPcm() - ? MpvPerformanceSetting.MULTICHANNEL_STEREO_COMPAT - : MpvPerformanceSetting.MULTICHANNEL_PCM); - refresh(); - }; case PlaybackPerformanceCatalog.PREFER_AAC -> () -> togglePlayer(id, PlayerSetting::isPreferAAC, PlayerSetting::putPreferAAC); case PlaybackPerformanceCatalog.AUDIO_SOFT_PREFER -> () -> togglePlayer(id, PlayerSetting::isAudioPrefer, PlayerSetting::putAudioPrefer); case PlaybackPerformanceCatalog.VIDEO_SOFT_PREFER -> () -> togglePlayer(id, PlayerSetting::isVideoPrefer, PlayerSetting::putVideoPrefer); @@ -725,6 +701,10 @@ public final class PlaybackPerformanceDialog extends DialogFragment { MpvPerformanceSetting.putSoftTuneMode((MpvPerformanceSetting.getSoftTuneMode() + 1) % 3); refresh(); }; + case PlaybackPerformanceCatalog.MPV_VERBOSE_LOG -> () -> { + MpvPerformanceSetting.putVerboseLog(!MpvPerformanceSetting.isVerboseLog()); + refresh(); + }; case PlaybackPerformanceCatalog.IJK_SCENE -> () -> { IjkPerformanceSetting.putScene((IjkPerformanceSetting.getScene() + 1) % 4); refresh(); @@ -807,7 +787,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void addHeader(String text) { MaterialTextView header = new MaterialTextView(requireContext()); header.setText(text); - header.setTextColor(ThemeController.current().colorOnSurfaceVariant()); + header.setTextColor(Color.parseColor("#5F6368")); header.setTextSize(13); header.setGravity(Gravity.CENTER_VERTICAL); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(28)); @@ -819,7 +799,6 @@ public final class PlaybackPerformanceDialog extends DialogFragment { boolean overridden = PlaybackPerformanceSetting.isOverridden( PlayerSetting.getPlayer(), id); MaterialButton button = new MaterialButton(requireContext()); - button.setTag(id); button.setAllCaps(false); button.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); button.setSingleLine(false); @@ -828,12 +807,12 @@ public final class PlaybackPerformanceDialog extends DialogFragment { button.setInsetBottom(0); button.setText(label + " " + value); button.setTextSize(14); - button.setTextColor(ColorStateList.valueOf(ThemeController.current().colorOnSurface())); + button.setTextColor(ColorStateList.valueOf(Color.parseColor("#202124"))); button.setBackgroundTintList(ColorStateList.valueOf(overridden - ? ThemeController.current().colorPrimaryContainer() : ThemeController.current().colorSurfaceContainerLowest())); + ? Color.parseColor("#E8F0FE") : Color.WHITE)); button.setCornerRadius(dp(6)); - button.setStrokeColor(ColorStateList.valueOf(overridden - ? ThemeController.current().colorPrimary() : ThemeController.current().colorOutlineVariant())); + button.setStrokeColor(ColorStateList.valueOf(Color.parseColor(overridden + ? "#8AB4F8" : "#C4C7C5"))); button.setStrokeWidth(dp(1)); button.setFocusable(true); button.setFocusableInTouchMode(Util.isLeanback()); @@ -848,14 +827,12 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void styleRow(MaterialButton button, boolean enabled, boolean overridden, boolean focused) { - ThemeTokens tokens = ThemeController.current(); - int text = focused ? tokens.colorOnPrimary() - : enabled ? tokens.colorOnSurface() : tokens.colorOnSurfaceVariant(); - int bg = focused ? tokens.colorPrimary() - : overridden ? tokens.colorPrimaryContainer() : tokens.colorSurfaceContainerLowest(); - int stroke = focused ? tokens.colorPrimary() - : overridden ? tokens.colorPrimary() - : tokens.colorOutlineVariant(); + int text = focused ? Color.WHITE : enabled ? Color.parseColor("#202124") : Color.parseColor("#5F6368"); + int bg = focused ? Color.parseColor("#1A73E8") + : overridden ? Color.parseColor("#E8F0FE") : Color.WHITE; + int stroke = focused ? Color.parseColor("#1A73E8") + : overridden ? Color.parseColor("#8AB4F8") + : Color.parseColor("#C4C7C5"); button.setTextColor(ColorStateList.valueOf(text)); button.setBackgroundTintList(ColorStateList.valueOf(bg)); button.setStrokeColor(ColorStateList.valueOf(stroke)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceModal.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceModal.java index 48f12b9876..d7f2a960d0 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceModal.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceModal.java @@ -80,7 +80,7 @@ final class PlaybackPerformanceModal { private static Shell createShell( Context context, CharSequence title) { - Dialog dialog = new Dialog(context, R.style.Theme_WebHTV_Dialog); + Dialog dialog = new Dialog(context, R.style.Theme_WebHTV_LightDialog); LinearLayout root = new LinearLayout(context); root.setOrientation(LinearLayout.VERTICAL); root.setBackgroundResource(R.drawable.shape_shell_proxy_dialog); @@ -109,7 +109,7 @@ final class PlaybackPerformanceModal { ScrollView scroll = new MaxHeightScrollView( context, Math.min(dp(context, 460), - DialogSafeArea.height(context) * 3 / 5)); + ResUtil.getScreenHeight(context) * 3 / 5)); scroll.setFillViewport(false); scroll.setOverScrollMode(View.OVER_SCROLL_NEVER); LinearLayout body = new LinearLayout(context); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackRemoteSyncDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackRemoteSyncDialog.java index 204bc958be..f7d7351e37 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackRemoteSyncDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackRemoteSyncDialog.java @@ -30,8 +30,6 @@ import com.fongmi.android.tv.playback.PlaybackRemoteSyncResult; import com.fongmi.android.tv.playback.PlaybackRemoteSyncStore; import com.fongmi.android.tv.playback.PlaybackRemoteSyncer; import com.fongmi.android.tv.playback.RemoteSyncConfig; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.utils.Formatters; import com.fongmi.android.tv.utils.Notify; @@ -68,7 +66,7 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -78,8 +76,8 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = DialogSafeArea.width(requireActivity(), window); - int screenHeight = DialogSafeArea.height(requireActivity(), window); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); @@ -164,7 +162,7 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { LinearLayoutCompat root = new LinearLayoutCompat(requireContext()); root.setOrientation(LinearLayoutCompat.VERTICAL); root.setPadding(dp(10), dp(9), dp(10), dp(9)); - root.setBackground(rowBackground()); + root.setBackground(rowBackground(config)); root.setFocusable(true); root.setClickable(true); root.setOnClickListener(view -> showEdit(copy(config))); @@ -176,12 +174,12 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { header.setGravity(Gravity.CENTER_VERTICAL); header.setOrientation(LinearLayoutCompat.HORIZONTAL); root.addView(header, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - header.addView(text(config.displayName(), 15, ThemeController.current().colorOnSurface(), true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + header.addView(text(config.displayName(), 15, Color.BLACK, true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); header.addView(badge(status(config), statusColor(config))); addDetail(root, config.url); addDetail(root, meta(config)); - if (!TextUtils.isEmpty(config.lastError)) addDetail(root, getString(R.string.playback_remote_sync_last_error, config.lastError), ThemeController.current().colorError()); + if (!TextUtils.isEmpty(config.lastError)) addDetail(root, getString(R.string.playback_remote_sync_last_error, config.lastError), Color.parseColor("#B3261E")); LinearLayoutCompat actions = new LinearLayoutCompat(requireContext()); actions.setGravity(Gravity.CENTER_VERTICAL); @@ -336,8 +334,8 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { } private int statusColor(RemoteSyncConfig config) { - if (config.enabled && config.isUsable()) return ThemeController.current().colorSuccess(); - return ThemeController.current().colorOnSurfaceVariant(); + if (config.enabled && config.isUsable()) return Color.parseColor("#137333"); + return Color.parseColor("#5F6368"); } private RemoteSyncConfig copy(RemoteSyncConfig source) { @@ -425,7 +423,7 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { button.setMinimumHeight(dp(36)); button.setPadding(dp(6), 0, dp(6), 0); ColorStateList bg = ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_bg : R.color.dialog_outlined_button_bg); - ColorStateList fg = danger ? ColorStateList.valueOf(ThemeController.current().colorError()) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); + ColorStateList fg = danger ? ColorStateList.valueOf(Color.parseColor("#B3261E")) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); button.setBackgroundTintList(bg); button.setTextColor(fg); if (!tonal) { @@ -436,7 +434,7 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { } private void addDetail(LinearLayoutCompat root, String value) { - addDetail(root, value, ThemeController.current().colorOnSurfaceVariant()); + addDetail(root, value, Color.parseColor("#5F6368")); } private void addDetail(LinearLayoutCompat root, String value, int color) { @@ -457,33 +455,23 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { return view; } - /** - * The badge fill follows the palette so the outlined status chip stays legible in both - * tables. It was a fixed {@code #FFFFFF}, which under the night table drew a white chip - * with the night success/error text on it. - */ private MaterialTextView badge(String value, int color) { MaterialTextView view = text(value, 12, color, true); view.setGravity(Gravity.CENTER); view.setSingleLine(true); view.setPadding(dp(8), dp(3), dp(8), dp(3)); GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(ThemeController.current().colorSurfaceContainerLowest()); + drawable.setColor(Color.parseColor("#FFFFFF")); drawable.setStroke(dp(1), color); drawable.setCornerRadius(dp(6)); view.setBackground(drawable); return view; } - /** - * The row card fill and stroke follow the active palette; see - * {@code ViewingRecordSyncDialog.rowBackground} for why the fixed light literals could - * not be rescued by {@code ThemeBinder}. - */ - private GradientDrawable rowBackground() { + private GradientDrawable rowBackground(RemoteSyncConfig config) { GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(ThemeController.current().colorSurfaceContainerLow()); - drawable.setStroke(dp(1), ThemeController.current().colorOutlineVariant()); + drawable.setColor(Color.parseColor("#F5F6F7")); + drawable.setStroke(dp(1), Color.parseColor(config.isUsable() ? "#DADCE0" : "#E2E5E8")); drawable.setCornerRadius(dp(6)); return drawable; } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackSpeedDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackSpeedDialog.java deleted file mode 100644 index a49d2eedf0..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackSpeedDialog.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import androidx.fragment.app.FragmentActivity; - -import com.fongmi.android.tv.R; - -import java.util.Arrays; -import java.util.Locale; - -/** Playback shortcut speeds, presented using the same chooser as player kernels. */ -public final class PlaybackSpeedDialog { - - public interface Listener { - void onSelected(float speed); - } - - private PlaybackSpeedDialog() { - } - - public static void show(FragmentActivity activity, float current, Listener listener) { - if (activity.isFinishing() || activity.isDestroyed() - || activity.getSupportFragmentManager().isStateSaved()) return; - Choices choices = choices(current); - ChoiceDialog.showSingleNoCancel(activity, R.string.setting_play_speed, - choices.labels(), choices.selected(), which -> listener.onSelected(choices.speeds()[which])); - } - - static Choices choices(float current) { - float[] speeds = {0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, 2.5f, 3f, 5f}; - // Keep a speed selected through the slider or remote fine adjustment visible. - if (current >= 0.25f && current <= 5f && selectedIndex(speeds, current) < 0) { - speeds = Arrays.copyOf(speeds, speeds.length + 1); - speeds[speeds.length - 1] = current; - Arrays.sort(speeds); - } - String[] labels = new String[speeds.length]; - for (int i = 0; i < speeds.length; i++) labels[i] = String.format(Locale.US, "%.2fx", speeds[i]); - return new Choices(speeds, labels, selectedIndex(speeds, current)); - } - - private static int selectedIndex(float[] speeds, float current) { - for (int i = 0; i < speeds.length; i++) if (Math.abs(speeds[i] - current) < 0.001f) return i; - return -1; - } - - record Choices(float[] speeds, String[] labels, int selected) { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackWebhookDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackWebhookDialog.java index 12a34a292e..086718b081 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackWebhookDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackWebhookDialog.java @@ -28,8 +28,6 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogPlaybackWebhookBinding; import com.fongmi.android.tv.playback.PlaybackWebhookStore; import com.fongmi.android.tv.playback.WebhookConfig; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; @@ -93,7 +91,7 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -103,8 +101,8 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = DialogSafeArea.width(requireActivity(), window); - int screenHeight = DialogSafeArea.height(requireActivity(), window); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); @@ -216,14 +214,14 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { header.setOrientation(LinearLayoutCompat.HORIZONTAL); root.addView(header, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - MaterialTextView title = text(config.displayName(), 15, ThemeController.current().colorOnSurface(), true); + MaterialTextView title = text(config.displayName(), 15, Color.BLACK, true); header.addView(title, new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); MaterialTextView status = badge(status(config), statusColor(config)); header.addView(status); addDetail(root, config.url); addDetail(root, meta(config)); - if (config.suspended && !TextUtils.isEmpty(config.lastError)) addDetail(root, getString(R.string.playback_webhook_last_error, config.lastError), ThemeController.current().colorError()); + if (config.suspended && !TextUtils.isEmpty(config.lastError)) addDetail(root, getString(R.string.playback_webhook_last_error, config.lastError), Color.parseColor("#B3261E")); LinearLayoutCompat actions = new LinearLayoutCompat(requireContext()); actions.setGravity(Gravity.CENTER_VERTICAL); @@ -433,9 +431,9 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { } private int statusColor(WebhookConfig config) { - if (config.suspended) return ThemeController.current().colorError(); - if (config.enabled && config.isUsable()) return ThemeController.current().colorSuccess(); - return ThemeController.current().colorOnSurfaceVariant(); + if (config.suspended) return Color.parseColor("#B3261E"); + if (config.enabled && config.isUsable()) return Color.parseColor("#137333"); + return Color.parseColor("#5F6368"); } private WebhookConfig copy(WebhookConfig source) { @@ -530,7 +528,7 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { button.setMinimumHeight(dp(36)); button.setPadding(dp(6), 0, dp(6), 0); ColorStateList bg = ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_bg : R.color.dialog_outlined_button_bg); - ColorStateList fg = danger ? ColorStateList.valueOf(ThemeController.current().colorError()) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); + ColorStateList fg = danger ? ColorStateList.valueOf(Color.parseColor("#B3261E")) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); button.setBackgroundTintList(bg); button.setTextColor(fg); if (!tonal) { @@ -541,7 +539,7 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { } private void addDetail(LinearLayoutCompat root, String value) { - addDetail(root, value, ThemeController.current().colorOnSurfaceVariant()); + addDetail(root, value, Color.parseColor("#5F6368")); } private void addDetail(LinearLayoutCompat root, String value, int color) { @@ -562,40 +560,23 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { return view; } - /** - * The badge fill follows the palette so the outlined status chip stays legible in both - * tables; see {@code PlaybackRemoteSyncDialog.badge}. - */ private MaterialTextView badge(String value, int color) { MaterialTextView view = text(value, 12, color, true); view.setGravity(Gravity.CENTER); view.setSingleLine(true); view.setPadding(dp(8), dp(3), dp(8), dp(3)); GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(ThemeController.current().colorSurfaceContainerLowest()); + drawable.setColor(Color.parseColor("#FFFFFF")); drawable.setStroke(dp(1), color); drawable.setCornerRadius(dp(6)); view.setBackground(drawable); return view; } - /** - * A suspended row is the one row that must read as "attention" rather than "normal", so - * it keeps a distinct fill - now the semantic error container ({@code #F9DEDC} day / - * {@code #93000A} night) instead of a fixed light pink that only worked in the day table. - * Its stroke is the paired {@code colorError}, as in {@code CustomCspDialog.rowBackground}, - * so the cue survives for a viewer who cannot separate the two fills by hue: the fills - * differ by only 1.15:1 in the day table. Every other row takes the shared semantic - * hairline; see {@code ViewingRecordSyncDialog.rowBackground}. - */ private GradientDrawable rowBackground(WebhookConfig config) { GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(config.suspended - ? ThemeController.current().colorErrorContainer() - : ThemeController.current().colorSurfaceContainerLow()); - drawable.setStroke(dp(1), config.suspended - ? ThemeController.current().colorError() - : ThemeController.current().colorOutlineVariant()); + drawable.setColor(Color.parseColor(config.suspended ? "#FFF7F7" : "#F5F6F7")); + drawable.setStroke(dp(1), Color.parseColor(config.isUsable() ? "#DADCE0" : "#E2E5E8")); drawable.setCornerRadius(dp(6)); return drawable; } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlayerButtonConfigDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlayerButtonConfigDialog.java index 035c71e4f8..0d30420dff 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlayerButtonConfigDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlayerButtonConfigDialog.java @@ -21,9 +21,6 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterPlayerButtonConfigBinding; import com.fongmi.android.tv.databinding.DialogPlayerButtonConfigBinding; import com.fongmi.android.tv.setting.PlayerButtonSetting; -import com.fongmi.android.tv.theme.PlayerSettingsRowTheme; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -56,7 +53,7 @@ public class PlayerButtonConfigDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -66,8 +63,8 @@ public class PlayerButtonConfigDialog extends BaseAlertDialog { if (dialog == null) return; Window window = dialog.getWindow(); if (window == null) return; - int screenWidth = DialogSafeArea.width(requireActivity(), window); - int screenHeight = DialogSafeArea.height(requireActivity(), window); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); boolean land = ResUtil.isLand(requireContext()); WindowManager.LayoutParams params = window.getAttributes(); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); @@ -170,8 +167,8 @@ public class PlayerButtonConfigDialog extends BaseAlertDialog { holder.binding.up.setFocusable(position > 0); holder.binding.down.setEnabled(position < items.size() - 1); holder.binding.down.setFocusable(position < items.size() - 1); - PlayerSettingsRowTheme.apply(holder.binding.root, holder.binding.name, ThemeController.current(), item.visible()); - PlayerSettingsRowTheme.applySwitch(holder.binding.visible, ThemeController.current()); + holder.binding.root.setAlpha(1f); + holder.binding.name.setAlpha(item.visible() ? 1f : 0.55f); holder.binding.root.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition(), R.id.visible)); holder.binding.visible.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition(), view.getId())); holder.binding.up.setOnClickListener(view -> move(-1, holder.getBindingAdapterPosition(), view.getId())); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PluginPanelDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PluginPanelDialog.java deleted file mode 100644 index 4e8d2a1db7..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PluginPanelDialog.java +++ /dev/null @@ -1,427 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.content.DialogInterface; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.webkit.WebView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.databinding.DialogPluginPanelBinding; -import com.fongmi.android.tv.ui.activity.PlaybackActivity; -import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Util; -import com.fongmi.android.tv.utils.WebViewUtil; - -/** - * 插件面板:把插件脚本返回的 html 渲染在一块侧滑面板里。 - * - *

为什么是侧滑而不是底部弹层。电视端控制条在屏幕下缘,底部弹层会盖住刚点过的 chip, - * 关闭后焦点也没有落点;侧滑面板从右侧推进,控制条始终可见,关闭后焦点回到原 chip 是自然的。 - * 这与 {@code docs/PLUGIN-01} §8.2 的交互裁定一致。 - * - *

只做容器,不做内容。面板不解析、不注入、不拼接插件返回的 html,直接用 - * {@code loadDataWithBaseURL} 交给 WebView。任何"宿主帮忙加工 html"的逻辑都会变成 - * 第二套模板系统,而插件作者已经有完整的 html/css 能力。 - */ -public final class PluginPanelDialog extends BaseSideSheetDialog { - - private static final String TAG = "plugin-panel"; - private static final String ARG_RESUME_PARENT = "resumeParent"; - /** 请求标识也要进 Bundle:重建走无参构造器,字段不会重放,重建实例会拿不到自己的 token。 */ - private static final String ARG_TOKEN = "token"; - /** 标题要进 Bundle:重建后标题栏不能退回占位文案。 */ - private static final String ARG_TITLE = "title"; - /** - * 内容要进 Bundle,且 {@code null} 本身就是语义(还在等结果)。 - * - *

真正的重建来源是进程被回收后恢复,以及 fontScale / density / locale 变化—— - * 这两类都会把 Activity 连同 Fragment 一起重建(旋屏不会:宿主页的 {@code configChanges} - * 已声明 orientation|screenSize)。只看字段,重建实例会以为从未填过正文,面板永久转圈。 - */ - private static final String ARG_HTML = "html"; - /** - * 正文没有进 Bundle 的标记(与 {@link #ARG_HTML} 互斥)。 - * - *

重建后读到这个标记就说明"内容不可恢复",面板只能关掉,见 {@link #initView}。 - */ - private static final String ARG_HTML_OVERSIZED = "htmlOversized"; - /** - * 允许写进 arguments 的正文上限,单位是**字符数**。 - * - *

为什么按字符数定,而不是字节数。Bundle 走 Binder 事务时的体积估算要做一次 - * UTF-8 编码,而那正是我们要避免的开销与不确定性;`String.length()` 在写入之前就能拿到, - * 且是最坏情况下(每字符 2 字节 UTF-16 + Parcel 长度前缀)体积的单调上界,判起来零成本。 - * - *

为什么是 32K 这个量级。整份 savedInstanceState 必须在一个 Binder 事务里过 - * (系统预算约 1MB,实测 500KB 上下就开始 {@code TransactionTooLargeException})。32K 字符 - * 约 64KB,既远低于该阈值,也给同一份 Bundle 里的 FragmentManager 状态留了余量;再放大一个 - * 量级就会把"用户按 Home"变成崩溃点。 - * - *

它和网络层的上限是两件事。{@code PluginNetBridge.MAX_RESPONSE_BYTES} 限的是**单次 - * HTTP 响应** 512KB,而插件可以多次请求后自行拼接,动作返回值本身没有长度上限。这条常量是 - * **宿主给 arguments 体积定的上界**,与那 512KB 没有推导关系,不要互相替换。 - */ - private static final int MAX_PERSISTED_HTML_CHARS = 32_768; - - @Nullable - private String title; - @Nullable - private String html; - @Nullable - private DialogPluginPanelBinding binding; - @Nullable - private WebView webView; - @Nullable - private Runnable onClosed; - /** 本次请求标识:用于把异步回来的结果配到打开它的那个面板上,防止串台。 */ - @Nullable - private String token; - private boolean resumeParent; - private boolean parentResumed; - /** 超限面板只关一次:root view 与主线程两条投递可能都会跑到,靠这个标记收口。 */ - private boolean oversizedShellClosing; - - /** 无收尾回调的入口:等价于四参版本传入 {@code null}。 */ - public static void show(@Nullable FragmentActivity activity, @Nullable String title, @Nullable String html) { - show(activity, title, html, null); - } - - /** - * 打开面板并先显示加载中,数据由随后的 {@link #fill} 填入。 - * - *

为什么要先把面板打开。插件一次调用最长 5 秒,豆瓣搜索+详情+短评通常 1~3 秒。 - * 原先的实现是"结果回来才弹面板",这段空白里用户点了 chip 看不到任何变化,会以为没点上, - * 于是连点——而那正好又叠加出多个面板。先开壳既给了即时反馈,也天然抑制了连点。 - * - *

若同一页已有面板在显示,本方法复用那个实例(不新开第二个),把它切回加载态, - * 并换发一个新 token:旧 token 从此失效,上一次未完成的结果回来时会被丢弃。 - * - * @return 本次请求的标识,交给 {@link #fill} 配对;{@code null} 表示面板没能打开 - */ - @Nullable - public static String showLoading(@Nullable FragmentActivity activity, @Nullable String title, - @Nullable Runnable onClosed) { - if (activity == null || activity.isFinishing()) return null; - String token = Long.toString(System.nanoTime()); - PluginPanelDialog existing = find(activity); - if (existing != null) { - existing.token = token; - // 复用必须把面板真正切回"等结果":fill(title, null) 会清掉旧内容并同步进 Bundle, - // 空结果回来时 dismissLoading 才认得出这是个壳(留着旧 html 它就永远撤不掉壳)。 - existing.fill(title, null); - return token; - } - if (activity.getSupportFragmentManager().isStateSaved()) return null; - PlaybackActivity playback = activity instanceof PlaybackActivity ? (PlaybackActivity) activity : null; - boolean resumeParent = playback != null && playback.pauseForOverlayPlayback(); - PluginPanelDialog dialog = new PluginPanelDialog().bind(title, null, onClosed); - dialog.token = token; - Bundle args = new Bundle(); - args.putBoolean(ARG_RESUME_PARENT, resumeParent); - dialog.setArguments(args); - dialog.persistState(); - try { - dialog.show(activity.getSupportFragmentManager(), TAG); - } catch (RuntimeException e) { - if (playback != null) playback.resumeAfterOverlayPlayback(resumeParent); - return null; - } - return token; - } - - /** - * 把结果填进由 {@link #showLoading} 打开的那个面板。 - * - *

token 不匹配就什么都不做:用户可能已经关掉面板、或又点了另一个 chip, - * 这时把上一次的结果灌进来等于串台。面板已不存在时同样返回。 - */ - public static void fill(@Nullable FragmentActivity activity, @Nullable String token, @Nullable String html) { - if (activity == null || token == null || html == null || html.isEmpty()) return; - PluginPanelDialog existing = find(activity); - if (existing == null || !token.equals(existing.token)) return; - existing.fill(null, html); - } - - /** - * 撤销一个仍在等待结果的加载壳。 - * - *

插件返回空(抓取失败、被隔离、运行时抛错)时必须走这里:留着转圈的面板比没有面板 - * 更糟,用户会一直等一个永远不会来的结果。 - */ - public static void dismissLoading(@Nullable FragmentActivity activity, @Nullable String token) { - if (activity == null || token == null) return; - PluginPanelDialog existing = find(activity); - if (existing == null || !token.equals(existing.token)) return; - // 已有内容的面板不撤:那不是"等结果"的壳,是用户正在读的页面。 - // 这条守卫能成立的前提是"切回加载态就一定清掉 html",见 fill()。 - if (existing.html != null) return; - try { - existing.dismissAllowingStateLoss(); - } catch (Throwable ignored) { - } - } - - @Nullable - private static PluginPanelDialog find(@Nullable FragmentActivity activity) { - if (activity == null) return null; - try { - Fragment fragment = activity.getSupportFragmentManager().findFragmentByTag(TAG); - return fragment instanceof PluginPanelDialog dialog && dialog.isAdded() ? dialog : null; - } catch (Throwable ignored) { - return null; - } - } - - /** - * 更新面板内容。{@code html} 为 {@code null} 表示切回加载态。 - * - *

为什么切回加载态要清掉 {@code this.html}。"还在等结果"与"已有内容"必须是两个 - * 可区分的事实:{@link #dismissLoading} 只撤前者。留着旧 html,复用面板后插件返回空时 - * 就会被当成"用户正在阅读",加载壳永久转圈。 - * - *

视图尚未建好(面板刚 show、{@code initView} 还没跑)时只更新字段, - * {@code initView} 会读到最新值。 - */ - private void fill(@Nullable String newTitle, @Nullable String html) { - if (newTitle != null) this.title = newTitle; - this.html = html; - persistState(); - DialogPluginPanelBinding current = binding; - if (current == null) return; - if (newTitle != null) current.pluginPanelTitle.setText(newTitle); - boolean loading = html == null; - current.pluginPanelLoading.setVisibility(loading ? View.VISIBLE : View.GONE); - current.pluginPanelWeb.setVisibility(loading ? View.INVISIBLE : View.VISIBLE); - if (!loading) { - WebView view = this.webView; - if (view != null) view.loadDataWithBaseURL(null, html, "text/html", "utf-8", null); - } - } - - /** - * 把 {@link #title} / {@link #html} / {@link #token} 同步进 arguments。 - * - *

为什么在每次变更时同步,而不是覆写 {@code onSaveInstanceState}。token 已经是 - * "写进 arguments"的写法,内容跟着它走只有一套语义;覆写还会和 DialogFragment 自己保存的 - * 对话框状态抢同一份 Bundle。 - * - *

正文超限时不写正文,只写 {@link #ARG_HTML_OVERSIZED}。arguments 会随 - * {@code onSaveInstanceState} 走 Binder 事务,几百 KB 的正文足以让宿主在用户退到后台时抛 - * {@code TransactionTooLargeException}。上限见 {@link #MAX_PERSISTED_HTML_CHARS};正文与标记 - * 严格互斥——写正文就清标记,写标记就一定不写正文,否则重建实例无法判断内容到底在不在。 - * - *

{@code html} 允许是 {@code null},并对 {@code null} 做显式写空:重建后 {@code initView} - * 要靠它区分"等结果的壳"和"有内容的页"。 - */ - private void persistState() { - Bundle args = getArguments(); - // 两个入口都在 setArguments 之后才走到这里,恢复实例的 arguments 也由系统还原: - // 为 null 只可能是调用方违约,此时放弃同步,不去碰可能已保存的 FragmentManager。 - if (args == null) return; - boolean oversized = html != null && html.length() > MAX_PERSISTED_HTML_CHARS; - args.putString(ARG_TOKEN, token); - args.putString(ARG_TITLE, title); - args.putString(ARG_HTML, oversized ? null : html); - args.putBoolean(ARG_HTML_OVERSIZED, oversized); - } - - /** - * 打开面板。 - * - *

Activity 正在结束或 FragmentManager 状态已保存时直接返回——此时提交事务会抛 - * {@code IllegalStateException},而插件动作是异步回来的,完全可能落在页面销毁之后。 - * - *

打开前先暂停宿主播放。面板是阅读界面:视频继续播会盖住声音、继续耗流量, - * 而用户此刻的注意力在短评上。恢复动作不在这里做,见 {@link #onDismiss}。 - * - *

为什么「是否要恢复」进 Bundle 而不是留字段。Fragment 被系统重建时走无参构造器 - * (进程被回收后恢复,以及 fontScale / density / locale 这类会重建 Activity 的变化;旋屏不会, - * 宿主页的 {@code configChanges} 已声明 orientation|screenSize),字段值不会被重放——重建后的 - * 实例会以为自己从未暂停过播放,关闭面板时静默跳过恢复,视频就永久停在暂停帧。 - * Bundle 活得比实例长,重建后仍在。 - * 恢复时也不持有 Activity 字段:持有一个 Activity 会让它在面板开着期间无法回收。 - * - * @param onClosed 面板关闭后的收尾动作(用来把焦点还给入口控件);可为 {@code null} - */ - public static void show(@Nullable FragmentActivity activity, @Nullable String title, @Nullable String html, - @Nullable Runnable onClosed) { - if (activity == null || activity.isFinishing()) return; - if (activity.getSupportFragmentManager().isStateSaved()) return; - PlaybackActivity playback = activity instanceof PlaybackActivity ? (PlaybackActivity) activity : null; - boolean resumeParent = playback != null && playback.pauseForOverlayPlayback(); - PluginPanelDialog dialog = new PluginPanelDialog().bind(title, html, onClosed); - Bundle args = new Bundle(); - args.putBoolean(ARG_RESUME_PARENT, resumeParent); - dialog.setArguments(args); - dialog.persistState(); - try { - dialog.show(activity.getSupportFragmentManager(), TAG); - } catch (RuntimeException e) { - // 事务提交失败等于面板从未出现:已经按下的暂停必须立刻还回去, - // 否则用户点了一下没反应,视频却停在那一帧再也不会自己动。 - if (playback != null) playback.resumeAfterOverlayPlayback(resumeParent); - } - } - - private PluginPanelDialog bind(@Nullable String title, @Nullable String html, @Nullable Runnable onClosed) { - this.title = title; - this.html = html; - this.onClosed = onClosed; - return this; - } - - /** - * 关闭后恢复播放,再执行收尾动作。 - * - *

为什么两条都在这里而不是 {@code onDestroyView}。用户按返回键、点关闭按钮、 - * 点面板外侧三条路径最终都会走到 {@code onDismiss},而 {@code onDestroyView} 只覆盖 - * 视图被销毁的情形;把恢复播放挂在视图销毁上,任何「只销毁视图不 dismiss」的路径都会 - * 让播放永久停在暂停态。 - * - *

为什么先恢复播放再还焦点。焦点归还可能立刻打开另一个遮罩,若顺序反过来, - * 用户会在视频仍暂停的那一瞬间看到焦点跳走。 - * - *

恢复只发一次({@code parentResumed} 守卫);Activity 已不在或正在因配置变更销毁时 - * 跳过——那时持有播放器的旧实例正在退出,不该由它把播放拉起来。 - * 播放页从当前的 {@code getActivity()} 现取,不从字段读:重建后的实例没有那个字段, - * 而「要不要恢复」已经由 Bundle 里的 {@code ARG_RESUME_PARENT} 带过来了。 - * 回调抛异常不让它冒出去——面板收尾失败不该比"焦点没还回去"更严重。 - */ - @Override - public void onDismiss(@NonNull DialogInterface dialog) { - super.onDismiss(dialog); - if (!parentResumed) { - parentResumed = true; - FragmentActivity activity = getActivity(); - if (activity != null && !activity.isChangingConfigurations() && activity instanceof PlaybackActivity) { - ((PlaybackActivity) activity).resumeAfterOverlayPlayback(resumeParent); - } - } - Runnable closed = onClosed; - onClosed = null; - if (closed == null) return; - try { - closed.run(); - } catch (Throwable ignored) { - } - } - - @NonNull - @Override - protected ViewBinding getBinding(@NonNull LayoutInflater inflater, @Nullable ViewGroup container) { - binding = DialogPluginPanelBinding.inflate(inflater, container, false); - return binding; - } - - @Override - protected int getWidth() { - int width = Math.min(ResUtil.dp2px(480), ResUtil.getScreenWidth(requireContext()) * 2 / 3); - return Math.max(width, ResUtil.dp2px(280)); - } - - /** - * 建视图、回填状态。 - * - *

正文超限时这个面板必须被关掉。超过 {@link #MAX_PERSISTED_HTML_CHARS} 的正文没有进 - * Bundle(见 {@link #persistState}),重建出来的实例手上什么都没有。此处只有两个选择:留一个 - * 永远转圈的空壳,或者关掉让用户重新点一次入口。选后者——**内容过大无法忠实恢复,宁可关掉 - * 让用户重新点击入口,也不留一个永远转圈的空壳,更不冒把宿主拖崩的风险**:用户重新点击只是 - * 一次操作,而空壳意味着他永远等不到结果。 - * - *

关掉的动作投递到 {@code binding.getRoot()} 上而不是当场执行:{@code initView} 跑在 - * {@code onViewCreated} 里,此刻提交事务会撞上尚未 added 的 Fragment。 - */ - @Override - protected void initView() { - // 重建会把视图再建一次,也正好是回读 Bundle 的时机:只有这里能保证新实例知道自己 - // 还欠一次「恢复播放」、自己的 token、以及手上那份内容是否已经到位。 - Bundle args = getArguments(); - boolean oversized = args != null && args.getBoolean(ARG_HTML_OVERSIZED, false); - resumeParent = args != null && args.getBoolean(ARG_RESUME_PARENT, false); - // 重建后字段不重放,只能从 Bundle 取回;否则重建实例的 token 为 null,结果回来时比不中, - // html 也为 null,有内容的面板会退回永久转圈。 - if (args != null) { - if (token == null) token = args.getString(ARG_TOKEN); - title = args.getString(ARG_TITLE); - // 只在 Bundle 真带回了正文时覆盖:超限实例的 Bundle 里没有正文,而同一实例仅重建视图 - // 的情形(字段仍然有效)不该被这个 null 抹掉。 - String restored = args.getString(ARG_HTML); - if (restored != null) html = restored; - } - if (binding == null) return; - binding.pluginPanelTitle.setText(title == null || title.isEmpty() ? getString(R.string.plugin_panel_title) : title); - binding.pluginPanelClose.setOnClickListener(view -> dismiss()); - WebView view = binding.pluginPanelWeb; - webView = view; - WebViewUtil.configureBase(view, TAG); - WebViewUtil.configureHome(view); - // html 为 null 表示"还等着异步结果":先把壳显示出来并转圈,等 fill() 再灌正文。 - boolean loading = html == null; - binding.pluginPanelLoading.setVisibility(loading ? View.VISIBLE : View.GONE); - view.setVisibility(loading ? View.INVISIBLE : View.VISIBLE); - if (!loading) view.loadDataWithBaseURL(null, html, "text/html", "utf-8", null); - // 面板是独立窗口:遥控器的方向键只看得到这个窗口里的控件,找不到任何"下一个"就会卡住。 - // 显式给出关闭按钮 ↔ 正文之间的上下通道,方向键才既出得去又回得来。 - if (Util.isLeanback()) { - binding.pluginPanelClose.setNextFocusDownId(R.id.pluginPanelWeb); - binding.pluginPanelWeb.setNextFocusUpId(R.id.pluginPanelClose); - } - // 正文超限:Bundle 里没有内容可恢复,这个壳永远等不到结果,投递关闭(理由见方法注释)。 - if (oversized && html == null) beginOversizedShellClose(binding.getRoot()); - } - - /** - * 投递"关掉这个内容不可恢复的面板"。 - * - *

投递而不是当场调用:{@code initView} 跑在 {@code onViewCreated} 里,此刻提交事务会撞上 - * {@code Fragment} 尚未 added 的时机。主路径挂在 root view 上——它在面板自己的窗口里,队列 - * 与面板同生命周期;但视图此刻通常还没 attach,而一个始终没 attach 的视图会把动作永久压在 - * 队列里,那就等于没投递,所以未 attach 时再补一次主线程投递。两条路径由幂等标记收口, - * 先到的那条生效,不会重复关闭。 - */ - private void beginOversizedShellClose(@NonNull View root) { - root.post(this::dismissOversizedShell); - if (!root.isAttachedToWindow()) new Handler(Looper.getMainLooper()).post(this::dismissOversizedShell); - } - - /** 关闭一个内容无法恢复的面板;投递执行时 Fragment 可能已被移除,失败不外抛。 */ - private void dismissOversizedShell() { - if (oversizedShellClosing) return; - oversizedShellClosing = true; - try { - dismissAllowingStateLoss(); - } catch (Throwable ignored) { - } - } - - /** - * 释放 WebView。 - * - *

WebView 持有原生渲染进程资源,不显式 destroy 会随面板次数累积。先切 - * {@code about:blank} 再 destroy,避免正在加载的页面在销毁瞬间回调到已解绑的视图。 - */ - @Override - public void onDestroyView() { - WebView view = webView; - webView = null; - if (view != null) { - try { - view.loadUrl("about:blank"); - view.destroy(); - } catch (Throwable ignored) { - } - } - binding = null; - super.onDestroyView(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/RemoteTrustDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/RemoteTrustDialog.java index 3a98e5dbd7..324a3584ac 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/RemoteTrustDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/RemoteTrustDialog.java @@ -26,7 +26,6 @@ import android.view.inputmethod.InputMethodManager; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.LinearLayoutCompat; import androidx.core.content.ContextCompat; -import androidx.core.view.ViewCompat; import androidx.core.widget.NestedScrollView; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; @@ -61,7 +60,6 @@ import com.fongmi.android.tv.remote.RemoteTokens; import com.fongmi.android.tv.bean.SyncOptions; import com.fongmi.android.tv.server.Server; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; import com.fongmi.android.tv.utils.QRCode; @@ -70,6 +68,7 @@ import com.fongmi.android.tv.utils.Task; import com.github.catvod.net.OkHttp; import com.google.android.material.button.MaterialButton; import com.google.android.material.button.MaterialButtonToggleGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; import com.google.android.material.textview.MaterialTextView; @@ -124,7 +123,7 @@ public final class RemoteTrustDialog { public static void show(FragmentActivity activity, Runnable callback) { Binding binding = build(activity); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) .setView(binding.root) .create(); binding.dialog = dialog; @@ -478,7 +477,7 @@ public final class RemoteTrustDialog { MaterialTextView hint = text(activity, activity.getString(R.string.remote_trust_send_server_hint), 12, "#5F6368", false); hint.setPadding(0, dp(activity, 8), 0, 0); root.addView(hint, matchWrap()); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) .setTitle(R.string.remote_trust_send_server_title) .setView(root) .setNegativeButton(R.string.dialog_cancel, null) @@ -869,7 +868,7 @@ public final class RemoteTrustDialog { code.setTextAlignment(View.TEXT_ALIGNMENT_CENTER); root.addView(code, matchWrap()); root.addView(caption(activity, R.string.remote_trust_bind_code_hint), topMargin(matchWrap(), 8)); - showModal(new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) + showModal(new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) .setTitle(R.string.remote_trust_bind_local_title) .setView(root) .setNegativeButton(R.string.dialog_cancel, null) @@ -934,7 +933,7 @@ public final class RemoteTrustDialog { return; } DialogRemoteTrustAddDeviceBinding dialogBinding = DialogRemoteTrustAddDeviceBinding.inflate(LayoutInflater.from(activity)); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) .setView(dialogBinding.getRoot()) .create(); dialogBinding.negative.setOnClickListener(v -> dialog.dismiss()); @@ -1150,7 +1149,7 @@ public final class RemoteTrustDialog { dialogBinding.inputLayout.setHint(activity.getString(hint)); dialogBinding.input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); dialogBinding.positive.setText("action.push".equals(type) ? R.string.remote_trust_send_push : R.string.remote_trust_send_search); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) .setView(dialogBinding.getRoot()) .create(); dialogBinding.negative.setOnClickListener(v -> dialog.dismiss()); @@ -1249,7 +1248,7 @@ public final class RemoteTrustDialog { state.contentScroll.addView(state.content, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); root.addView(state.contentScroll, topMargin(new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, configContentHeight(activity)), 10)); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) .setView(root) .create(); state.dialog = dialog; @@ -1311,7 +1310,7 @@ public final class RemoteTrustDialog { private static int configContentHeight(Context context) { boolean land = ResUtil.isLand(context); - int height = (int) (DialogSafeArea.height(context) * (land ? 0.44f : 0.34f)); + int height = (int) (ResUtil.getScreenHeight(context) * (land ? 0.44f : 0.34f)); return Math.max(dp(context, land ? 220 : 190), Math.min(dp(context, land ? 360 : 300), height)); } @@ -1755,8 +1754,6 @@ public final class RemoteTrustDialog { private static JsonObject configPayload(JsonObject object) { JsonObject payload = configPayload(payloadType(object), safe(object, "url"), safe(object, "name")); - if (object != null && object.has("interfaceKey")) payload.addProperty("interfaceKey", safe(object, "interfaceKey")); - if (object != null && object.has("urls")) payload.add("urls", object.get("urls")); if (object != null && object.has("homeKey")) payload.addProperty("homeKey", safe(object, "homeKey")); if (object != null && object.has("homeName")) payload.addProperty("homeName", safe(object, "homeName")); return payload; @@ -2170,7 +2167,7 @@ public final class RemoteTrustDialog { DialogRemoteTrustConfirmBinding dialogBinding = DialogRemoteTrustConfirmBinding.inflate(LayoutInflater.from(activity)); dialogBinding.title.setText(R.string.remote_trust_action_sync); dialogBinding.message.setText(activity.getString(R.string.remote_trust_sync_confirm, deviceName(selected.device))); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) .setView(dialogBinding.getRoot()) .create(); dialogBinding.negative.setOnClickListener(v -> dialog.dismiss()); @@ -2972,7 +2969,7 @@ public final class RemoteTrustDialog { DialogRemoteTrustQrBinding dialogBinding = DialogRemoteTrustQrBinding.inflate(LayoutInflater.from(activity)); dialogBinding.image.setImageBitmap(QRCode.getLightBitmap(server, 220, 1)); dialogBinding.value.setText(server); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) .setView(dialogBinding.getRoot()) .create(); dialogBinding.negative.setOnClickListener(v -> dialog.dismiss()); @@ -3088,7 +3085,7 @@ public final class RemoteTrustDialog { input.setSingleLine(singleLine); input.setTextSize(14); input.setMinHeight(dp(context, 46)); - ViewCompat.setImportantForAutofill(input, View.IMPORTANT_FOR_AUTOFILL_NO); + input.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO); input.setSelectAllOnFocus(false); input.setTextColor(Color.BLACK); input.setHintTextColor(Color.parseColor("#666666")); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShellProxyDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShellProxyDialog.java index 015e758c78..3941f7dd61 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShellProxyDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShellProxyDialog.java @@ -34,7 +34,6 @@ import com.fongmi.android.tv.databinding.DialogShellProxyBinding; import com.fongmi.android.tv.databinding.DialogShellProxyRuleEditBinding; import com.fongmi.android.tv.setting.ProxySetting; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.ui.custom.SettingClipboardOverlay; import com.fongmi.android.tv.utils.Notify; @@ -104,7 +103,7 @@ public class ShellProxyDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -114,8 +113,8 @@ public class ShellProxyDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = DialogSafeArea.width(requireActivity(), window); - int screenHeight = DialogSafeArea.height(requireActivity(), window); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShortDramaSourceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShortDramaSourceDialog.java index 21813c9768..9017fbea14 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShortDramaSourceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShortDramaSourceDialog.java @@ -16,9 +16,9 @@ import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.ShortDramaConfig; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.List; @@ -62,9 +62,7 @@ public class ShortDramaSourceDialog { // 初始化暂存数据 ShortDramaConfig config = ShortDramaConfig.objectFrom(Setting.getShortDramaConfig()); - tempEnabledRules = new ArrayList<>(config.isConfigured() - ? config.getEnabledSites() - : ShortDramaConfig.defaultRules()); + tempEnabledRules = new ArrayList<>(config.getEnabledSites()); tempDisabledSites = new ArrayList<>(config.getDisabledSites()); updateChipsDisplay(); @@ -79,7 +77,7 @@ public class ShortDramaSourceDialog { manageBtn.setOnClickListener(v -> showSiteManage()); resetBtn.setOnClickListener(v -> resetToDefault()); - dialog = new WebHtvAlertDialogBuilder(activity) + dialog = new MaterialAlertDialogBuilder(activity) .setTitle(R.string.setting_short_drama_source) .setView(view) .setPositiveButton(R.string.dialog_positive, (d, w) -> onSave()) @@ -98,7 +96,9 @@ public class ShortDramaSourceDialog { List sites = VodConfig.get().getSites().stream().filter(s -> s != null && !s.isEmpty()).toList(); if (sites.isEmpty()) return; - List enabledRules = new ArrayList<>(tempEnabledRules); + List enabledRules = tempEnabledRules.isEmpty() + ? List.of(ShortDramaConfig.defaultRulesText().split(";")) + : new ArrayList<>(tempEnabledRules); List disabledSites = new ArrayList<>(tempDisabledSites); String[] labels = new String[sites.size()]; @@ -112,7 +112,7 @@ public class ShortDramaSourceDialog { checked[i] = matchedByRule && !inBlacklist; } - new WebHtvAlertDialogBuilder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(R.string.dialog_short_drama_site_manage) .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> checked[which] = isChecked) .setPositiveButton(R.string.dialog_positive, (d, w) -> applySiteManage(sites, enabledRules, disabledSites, checked)) @@ -250,7 +250,9 @@ public class ShortDramaSourceDialog { enabledChips.removeAllViews(); disabledChips.removeAllViews(); - List enabledRules = tempEnabledRules; + List enabledRules = tempEnabledRules.isEmpty() + ? List.of(ShortDramaConfig.defaultRulesText().split(";")) + : tempEnabledRules; for (String rule : enabledRules) { if (TextUtils.isEmpty(rule)) continue; @@ -322,7 +324,6 @@ public class ShortDramaSourceDialog { private void resetToDefault() { tempEnabledRules.clear(); - tempEnabledRules.addAll(ShortDramaConfig.defaultRules()); tempDisabledSites.clear(); updateChipsDisplay(); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthDialog.java index 180309007f..f5143eb991 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthDialog.java @@ -12,7 +12,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogSiteHealthBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.button.MaterialButton; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -43,7 +42,7 @@ public class SiteHealthDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -55,7 +54,7 @@ public class SiteHealthDialog extends BaseAlertDialog { WindowManager.LayoutParams params = window.getAttributes(); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); - params.width = (int) (DialogSafeArea.width(requireActivity(), window) * (ResUtil.isLand(requireContext()) ? 0.44f : 0.92f)); + params.width = (int) (ResUtil.getScreenWidth(requireContext()) * (ResUtil.isLand(requireContext()) ? 0.44f : 0.92f)); params.height = WindowManager.LayoutParams.WRAP_CONTENT; window.setAttributes(params); window.setLayout(params.width, params.height); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialog.java index aa8a1cc1a2..2e92db18f2 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthReportDialog.java @@ -1,20 +1,15 @@ package com.fongmi.android.tv.ui.dialog; -import android.app.Dialog; import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.ColorDrawable; -import android.text.Editable; import android.text.TextUtils; -import android.text.TextWatcher; import android.text.format.DateFormat; -import android.view.Gravity; import android.view.View; import android.view.Window; import android.view.WindowManager; import android.widget.ProgressBar; -import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.LinearLayoutCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; @@ -24,9 +19,6 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.databinding.DialogSiteHealthReportBinding; import com.fongmi.android.tv.setting.SiteHealthStore; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeEditorUi; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.button.MaterialButton; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -34,7 +26,6 @@ import com.google.android.material.textview.MaterialTextView; import java.util.ArrayList; import java.util.List; -import java.util.Locale; public class SiteHealthReportDialog extends BaseAlertDialog { @@ -42,7 +33,6 @@ public class SiteHealthReportDialog extends BaseAlertDialog { private SiteHealthStore.Report report; private Filter filter = Filter.ALL; private Sort sort = Sort.RECENT; - private String query = ""; public static void show(Fragment fragment) { new SiteHealthReportDialog().show(fragment.getChildFragmentManager(), null); @@ -59,47 +49,23 @@ public class SiteHealthReportDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override public void onStart() { super.onStart(); - Dialog dialog = getDialog(); - if (dialog == null || dialog.getWindow() == null) return; - Window window = dialog.getWindow(); - configureWindow(window); - // The window can be laid out before the platform reports its system-bar insets, in which - // case the first sizing falls back to the display bounds and the footer's cancel button - // ends up under the navigation bar. Re-sizing when the real insets arrive is what makes - // the fix independent of that ordering. - DialogSafeArea.follow(requireActivity(), window, () -> configureWindow(window)); - binding.close.requestFocus(); - } - - /** - * Sizes the dialog window from the system-bar-safe area instead of the raw display. - * - *

{@link ResUtil#getScreenHeight} returns the whole display, so a window sized from it is - * taller than the area the window manager actually offers a floating dialog: the window is - * centred in the bar-safe area and its last row - the fixed-height cancel button - is painted - * under the navigation bar and clipped. {@link DialogSafeArea} resolves that area for every - * dialog in this package; the gutter stays the existing 16dp portrait / 24dp landscape value - * so television geometry is unchanged. - */ - private void configureWindow(Window window) { + if (getDialog() == null) return; + Window window = getDialog().getWindow(); + if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); - int margin = ResUtil.dp2px(ResUtil.isLand(requireContext()) ? 24 : 16); - ThemeDialogLayout.Area area = DialogSafeArea.area(requireActivity(), window); - params.width = ThemeDialogLayout.width(area.width(), margin); - params.height = ThemeDialogLayout.height(area.height(), margin); - // Gravity.CENTER centres the window inside the bar-safe area; an explicit params.x/y - // offset is re-clamped by the window manager and is deliberately not set. - params.gravity = Gravity.CENTER; + params.width = (int) (ResUtil.getScreenWidth(requireContext()) * (ResUtil.isLand(requireContext()) ? 0.62f : 0.94f)); + params.height = (int) (ResUtil.getScreenHeight(requireContext()) * (ResUtil.isLand(requireContext()) ? 0.78f : 0.82f)); window.setAttributes(params); window.setLayout(params.width, params.height); + binding.close.requestFocus(); } @Override @@ -119,15 +85,6 @@ public class SiteHealthReportDialog extends BaseAlertDialog { binding.sortRecent.setOnClickListener(view -> setSort(Sort.RECENT)); binding.sortRate.setOnClickListener(view -> setSort(Sort.RATE)); binding.sortSamples.setOnClickListener(view -> setSort(Sort.SAMPLES)); - binding.clearAll.setOnClickListener(view -> confirmClearAll()); - binding.search.addTextChangedListener(new TextWatcher() { - @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} - @Override public void afterTextChanged(Editable s) {} - @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - query = s == null ? "" : s.toString().trim().toLowerCase(Locale.ROOT); - render(); - } - }); binding.close.setOnClickListener(view -> dismiss()); } @@ -163,16 +120,9 @@ public class SiteHealthReportDialog extends BaseAlertDialog { button.setTypeface(Typeface.DEFAULT, selected ? Typeface.BOLD : Typeface.NORMAL); } - private void refreshReport() { - report = SiteHealthStore.report(); - render(); - } - private void render() { while (binding.rows.getChildCount() > 1) binding.rows.removeViewAt(1); - binding.summary.setText(summaryText(report.summary) + "\n" + adDimensionSummary()); - binding.clearAll.setEnabled(!report.isEmpty()); - binding.clearAll.setAlpha(report.isEmpty() ? 0.5f : 1.0f); + binding.summary.setText(summaryText(report.summary)); int visible = 0; for (SiteHealthStore.Row row : sortedRows()) { if (!matches(row)) continue; @@ -209,8 +159,7 @@ public class SiteHealthReportDialog extends BaseAlertDialog { } private long lastFailAt(SiteHealthStore.Row row) { - return Math.max(Math.max(row.home.lastFailAt, row.category.lastFailAt), - Math.max(Math.max(row.search.lastFailAt, row.detail.lastFailAt), Math.max(row.parse.lastFailAt, row.play.lastFailAt))); + return Math.max(Math.max(row.search.lastFailAt, row.detail.lastFailAt), Math.max(row.parse.lastFailAt, row.play.lastFailAt)); } private float successRate(SiteHealthStore.Row row) { @@ -219,7 +168,6 @@ public class SiteHealthReportDialog extends BaseAlertDialog { } private boolean matches(SiteHealthStore.Row row) { - if (!query.isEmpty() && !row.siteName.toLowerCase(Locale.ROOT).contains(query)) return false; return switch (filter) { case ALL -> true; case BAD -> row.status == SiteHealthStore.Status.BAD; @@ -227,39 +175,6 @@ public class SiteHealthReportDialog extends BaseAlertDialog { }; } - private String adDimensionSummary() { - return getString(R.string.site_health_report_ad_summary, report.adBlockedTotal, pipelineSummary(report.adBlockedByPipeline)) - + "\n" + getString(R.string.ad_site_rank) + ": " + dimensionSummary(report.adBlockedBySite) - + "\n" + getString(R.string.ad_rule_rank) + ": " + ruleDimensionSummary(report.adBlockedByRule) - + "\n" + getString(R.string.ad_pipeline_rank) + ": " + dimensionSummary(report.adBlockedByPipeline); - } - - private String dimensionSummary(java.util.Map values) { - if (values.isEmpty()) return getString(R.string.ad_stats_empty); - return values.entrySet().stream() - .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) - .limit(5) - .map(entry -> entry.getKey() + " " + entry.getValue()) - .collect(java.util.stream.Collectors.joining(" · ")); - } - - private String ruleDimensionSummary(java.util.Map values) { - if (values.isEmpty()) return getString(R.string.ad_stats_empty); - return values.entrySet().stream() - .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) - .limit(5) - .map(entry -> AdBlockStatsStore.getRuleDisplayName(entry.getKey()) + " " + entry.getValue()) - .collect(java.util.stream.Collectors.joining(" · ")); - } - - private String pipelineSummary(java.util.Map pipelines) { - if (pipelines.isEmpty()) return getString(R.string.ad_stats_empty); - return pipelines.entrySet().stream() - .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) - .map(entry -> entry.getKey() + " " + entry.getValue()) - .collect(java.util.stream.Collectors.joining(" · ")); - } - private String summaryText(SiteHealthStore.Summary summary) { if (TextUtils.isEmpty(summary.topFailureReason)) { return getString(R.string.site_health_report_summary, summary.siteCount, summary.sampleCount, summary.failureCount, summary.degradedCount); @@ -281,7 +196,7 @@ public class SiteHealthReportDialog extends BaseAlertDialog { header.setOrientation(LinearLayoutCompat.HORIZONTAL); root.addView(header, new LinearLayoutCompat.LayoutParams(LinearLayoutCompat.LayoutParams.MATCH_PARENT, LinearLayoutCompat.LayoutParams.WRAP_CONTENT)); - MaterialTextView title = text(row.siteName, 16, ThemeController.current().colorOnSurface(), Typeface.BOLD); + MaterialTextView title = text(row.siteName, 16, R.color.black, Typeface.BOLD); header.addView(title, new LinearLayoutCompat.LayoutParams(0, LinearLayoutCompat.LayoutParams.WRAP_CONTENT, 1)); MaterialTextView status = text(statusLabel(row.status), 13, statusColor(row.status), Typeface.BOLD); @@ -290,18 +205,16 @@ public class SiteHealthReportDialog extends BaseAlertDialog { statusParams.setMargins(0, 0, dp(8), 0); header.addView(status, statusParams); - MaterialTextView toggle = text("+", 16, ThemeController.current().colorOnSurfaceVariant(), Typeface.BOLD); + MaterialTextView toggle = text("+", 16, R.color.black_50, Typeface.BOLD); toggle.setGravity(android.view.Gravity.CENTER); header.addView(toggle, new LinearLayoutCompat.LayoutParams(dp(20), LinearLayoutCompat.LayoutParams.WRAP_CONTENT)); root.addView(metaText(getString(R.string.site_health_report_row_meta, row.sampleCount(), row.failureCount()))); - addStage(root, R.string.site_health_stage_home, row.home); - addStage(root, R.string.site_health_stage_category, row.category); addStage(root, R.string.site_health_stage_search, row.search); addStage(root, R.string.site_health_stage_detail, row.detail); addStage(root, R.string.site_health_stage_parse, row.parse); addStage(root, R.string.site_health_stage_play, row.play); - addAdBlockStats(root, row); + addAdBlockStats(root, row.siteKey); String reason = row.topFailureReason(); if (!TextUtils.isEmpty(reason)) root.addView(metaText(getString(R.string.site_health_report_reason, reasonLabel(reason), row.topFailureCount()))); @@ -329,7 +242,7 @@ public class SiteHealthReportDialog extends BaseAlertDialog { blockParams.setMargins(0, dp(8), 0, 0); root.addView(block, blockParams); - MaterialTextView label = text(stageText(labelRes, stage), 13, ThemeController.current().colorOnSurface(), Typeface.NORMAL); + MaterialTextView label = text(stageText(labelRes, stage), 13, R.color.black_80, Typeface.NORMAL); block.addView(label, new LinearLayoutCompat.LayoutParams(LinearLayoutCompat.LayoutParams.MATCH_PARENT, LinearLayoutCompat.LayoutParams.WRAP_CONTENT)); ProgressBar progress = new ProgressBar(requireContext(), null, android.R.attr.progressBarStyleHorizontal); @@ -340,14 +253,11 @@ public class SiteHealthReportDialog extends BaseAlertDialog { block.addView(progress, progressParams); } - private void addAdBlockStats(LinearLayoutCompat root, SiteHealthStore.Row row) { - long blocked = AdBlockStatsStore.getSiteBlockedCount(row.siteKey); - int playCount = row.playAttempts; - // 始终显示播放与广告拦截比值(即使为 0,让用户知道该功能在工作) - int color = blocked > 0 - ? readableOnPanel(ResUtil.getColor(R.color.site_health_warn)) - : ThemeController.current().colorOnSurface(); - MaterialTextView view = text(getString(R.string.site_health_report_ad_blocked, playCount, blocked), 13, color, Typeface.NORMAL); + private void addAdBlockStats(LinearLayoutCompat root, String siteKey) { + long blocked = AdBlockStatsStore.getSiteBlockedCount(siteKey); + // 始终显示广告拦截统计(即使为 0,让用户知道该功能在工作) + int colorRes = blocked > 0 ? R.color.site_health_warn : R.color.black_80; + MaterialTextView view = text(getString(R.string.site_health_report_ad_blocked, blocked), 13, colorRes, Typeface.NORMAL); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(LinearLayoutCompat.LayoutParams.MATCH_PARENT, LinearLayoutCompat.LayoutParams.WRAP_CONTENT); params.setMargins(0, dp(8), 0, 0); view.setLayoutParams(params); @@ -370,14 +280,12 @@ public class SiteHealthReportDialog extends BaseAlertDialog { LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(LinearLayoutCompat.LayoutParams.MATCH_PARENT, LinearLayoutCompat.LayoutParams.WRAP_CONTENT); params.setMargins(0, dp(8), 0, 0); block.setLayoutParams(params); - addRecentError(block, R.string.site_health_stage_home, row.home); - addRecentError(block, R.string.site_health_stage_category, row.category); addRecentError(block, R.string.site_health_stage_search, row.search); addRecentError(block, R.string.site_health_stage_detail, row.detail); addRecentError(block, R.string.site_health_stage_parse, row.parse); addRecentError(block, R.string.site_health_stage_play, row.play); if (block.getChildCount() > 0) { - MaterialTextView title = text(getString(R.string.site_health_report_recent_errors), 12, ThemeController.current().colorOnSurfaceVariant(), Typeface.BOLD); + MaterialTextView title = text(getString(R.string.site_health_report_recent_errors), 12, R.color.black_70, Typeface.BOLD); block.addView(title, 0); } block.addView(clearButton(row)); @@ -397,35 +305,16 @@ public class SiteHealthReportDialog extends BaseAlertDialog { } private void confirmClearSite(SiteHealthStore.Row row) { - showClearConfirmation( - R.string.site_health_clear_site_title, - getString(R.string.site_health_clear_site_message, row.siteName), - () -> { - SiteHealthStore.clear(row.siteKey); - binding.root.post(this::refreshReport); - }); - } - - private void confirmClearAll() { - if (report.isEmpty()) return; - showClearConfirmation( - R.string.site_health_clear_all_title, - getString(R.string.site_health_clear_all_message), - () -> { - SiteHealthStore.clear(); - binding.root.post(this::refreshReport); - }); - } - - private void showClearConfirmation(int titleRes, CharSequence message, Runnable action) { - AlertDialog dialog = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) - .setTitle(titleRes) - .setMessage(message) + new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog) + .setTitle(R.string.site_health_clear_site_title) + .setMessage(getString(R.string.site_health_clear_site_message, row.siteName)) .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, (confirmation, which) -> action.run()) - .create(); - dialog.show(); - LightDialog.apply(dialog); + .setPositiveButton(R.string.dialog_positive, (dialog, which) -> { + SiteHealthStore.clear(row.siteKey); + report = SiteHealthStore.report(); + render(); + }) + .show(); } private void addRecentError(LinearLayoutCompat block, int labelRes, SiteHealthStore.Stage stage) { @@ -443,24 +332,18 @@ public class SiteHealthReportDialog extends BaseAlertDialog { } private MaterialTextView metaText(String value) { - MaterialTextView view = text(value, 12, ThemeController.current().colorOnSurfaceVariant(), Typeface.NORMAL); + MaterialTextView view = text(value, 12, R.color.black_60, Typeface.NORMAL); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(LinearLayoutCompat.LayoutParams.MATCH_PARENT, LinearLayoutCompat.LayoutParams.WRAP_CONTENT); params.setMargins(0, dp(6), 0, 0); view.setLayoutParams(params); return view; } - /** - * The report body is generated in code, so its text cannot inherit a theme attribute the way - * the dialog XML does. It must use the resolved semantic roles instead: the panel is a dark - * surface in dark mode, and a hard-coded translucent black made every generated row - * unreadable there (the XML title, which uses {@code ?attr/colorOnSurface}, stayed legible). - */ - private MaterialTextView text(String value, int sp, int color, int style) { + private MaterialTextView text(String value, int sp, int colorRes, int style) { MaterialTextView view = new MaterialTextView(requireContext()); view.setText(value); view.setTextSize(sp); - view.setTextColor(color); + view.setTextColor(ResUtil.getColor(colorRes)); view.setTypeface(Typeface.DEFAULT, style); return view; } @@ -499,22 +382,12 @@ public class SiteHealthReportDialog extends BaseAlertDialog { } private int statusColor(SiteHealthStore.Status status) { - int color = switch (status) { - case GOOD -> ResUtil.getColor(R.color.site_health_good); - case BAD -> ResUtil.getColor(R.color.site_health_bad); - case WARN -> ResUtil.getColor(R.color.site_health_warn); - default -> ThemeController.current().colorOnSurfaceVariant(); + return switch (status) { + case GOOD -> R.color.site_health_good; + case BAD -> R.color.site_health_bad; + case WARN -> R.color.site_health_warn; + default -> R.color.black_50; }; - return readableOnPanel(color); - } - - /** - * The status/ad colours are tuned for a light panel. On a dark panel a mid-tone amber or green - * can fall below 4.5:1, so fall back to black/white by contrast - the same rule the theme - * editor uses for values it draws over a themed surface. - */ - private int readableOnPanel(int color) { - return ThemeEditorUi.readable(color, ThemeController.current().colorSurfaceContainerHigh()); } private int dp(int value) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteNameDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteNameDialog.java index c944c4617d..8535264c5d 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteNameDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteNameDialog.java @@ -13,8 +13,8 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.setting.SiteNameStore; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; import com.google.android.material.textview.MaterialTextView; @@ -47,7 +47,7 @@ public final class SiteNameDialog { } CharSequence[] labels = new CharSequence[sites.size()]; for (int i = 0; i < sites.size(); i++) labels[i] = label(sites.get(i)); - new WebHtvAlertDialogBuilder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(R.string.setting_site_name) .setItems(labels, (dialog, which) -> showEditor(sites.get(which))) .setNeutralButton(R.string.setting_reset, (dialog, which) -> confirmReset()) @@ -86,7 +86,7 @@ public final class SiteNameDialog { inputParams.topMargin = Math.round(16 * activity.getResources().getDisplayMetrics().density); panel.addView(inputLayout, inputParams); - new WebHtvAlertDialogBuilder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(site.getDisplayName()) .setView(panel) .setPositiveButton(R.string.dialog_positive, (dialog, which) -> { @@ -104,7 +104,7 @@ public final class SiteNameDialog { } private void confirmReset() { - new WebHtvAlertDialogBuilder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(R.string.site_name_reset_title) .setMessage(R.string.site_name_reset_message) .setPositiveButton(R.string.setting_reset, (dialog, which) -> { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleManualSearchDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleManualSearchDialog.java index 3c36afff32..b2c04045d7 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleManualSearchDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleManualSearchDialog.java @@ -23,11 +23,11 @@ import com.fongmi.android.tv.subtitle.model.SubtitleMatchType; import com.fongmi.android.tv.subtitle.model.SubtitleRequest; import com.fongmi.android.tv.subtitle.translate.SubtitleTranslationRequest; import com.fongmi.android.tv.subtitle.translate.SubtitleTranslationResult; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.utils.Util; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; import java.util.ArrayList; @@ -60,7 +60,7 @@ public final class SubtitleManualSearchDialog { input.setTextColor(0xFF202124); input.setHintTextColor(0xFF5F6368); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.subtitle_manual_search) .setView(input) .setNegativeButton(R.string.dialog_negative, null) @@ -112,7 +112,7 @@ public final class SubtitleManualSearchDialog { if (candidates == null || candidates.isEmpty()) return; String[] labels = new String[candidates.size()]; for (int i = 0; i < candidates.size(); i++) labels[i] = label(candidates.get(i)); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(ResUtil.getString(R.string.subtitle_manual_select_title, candidates.size())) .setNegativeButton(R.string.dialog_negative, null) .setSingleChoiceItems(labels, indexOfCandidate(candidates, selected), (d, which) -> { @@ -154,7 +154,7 @@ public final class SubtitleManualSearchDialog { private static void maybeOfferAiTranslate(FragmentActivity activity, SubtitlePlaybackSession session, SubtitlePlaybackSession.Host host, SubtitleRequest request, SubtitleMatchResult result, SubtitleCandidate candidate, List candidates) { if (!shouldOfferAiTranslate(result, candidate)) return; - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.subtitle_ai_translate_title) .setMessage(R.string.subtitle_ai_translate_message) .setNegativeButton(R.string.dialog_negative, null) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleSettingsDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleSettingsDialog.java index 4ef3afcaa5..e06a94ac7b 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleSettingsDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleSettingsDialog.java @@ -9,8 +9,6 @@ import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; @@ -31,7 +29,7 @@ public final class SubtitleSettingsDialog { public static void showPreferredLanguage(FragmentActivity activity, String[] labels, String[] values, String currentValue, ValueCallback callback) { if (activity == null || labels == null || values == null || labels.length == 0 || values.length == 0) return; int checked = indexOf(values, currentValue); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.player_subtitle_language) .setNegativeButton(R.string.dialog_negative, null) .setSingleChoiceItems(labels, checked, (d, which) -> { @@ -42,65 +40,39 @@ public final class SubtitleSettingsDialog { LightDialog.apply(dialog); } - public static void showSourceSummary(FragmentActivity activity, java.util.List providers, Runnable onSaved) { + public static void showAssrtToken(FragmentActivity activity, String currentToken, ValueCallback callback) { if (activity == null) return; - if (providers == null || providers.isEmpty()) { - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) - .setTitle(R.string.player_subtitle_source) - .setMessage(R.string.player_subtitle_source_empty) - .setPositiveButton(R.string.dialog_positive, null) - .show(); - LightDialog.apply(dialog); - return; - } - String[] names = new String[providers.size()]; - for (int i = 0; i < providers.size(); i++) names[i] = providers.get(i).getName(); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) - .setTitle(R.string.player_subtitle_source) - .setNegativeButton(R.string.dialog_negative, null) - .setItems(names, (d, which) -> showSourceEnvironment(activity, providers.get(which), onSaved)) - .show(); - LightDialog.apply(dialog); - } - - private static void showSourceEnvironment(FragmentActivity activity, com.fongmi.android.tv.subtitle.provider.SubtitleProvider provider, Runnable onSaved) { TextInputEditText input = new TextInputEditText(activity); - input.setSingleLine(false); - input.setMinLines(4); - input.setText(Setting.getSubtitleSourceEnvironment(provider.getKey())); + input.setSingleLine(true); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + input.setText(TextUtils.isEmpty(currentToken) ? "" : currentToken); + if (input.getText() != null) input.setSelection(input.length()); + TextInputLayout layout = new TextInputLayout(activity); - layout.setHint(activity.getString(R.string.player_subtitle_source_environment)); + layout.setHint(activity.getString(R.string.player_subtitle_assrt_token)); layout.setBoxBackgroundMode(TextInputLayout.BOX_BACKGROUND_OUTLINE); layout.addView(input, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + LinearLayout container = new LinearLayout(activity); container.setOrientation(LinearLayout.VERTICAL); int horizontal = dp(activity, 20); container.setPadding(horizontal, dp(activity, 8), horizontal, 0); - container.addView(layout); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) - .setTitle(provider.getName()) + container.addView(layout, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.player_subtitle_assrt_token) .setView(container) .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, null) + .setPositiveButton(R.string.dialog_positive, (d, which) -> { + if (callback != null) callback.onValue(input.getText() == null ? "" : input.getText().toString().trim()); + }) .show(); - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(view -> { - String value = input.getText() == null ? "" : input.getText().toString().trim(); - try { - if (!value.isEmpty() && !com.google.gson.JsonParser.parseString(value).isJsonObject()) throw new IllegalArgumentException(); - } catch (RuntimeException e) { - layout.setError(activity.getString(R.string.player_subtitle_source_environment_invalid)); - return; - } - Setting.putSubtitleSourceEnvironment(provider.getKey(), value); - if (onSaved != null) onSaved.run(); - dialog.dismiss(); - }); LightDialog.apply(dialog); } public static void showRealtimeModel(FragmentActivity activity, String[] labels, int checked, boolean canDelete, IntCallback selection, Runnable deleteAction) { if (activity == null || labels == null || labels.length == 0) return; - MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.subtitle_realtime_model_select_title) .setNegativeButton(R.string.dialog_negative, null) .setSingleChoiceItems(labels, checked, (d, which) -> { @@ -114,7 +86,7 @@ public final class SubtitleSettingsDialog { public static void showDeleteRealtimeModel(FragmentActivity activity, Runnable action) { if (activity == null) return; - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.subtitle_realtime_model_delete_title) .setMessage(R.string.subtitle_realtime_model_delete_message) .setNegativeButton(R.string.dialog_negative, null) @@ -127,7 +99,7 @@ public final class SubtitleSettingsDialog { public static void showRealtimeCompatibility(FragmentActivity activity, Runnable action) { if (activity == null) return; - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.subtitle_realtime_pcm_title) .setMessage(R.string.subtitle_realtime_pcm_required) .setNegativeButton(R.string.dialog_negative, null) @@ -140,7 +112,7 @@ public final class SubtitleSettingsDialog { public static void showRealtimeSpeedCompatibility(FragmentActivity activity, Runnable action) { if (activity == null) return; - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.subtitle_realtime_speed_title) .setMessage(R.string.subtitle_realtime_speed_required) .setNegativeButton(R.string.dialog_negative, null) @@ -170,7 +142,7 @@ public final class SubtitleSettingsDialog { container.setPadding(horizontal, dp(activity, 8), horizontal, 0); container.addView(layout, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(titleRes) .setView(container) .setNegativeButton(R.string.dialog_negative, null) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SyncPathDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SyncPathDialog.java index 78842cba1c..b7df9affcf 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SyncPathDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SyncPathDialog.java @@ -15,7 +15,6 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterSyncPathDirBinding; import com.fongmi.android.tv.databinding.DialogSyncPathBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.SyncFiles; @@ -56,7 +55,7 @@ public class SyncPathDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeDialogLayout.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeDialogLayout.java deleted file mode 100644 index f0127192c9..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeDialogLayout.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.graphics.drawable.Drawable; -import android.graphics.drawable.InsetDrawable; - -/** - * Window sizing for the theme colour editor. - * - *

The editor previously inherited Material's default alert width, so the 13 colour - * slots, the 3 opacity sliders and the live preview were squeezed into a narrow column - * and the dialog covered only about 58% x 70% of a 1920x1080 screen - measured on - * device at [405,160][1515,920]. It now follows the same contract as the ad-block - * statistics dialog: fill the screen minus a constant safety margin. - * - *

The margin is a fixed dp value rather than a screen percentage. A percentage - * silently changes the usable area with the panel resolution, while the editor's rows - * have a fixed dp height and the preview needs a predictable amount of room; a fixed - * margin keeps the same physical gutter on every device. It also keeps the dialog from - * ever reaching the very edge of an overscanning TV panel. - * - *

The gutter is subtracted from the system-bar-safe area, not from the raw display - * bounds. {@code ResUtil.getScreenHeight} returns the full display on API 30+, so sizing - * the window from it pushed the footer's cancel/reset/save row under the navigation bar - * and the buttons were cut off. {@link #safeArea} subtracts the window's status-bar, - * navigation-bar and display-cutout insets instead, so the result stays inside every - * system bar and any TV overscan. - * - *

The same contract also sizes the playback-link health report dialog - * ({@code SiteHealthReportDialog}), whose footer cancel button was clipped for exactly the - * same reason. The arithmetic lives here so the two dialogs cannot drift apart. - */ -final class ThemeDialogLayout { - - /** Matches the ad-block statistics dialog's mobile gutter. */ - static final int MOBILE_MARGIN_DP = 16; - - /** Matches the ad-block statistics dialog's television gutter. */ - static final int LEANBACK_MARGIN_DP = 24; - - private ThemeDialogLayout() { - } - - /** - * The system-bar-safe area the editor may occupy. - * - * @param width usable width, already excluding the status/navigation bars and cutouts - * @param height usable height, already excluding the status/navigation bars and cutouts - */ - record Area(int width, int height) { - } - - /** - * The four system-bar insets that have to be kept clear, in pixels. - */ - record Insets(int left, int top, int right, int bottom) { - - static Insets none() { - return new Insets(0, 0, 0, 0); - } - - /** - * True when every edge is zero, i.e. the source carried no usable information. - * - *

A floating dialog window on API 28 reports {@code systemBars()} as all zeros even - * while a navigation bar is on screen (measured: the window's own insets are - * {@code [0,0][0,0]} while the display's stable area is {@code [0,42][1080,1830]}). - * Treating that as "no bars" is what let the editor grow past the navigation bar, so an - * all-zero report must stay indistinguishable from "unknown" and leave the host-content - * fallback in charge.

- */ - boolean isEmpty() { - return left <= 0 && top <= 0 && right <= 0 && bottom <= 0; - } - - /** - * Insets implied by the display bounds minus the host content view. - * - *

The host already gave up that space to the status bar, the navigation bar, a - * display cutout and TV overscan, which makes this a reliable second source when the - * dialog window cannot report its own insets. A zero-sized content view means - * "unknown" and reports no insets. - */ - static Insets ofContent(int screenWidth, int screenHeight, int contentWidth, int contentHeight) { - if (contentWidth <= 0 || contentHeight <= 0) return none(); - return new Insets(0, 0, Math.max(0, screenWidth - contentWidth), - Math.max(0, screenHeight - contentHeight)); - } - - /** Never negative, never wider or taller than the display itself. */ - Insets clamped(int screenWidth, int screenHeight) { - int l = Math.max(0, left); - int t = Math.max(0, top); - int r = Math.max(0, Math.min(right, Math.max(0, screenWidth - l))); - int b = Math.max(0, Math.min(bottom, Math.max(0, screenHeight - t))); - return new Insets(l, t, r, b); - } - } - - /** Screen edge gutter in dp for the running flavour. */ - static int marginDp(boolean leanback) { - return leanback ? LEANBACK_MARGIN_DP : MOBILE_MARGIN_DP; - } - - /** - * The display minus the system-bar insets, or the whole display when that leaves no room. - * - *

On a 1080x1920 phone with a 42px status bar and a 90px navigation bar this is - * 1080x1788, and the window manager centres the resulting 1732px-tall window in the - * bar-safe area, so it spans y=[70,1802] and clears the navigation bar at y=1830. - * Sizing from the raw display made the window 1864px tall instead, which is what put - * the footer's save button under that bar. - */ - static Area safeArea(int screenWidth, int screenHeight, Insets insets) { - int screenW = Math.max(1, screenWidth); - int screenH = Math.max(1, screenHeight); - Insets bars = (insets == null ? Insets.none() : insets).clamped(screenW, screenH); - int width = screenW - bars.left() - bars.right(); - int height = screenH - bars.top() - bars.bottom(); - if (width <= 0 || height <= 0) return fullScreen(screenW, screenH); - return new Area(width, height); - } - - /** The whole display; used only when the system bars would leave no usable area. */ - static Area fullScreen(int screenWidth, int screenHeight) { - return new Area(Math.max(1, screenWidth), Math.max(1, screenHeight)); - } - - /** Window width that keeps {@code margin} on both sides, never below one pixel. */ - static int width(int screenWidth, int margin) { - return Math.max(1, Math.max(1, screenWidth) - Math.max(0, margin) * 2); - } - - /** Window height that keeps {@code margin} above and below, never below one pixel. */ - static int height(int screenHeight, int margin) { - return Math.max(1, Math.max(1, screenHeight) - Math.max(0, margin) * 2); - } - - /** - * The panel drawable the editor must keep as its window background. - * - *

{@code WebHtvAlertDialogBuilder} paints this editor's panel through the window - * background rather than through a view: the title row, the colour slots, the weighted - * scroll area and the action buttons carry no background of their own. Material wraps - * that panel in an {@link InsetDrawable} gutter, which is what kept the panel narrower - * than the window, so the wrapper is dropped now that the window carries the margin - * itself. - * - *

The panel itself has to be kept. Replacing the window background with a transparent - * fill is only safe for a dialog whose layout supplies its own card - the ad-block - * statistics dialog does - while here it erased the panel and left the settings page - * visible through the whole editor. - * - * @return the inset-free panel, or {@code null} when the window carries no background. - */ - static Drawable panelBackground(Drawable windowBackground) { - Drawable panel = windowBackground; - while (panel instanceof InsetDrawable inset) { - panel = inset.getDrawable(); - } - return panel; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeImportDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeImportDialog.java deleted file mode 100644 index 9072dc6d01..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeImportDialog.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.content.Intent; -import android.net.Uri; -import android.text.TextUtils; -import android.view.View; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.databinding.DialogThemeImportBinding; -import com.fongmi.android.tv.theme.ThemeProfile; -import com.fongmi.android.tv.theme.ThemeProfileValidator; -import com.fongmi.android.tv.theme.ThemeTweakCnAdapter; -import com.fongmi.android.tv.theme.ThemeTransfer; -import com.fongmi.android.tv.utils.Task; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import java.io.InputStream; -import java.util.List; - -/** Imports a safe color-only theme into the editor draft. */ -public final class ThemeImportDialog extends BaseAlertDialog { - - private DialogThemeImportBinding binding; - private Listener listener; - private ThemeProfile pending; - private List warnings = List.of(); - - public static void show(Fragment fragment, Listener listener) { - ThemeImportDialog dialog = new ThemeImportDialog(); - dialog.listener = listener; - dialog.show(fragment.getChildFragmentManager(), ThemeImportDialog.class.getSimpleName()); - } - - @Override - protected ViewBinding getBinding() { - return binding = DialogThemeImportBinding.inflate(getLayoutInflater()); - } - - @Override - protected MaterialAlertDialogBuilder getBuilder() { - return builder().setTitle(R.string.theme_import_title).setView(getBinding().getRoot()); - } - - @Override - protected void initView() { - binding.buttonOpenFile.setOnClickListener(view -> openFileLauncher.launch(new String[]{"application/json", "text/plain", "*/*"})); - binding.buttonCommunity.setOnClickListener(view -> openCommunity()); - binding.buttonPreview.setOnClickListener(view -> previewInput()); - binding.buttonUse.setOnClickListener(view -> usePending()); - binding.buttonCancel.setOnClickListener(view -> dismiss()); - binding.buttonUse.setEnabled(false); - } - - private void previewInput() { - String value = binding.input.getText() == null ? "" : binding.input.getText().toString().trim(); - if (value.isEmpty()) showError(getString(R.string.theme_import_empty)); - else if (ThemeTransfer.isHttps(value)) fetch(value); - else inspect(value, getString(R.string.theme_import_source_paste)); - } - - private void fetch(String url) { - setBusy(true); - Task.execute(() -> { - try { - String json = ThemeTransfer.fetch(url); - App.post(() -> { - if (!isAdded() || binding == null) return; - setBusy(false); - inspect(json, ThemeTransfer.host(url)); - }); - } catch (Exception error) { - App.post(() -> { - if (!isAdded() || binding == null) return; - setBusy(false); - showError(error.getMessage() == null ? getString(R.string.theme_import_failed) : error.getMessage()); - }); - } - }); - } - - private void inspect(String json, String source) { - try { - ThemeTweakCnAdapter.Result result = ThemeTweakCnAdapter.parse(json); - ThemeProfileValidator.Result checked = ThemeProfileValidator.validate(result.profile()); - if (!checked.valid()) throw new IllegalArgumentException(checked.message()); - pending = checked.profile(); - warnings = result.warnings(); - String label = TextUtils.isEmpty(source) ? pending.displayName() : pending.displayName() + " · " + source; - binding.preview.setText(getString(R.string.theme_import_preview, label)); - binding.warning.setText(warnings.isEmpty() ? getString(R.string.theme_import_no_warnings) - : getString(R.string.theme_import_warnings, String.join("\n", warnings))); - binding.warning.setVisibility(warnings.isEmpty() ? View.GONE : View.VISIBLE); - binding.error.setVisibility(View.GONE); - binding.buttonUse.setEnabled(true); - } catch (RuntimeException error) { - pending = null; - warnings = List.of(); - binding.buttonUse.setEnabled(false); - binding.preview.setText(""); - binding.warning.setVisibility(View.GONE); - showError(error.getMessage() == null ? getString(R.string.theme_import_failed) : error.getMessage()); - } - } - - private void usePending() { - if (pending == null || listener == null) return; - listener.onImported(pending.copy()); - dismiss(); - } - - private void openCommunity() { - try { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://tweakcn.com/community"))); - } catch (RuntimeException error) { - showError(getString(R.string.theme_import_browser_unavailable)); - } - } - - private void setBusy(boolean busy) { - binding.buttonPreview.setEnabled(!busy); - binding.buttonOpenFile.setEnabled(!busy); - binding.buttonCommunity.setEnabled(!busy); - binding.buttonUse.setEnabled(!busy && pending != null); - binding.error.setVisibility(View.GONE); - } - - private void showError(String message) { - binding.error.setText(message); - binding.error.setVisibility(View.VISIBLE); - } - - private final ActivityResultLauncher openFileLauncher = registerForActivityResult( - new ActivityResultContracts.OpenDocument(), this::readFile); - - private void readFile(Uri uri) { - if (uri == null) return; - setBusy(true); - Task.execute(() -> { - try (InputStream input = requireContext().getContentResolver().openInputStream(uri)) { - String json = ThemeTransfer.read(input); - App.post(() -> { - if (!isAdded() || binding == null) return; - setBusy(false); - binding.input.setText(json); - inspect(json, getString(R.string.theme_import_source_file)); - }); - } catch (Exception error) { - App.post(() -> { - if (!isAdded() || binding == null) return; - setBusy(false); - showError(error.getMessage() == null ? getString(R.string.theme_import_failed) : error.getMessage()); - }); - } - }); - } - - public interface Listener { - void onImported(@NonNull ThemeProfile profile); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbPersonDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbPersonDialog.java index 0cbecf8d6c..0e94f0c8c5 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbPersonDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbPersonDialog.java @@ -29,7 +29,6 @@ import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.bean.TmdbPerson; import com.fongmi.android.tv.service.TmdbService; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.ui.activity.VideoActivity; import com.fongmi.android.tv.ui.adapter.TmdbPersonPhotoAdapter; import com.fongmi.android.tv.ui.adapter.TmdbPersonWorkAdapter; @@ -105,7 +104,7 @@ public class TmdbPersonDialog { this.person = person; this.site = site; this.tmdbService = new TmdbService(); - this.tmdbConfig = TmdbConfig.effectiveCurrent(); + this.tmdbConfig = TmdbConfig.objectFrom(Setting.getTmdbConfig()); this.light = resolveLightTheme(activity); } @@ -370,7 +369,7 @@ public class TmdbPersonDialog { MaterialCardView biographyCard = view.findViewById(R.id.biographyCard); ImageView profile = view.findViewById(R.id.profile); ImageView close = view.findViewById(R.id.closeBtn); - int overlay = applyScrimOpacity(light ? 0x99F4F7FA : 0x8F000000, light); + int overlay = light ? 0x99F4F7FA : 0x8F000000; int panelColor = light ? 0xFFF4F7FA : 0xF2101821; int cardColor = light ? 0xFFFFFFFF : 0x261C2833; int imageBg = light ? 0xFFE7EDF3 : 0xFF25313D; @@ -409,19 +408,6 @@ public class TmdbPersonDialog { return Setting.resolveTmdbDetailLightTheme(Setting.getTmdbDetailTheme(), night == Configuration.UI_MODE_NIGHT_YES); } - /** - * Keeps this dialog's own scrim colour and lets the user drive only its alpha. - * - *

The shipped scrim is translucent white in light mode and translucent black in - * dark mode; the theme token is translucent black in both. Substituting the token - * was measured to invert the light-mode scrim from lightening to darkening, so the - * user's {@code scrimOpacity} is applied to the dialog's existing colour instead. - * An unset slot returns {@code base} untouched, keeping the default byte-identical. - */ - private static int applyScrimOpacity(int base, boolean light) { - return ThemeController.applyScrimOpacity(base, ThemeController.configuredScrimOpacity(light)); - } - /** * 根据筛选条件显示作品(分批懒加载)。 */ diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSearchDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSearchDialog.java index 2cf2b753e8..a2614f194a 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSearchDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSearchDialog.java @@ -19,10 +19,10 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.databinding.DialogResultListBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.TmdbAdapter; import com.fongmi.android.tv.utils.KeyUtil; import com.fongmi.android.tv.utils.Util; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.Collections; import java.util.List; @@ -59,7 +59,7 @@ public class TmdbSearchDialog { public TmdbSearchDialog(Activity activity) { this.activity = activity; - ContextThemeWrapper dialogContext = new ContextThemeWrapper(activity, R.style.Theme_WebHTV_Dialog); + ContextThemeWrapper dialogContext = new ContextThemeWrapper(activity, R.style.Theme_WebHTV_LightDialog); this.binding = DialogResultListBinding.inflate(LayoutInflater.from(dialogContext)); } @@ -100,7 +100,7 @@ public class TmdbSearchDialog { public void show() { if (activity == null || activity.isFinishing() || activity.isDestroyed()) return; - dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog).setView(binding.getRoot()).create(); + dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog).setView(binding.getRoot()).create(); if (activity.isFinishing() || activity.isDestroyed()) return; dialog.show(); LightDialog.apply(dialog); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSourceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSourceDialog.java index 1e15c608c2..492ab6633c 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSourceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSourceDialog.java @@ -17,15 +17,10 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.service.TmdbConfigTestService; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.utils.Task; -import com.fongmi.android.tv.utils.TmdbProxy; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.textfield.MaterialAutoCompleteTextView; import java.util.ArrayList; import java.util.List; @@ -49,18 +44,9 @@ public class TmdbSourceDialog { private TextView disabledLabel; private EditText apiKeyInput; private EditText languageInput; - private MaterialAutoCompleteTextView apiHostInput; - private MaterialAutoCompleteTextView imageHostInput; - private EditText apiCustomInput; - private EditText imageCustomInput; + private EditText apiHostInput; + private EditText imageHostInput; private EditText omdbApiKeyInput; - private EditText ruleInputField; - private EditText disabledRuleInputField; - private View addBtnField; - private View addDisabledBtnField; - private View manageBtnField; - private View testBtnField; - private View resetBtnField; private Runnable onDismiss; private List tempEnabledRules; @@ -90,51 +76,25 @@ public class TmdbSourceDialog { apiKeyInput = view.findViewById(R.id.apiKeyInput); languageInput = view.findViewById(R.id.languageInput); apiHostInput = view.findViewById(R.id.apiHostInput); - setupRouteDropdown(apiHostInput, apiOptionLabels(), activity.getString(R.string.dialog_tmdb_api_host_label)); imageHostInput = view.findViewById(R.id.imageHostInput); - setupRouteDropdown(imageHostInput, imageOptionLabels(), activity.getString(R.string.dialog_tmdb_image_host_label)); - apiCustomInput = view.findViewById(R.id.apiCustomInput); - imageCustomInput = view.findViewById(R.id.imageCustomInput); omdbApiKeyInput = view.findViewById(R.id.omdbApiKeyInput); EditText ruleInput = view.findViewById(R.id.ruleInput); EditText disabledRuleInput = view.findViewById(R.id.disabledRuleInput); - TextView addBtn = view.findViewById(R.id.add); - TextView addDisabledBtn = view.findViewById(R.id.addDisabled); - TextView manageBtn = view.findViewById(R.id.manage); - TextView testBtn = view.findViewById(R.id.testConfig); - TextView resetBtn = view.findViewById(R.id.resetDefault); - ruleInputField = ruleInput; - disabledRuleInputField = disabledRuleInput; - addBtnField = addBtn; - addDisabledBtnField = addDisabledBtn; - manageBtnField = manageBtn; - testBtnField = testBtn; - resetBtnField = resetBtn; - addBtn.setText(R.string.dialog_tmdb_add); - addDisabledBtn.setText(R.string.dialog_tmdb_add); - manageBtn.setText(R.string.dialog_tmdb_site_manage); - testBtn.setText(R.string.dialog_tmdb_test_config); - resetBtn.setText(R.string.dialog_tmdb_reset_default); - addBtn.setAllCaps(false); - addDisabledBtn.setAllCaps(false); - manageBtn.setAllCaps(false); - testBtn.setAllCaps(false); - resetBtn.setAllCaps(false); + View addBtn = view.findViewById(R.id.add); + View addDisabledBtn = view.findViewById(R.id.addDisabled); + View manageBtn = view.findViewById(R.id.manage); + View resetBtn = view.findViewById(R.id.resetDefault); TmdbConfig config = TmdbConfig.objectFrom(Setting.getTmdbConfig()); tempEnabledRules = new ArrayList<>(config.getEnabledSites()); tempDisabledSites = new ArrayList<>(config.getDisabledSites()); tempAllowedSites = new ArrayList<>(config.getAllowedSites()); apiKeyInput.setText(TextUtils.isEmpty(config.getAccessToken()) ? config.getApiKey() : config.getAccessToken()); - // 未显式配置过语言时留空,让用户看到「跟随界面」而不是一个看似已选的缺省值。 - languageInput.setText(config.isLanguageConfigured() ? config.getLanguage() : ""); - apiHostInput.setText(apiDisplayFor(config), false); - imageHostInput.setText(imageDisplayFor(config), false); - apiCustomInput.setText(apiCustomValueFor(config)); - imageCustomInput.setText(imageCustomValueFor(config)); + languageInput.setText(config.getLanguage()); + apiHostInput.setText(config.getApiHost()); + imageHostInput.setText(config.getImageHost()); omdbApiKeyInput.setText(config.getOmdbApiKey()); updateChipsDisplay(); - updateRouteCustomVisibility(); addBtn.setOnClickListener(v -> addRule(ruleInput)); addDisabledBtn.setOnClickListener(v -> addDisabledRule(disabledRuleInput)); @@ -153,7 +113,6 @@ public class TmdbSourceDialog { return false; }); manageBtn.setOnClickListener(v -> showSiteManage()); - testBtn.setOnClickListener(v -> testConfig(testBtn)); resetBtn.setOnClickListener(v -> resetToDefault()); dialog = builder @@ -161,320 +120,33 @@ public class TmdbSourceDialog { .setView(view) .setPositiveButton(R.string.dialog_positive, (d, w) -> onSave()) .setNegativeButton(R.string.dialog_negative, null) - .setOnDismissListener(d -> { - if (onDismiss != null) onDismiss.run(); - }) + .setOnDismissListener(d -> { if (onDismiss != null) onDismiss.run(); }) .create(); dialog.show(); wireConfigDialogFocus(dialog, ruleInput, addBtn, disabledRuleInput, addDisabledBtn, manageBtn, resetBtn); LightDialog.apply(dialog); } - private void rewiringFocusAfterVisibilityChange() { - if (dialog == null) return; - wireConfigDialogFocus(dialog, ruleInputField, addBtnField, disabledRuleInputField, addDisabledBtnField, manageBtnField, resetBtnField); - } - - private void testConfig(View testButton) { - String credential = inputText(apiKeyInput); - String omdbApiKey = inputText(omdbApiKeyInput); - updateRouteCustomVisibility(); - final String apiHost = apiValueFor(inputText(apiHostInput)); - final String imageHost = imageValueFor(inputText(imageHostInput)); - AlertDialog sourceDialog = dialog; - testButton.setEnabled(false); - Task.execute(() -> { - TmdbConfigTestService.Result result = TmdbConfigTestService.test(credential, apiHost, imageHost, "", omdbApiKey); - activity.runOnUiThread(() -> { - testButton.setEnabled(true); - if (activity.isFinishing() || activity.isDestroyed() || sourceDialog == null - || dialog != sourceDialog || !sourceDialog.isShowing()) return; - String apiResult = resultText(result.api, R.string.dialog_tmdb_test_api_success, R.string.dialog_tmdb_test_api_failed); - String imageResult = resultText(result.image, R.string.dialog_tmdb_test_image_success, R.string.dialog_tmdb_test_image_failed); - String omdbResult = resultText(result.omdb, R.string.dialog_tmdb_test_omdb_success, R.string.dialog_tmdb_test_omdb_failed); - new WebHtvAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_Dialog) - .setTitle(R.string.dialog_tmdb_test_result_title) - .setMessage(apiResult + "\n" + imageResult + "\n" + omdbResult) - .setPositiveButton(R.string.dialog_positive, null) - .show(); - }); - }); - } - - private String resultText(TmdbConfigTestService.Check check, int successText, int failureText) { - return check.success - ? activity.getString(successText, check.latencyMillis) - : activity.getString(failureText, check.message, check.latencyMillis); - } - - private static String inputText(EditText input) { - return input.getText() == null ? "" : input.getText().toString().trim(); - } - - private void setupRouteDropdown(MaterialAutoCompleteTextView input, String[] labels, String title) { - input.setKeyListener(null); - input.setAdapter(null); - input.setOnClickListener(v -> showRoutePicker(input, labels, title)); - input.setOnKeyListener((v, keyCode, event) -> { - if (event.getAction() != KeyEvent.ACTION_DOWN) return false; - if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { - showRoutePicker(input, labels, title); - return true; - } - return false; - }); - } - - private void wireRouteDpadFocus(MaterialAutoCompleteTextView input, String[] labels, String title, View up, View down) { - input.setOnKeyListener((v, keyCode, event) -> { - if (event.getAction() != KeyEvent.ACTION_DOWN) return false; - if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { - showRoutePicker(input, labels, title); - return true; - } - if (keyCode == KeyEvent.KEYCODE_DPAD_UP && up != null) return requestFocus(up); - if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && down != null) return requestFocus(down); - return false; - }); - } - - private void showRoutePicker(MaterialAutoCompleteTextView input, String[] labels, String title) { - String[] pickerLabels = withCustomLabel(labels); - String current = inputText(input); - int checked = -1; - for (int i = 0; i < pickerLabels.length; i++) if (pickerLabels[i].equals(current)) checked = i; - if (checked < 0 && !TextUtils.isEmpty(current)) checked = pickerLabels.length - 1; - final int focusIndex = Math.max(0, checked); - EditText customInput = customInputFor(input); - AlertDialog picker = new WebHtvAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_Dialog) - .setTitle(title) - .setSingleChoiceItems(pickerLabels, checked, (dialog, which) -> { - if (which == pickerLabels.length - 1) { - input.setText(pickerLabels[which], false); - showCustomInput(input, customInput); - } else { - hideCustomInput(input, customInput); - input.setText(pickerLabels[which], false); - } - dialog.dismiss(); - }) - .setNegativeButton(R.string.dialog_negative, null) - .show(); - picker.getListView().post(() -> { - if (picker.isShowing()) picker.getListView().setSelection(focusIndex); - if (picker.isShowing()) picker.getListView().requestFocus(); - }); - LightDialog.apply(picker); - } - - private String[] withCustomLabel(String[] labels) { - String[] result = new String[labels.length + 1]; - System.arraycopy(labels, 0, result, 0, labels.length); - result[labels.length] = activity.getString(R.string.dialog_tmdb_route_custom); - return result; - } - - private EditText customInputFor(MaterialAutoCompleteTextView input) { - return input == apiHostInput ? apiCustomInput : imageCustomInput; - } - - private String customLabel() { - return activity.getString(R.string.dialog_tmdb_route_custom); - } - - private void showCustomInput(MaterialAutoCompleteTextView input, EditText customInput) { - if (customInput == null) return; - customInput.setVisibility(View.VISIBLE); - rewiringFocusAfterVisibilityChange(); - customInput.post(() -> { - if (customInput.getVisibility() == View.VISIBLE && !activity.isFinishing() && !activity.isDestroyed()) { - requestFocus(customInput); - } - }); - } - - private void hideCustomInput(MaterialAutoCompleteTextView input, EditText customInput) { - if (customInput == null) return; - if (customInput.hasFocus() && input != null) requestFocus(input); - customInput.setVisibility(View.GONE); - rewiringFocusAfterVisibilityChange(); - } - - private void updateRouteCustomVisibility() { - updateCustomVisibility(apiHostInput, apiCustomInput); - updateCustomVisibility(imageHostInput, imageCustomInput); - } - - private void updateCustomVisibility(MaterialAutoCompleteTextView input, EditText customInput) { - if (input == null || customInput == null) return; - boolean visible = customLabel().equals(inputText(input)); - if (visible != isCustomVisible(customInput)) { - customInput.setVisibility(visible ? View.VISIBLE : View.GONE); - rewiringFocusAfterVisibilityChange(); - } - } - - private String[] apiOptionLabels() { - return new String[]{ - activity.getString(R.string.dialog_tmdb_api_auto), - activity.getString(R.string.dialog_tmdb_api_direct), - activity.getString(R.string.dialog_tmdb_api_itv666) - }; - } - - private String[] imageOptionLabels() { - return new String[]{ - activity.getString(R.string.dialog_tmdb_image_auto), - activity.getString(R.string.dialog_tmdb_image_direct), - activity.getString(R.string.dialog_tmdb_image_itv666), - activity.getString(R.string.dialog_tmdb_image_wsrv) - }; - } - - private String apiDisplayFor(TmdbConfig config) { - if (config != null && (config.isApiAuto() || config.isApiRouteDefault())) return activity.getString(R.string.dialog_tmdb_api_auto); - String value = config == null ? TmdbProxy.OFFICIAL_API : config.getApiHost(); - String display = routeDisplay(value, TmdbProxy.apiValues(), apiOptionLabels()); - return isCustomDisplay(display, apiOptionLabels()) ? customLabel() : display; - } - - private String imageDisplayFor(TmdbConfig config) { - if (config != null && (config.isImageAuto() || config.isImageRouteDefault())) return activity.getString(R.string.dialog_tmdb_image_auto); - String value = config == null ? TmdbProxy.OFFICIAL_IMAGE : config.getConfiguredImageBase(); - String display = routeDisplayImage(value, imageOptionLabels()); - return isCustomDisplay(display, imageOptionLabels()) ? customLabel() : display; - } - - /** display 未命中任何内置选项标签时才是真正的自定义线路。 */ - private boolean isCustomDisplay(String display, String[] builtInLabels) { - if (TextUtils.isEmpty(display)) return false; - for (String label : builtInLabels) { - if (TextUtils.equals(label, display)) return false; - } - return true; - } - - private String apiCustomValueFor(TmdbConfig config) { - if (config == null || config.isApiAuto() || config.isApiRouteDefault()) return ""; - String value = TmdbProxy.normalizeConfig(config.getApiHost()); - return isBuiltInApiValue(value) ? "" : value; - } - - private String imageCustomValueFor(TmdbConfig config) { - if (config == null || config.isImageAuto() || config.isImageRouteDefault()) return ""; - String value = TmdbProxy.normalizeImageConfig(config.getConfiguredImageBase()); - return isBuiltInImageValue(value) ? "" : value; - } - - private boolean isBuiltInApiValue(String value) { - List values = TmdbProxy.apiValues(); - for (String item : values) if (TextUtils.equals(item, value)) return true; - return TmdbProxy.isOfficialApiHost(value); - } - - private boolean isBuiltInImageValue(String value) { - List values = TmdbProxy.imageValues(); - for (String item : values) if (TextUtils.equals(item, value)) return true; - return TmdbProxy.isOfficialImageHost(value); - } - - private boolean isApiCustomMode() { - return apiCustomInput != null && apiCustomInput.getVisibility() == View.VISIBLE; - } - - private boolean isImageCustomMode() { - return imageCustomInput != null && imageCustomInput.getVisibility() == View.VISIBLE; - } - - private String apiValueFor(String value) { - if (isApiCustomMode()) { - String normalized = TmdbProxy.normalizeConfig(text(apiCustomInput)); - return TextUtils.isEmpty(normalized) ? TmdbProxy.OFFICIAL_API : normalized; - } - return routeValue(value, TmdbProxy.apiValues(), apiOptionLabels(), TmdbProxy.OFFICIAL_API); - } - - private String imageValueFor(String value) { - if (isImageCustomMode()) { - String normalized = TmdbProxy.normalizeImageConfig(text(imageCustomInput)); - return TextUtils.isEmpty(normalized) ? TmdbProxy.OFFICIAL_IMAGE : normalized; - } - String text = value == null ? "" : value.trim(); - String[] labels = imageOptionLabels(); - if (labels[0].equals(text)) return TmdbProxy.AUTO; - if (labels[1].equals(text)) return TmdbProxy.OFFICIAL_IMAGE; - if (labels[2].equals(text)) return TmdbProxy.ITV666; - if (labels[3].equals(text)) return TmdbProxy.WSRV_IMAGE; - String normalized = TmdbProxy.normalizeImageConfig(text); - return TextUtils.isEmpty(normalized) ? TmdbProxy.OFFICIAL_IMAGE : normalized; - } - - private String routeDisplay(String value, List values, String[] labels) { - String normalized = TmdbProxy.normalizeConfig(value); - for (int i = 0; i < values.size() && i < labels.length; i++) { - if (values.get(i).equals(normalized)) return labels[i]; - } - return normalized; - } - - private String routeDisplayImage(String value, String[] labels) { - String normalized = TmdbProxy.normalizeImageConfig(value); - List values = TmdbProxy.imageValues(); - for (int i = 0; i < values.size() && i < labels.length; i++) { - if (values.get(i).equals(normalized)) return labels[i]; - } - return normalized; - } - - private String routeValue(String value, List values, String[] labels, String fallback) { - String text = value == null ? "" : value.trim(); - for (int i = 0; i < values.size() && i < labels.length; i++) { - if (labels[i].equals(text)) return values.get(i); - } - String normalized = TmdbProxy.normalizeConfig(text); - return TextUtils.isEmpty(normalized) ? fallback : normalized; - } - - private static boolean isCustomVisible(EditText customInput) { - return customInput != null && customInput.getVisibility() == View.VISIBLE; - } - private MaterialAlertDialogBuilder builder() { - return new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog); + return new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog); } private void wireConfigDialogFocus(AlertDialog dialog, EditText ruleInput, View addBtn, EditText disabledRuleInput, View addDisabledBtn, View manageBtn, View resetBtn) { - wireConfigDialogFocus(dialog, ruleInput, addBtn, disabledRuleInput, addDisabledBtn, manageBtn, dialog.findViewById(R.id.testConfig), resetBtn); - } - - private void wireConfigDialogFocus(AlertDialog dialog, EditText ruleInput, View addBtn, EditText disabledRuleInput, View addDisabledBtn, View manageBtn, View testBtn, View resetBtn) { View positive = dialog.getButton(AlertDialog.BUTTON_POSITIVE); View negative = dialog.getButton(AlertDialog.BUTTON_NEGATIVE); wireTextDpadFocus(apiKeyInput, null, languageInput, null, null); wireTextDpadFocus(languageInput, apiKeyInput, apiHostInput, null, null); - wireRouteDpadFocus(apiHostInput, apiOptionLabels(), - activity.getString(R.string.dialog_tmdb_api_host_label), languageInput, - isCustomVisible(apiCustomInput) ? apiCustomInput : imageHostInput); - if (isCustomVisible(apiCustomInput)) { - wireTextDpadFocus(apiCustomInput, apiHostInput, imageHostInput, null, null); - } - wireRouteDpadFocus(imageHostInput, imageOptionLabels(), - activity.getString(R.string.dialog_tmdb_image_host_label), - isCustomVisible(apiCustomInput) ? apiCustomInput : apiHostInput, - isCustomVisible(imageCustomInput) ? imageCustomInput : omdbApiKeyInput); - if (isCustomVisible(imageCustomInput)) { - wireTextDpadFocus(imageCustomInput, imageHostInput, omdbApiKeyInput, null, null); - } - wireTextDpadFocus(omdbApiKeyInput, isCustomVisible(imageCustomInput) ? imageCustomInput : imageHostInput, ruleInput, null, null); + wireTextDpadFocus(apiHostInput, languageInput, imageHostInput, null, null); + wireTextDpadFocus(imageHostInput, apiHostInput, omdbApiKeyInput, null, null); + wireTextDpadFocus(omdbApiKeyInput, imageHostInput, ruleInput, null, null); wireTextDpadFocus(ruleInput, omdbApiKeyInput, disabledRuleInput, null, addBtn); wireDpadFocus(addBtn, omdbApiKeyInput, addDisabledBtn, ruleInput, null); wireTextDpadFocus(disabledRuleInput, ruleInput, manageBtn, null, addDisabledBtn); - wireDpadFocus(addDisabledBtn, addBtn, testBtn, disabledRuleInput, null); - wireDpadFocus(manageBtn, disabledRuleInput, positive, null, testBtn); - wireDpadFocus(testBtn, addDisabledBtn, positive, manageBtn, resetBtn); - wireDpadFocus(resetBtn, addDisabledBtn, positive, testBtn, null); + wireDpadFocus(addDisabledBtn, addBtn, resetBtn, disabledRuleInput, null); + wireDpadFocus(manageBtn, disabledRuleInput, positive, null, resetBtn); + wireDpadFocus(resetBtn, addDisabledBtn, positive, manageBtn, null); wireDpadFocus(negative, manageBtn, null, null, positive); - wireDpadFocus(positive, testBtn, null, negative, null); + wireDpadFocus(positive, resetBtn, null, negative, null); } private static void wireDpadFocus(View view, View up, View down, View left, View right) { @@ -520,11 +192,10 @@ public class TmdbSourceDialog { } private void onSave() { - updateRouteCustomVisibility(); String apiKey = text(apiKeyInput); String language = text(languageInput); - String apiHost = apiValueFor(text(apiHostInput)); - String imageHost = imageValueFor(text(imageHostInput)); + String apiHost = text(apiHostInput); + String imageHost = text(imageHostInput); String omdbApiKey = text(omdbApiKeyInput); boolean isToken = apiKey.split("\\.").length >= 3; StringBuilder sb = new StringBuilder("{"); @@ -532,19 +203,14 @@ public class TmdbSourceDialog { else sb.append("\"apiKey\":\"").append(escape(apiKey)).append("\","); if (!TextUtils.isEmpty(language)) { sb.append("\"language\":\"").append(escape(language)).append("\","); - // 用户在设置里保存过语言 = 明确的偏好表达;此后请求语言不再跟随界面语言。 - sb.append("\"languageConfigured\":true,"); } - boolean apiAuto = TmdbProxy.isAuto(apiHost); - boolean imageAuto = TmdbProxy.isAuto(imageHost); - sb.append("\"apiBase\":\"").append(escape(apiAuto ? TmdbProxy.OFFICIAL_API : apiHost)).append("\","); - sb.append("\"apiAuto\":").append(apiAuto).append(','); - sb.append("\"apiRouteConfigured\":true,"); - sb.append("\"apiRouteMode\":\"").append(apiAuto ? "auto" : TmdbProxy.isOfficialApiHost(apiHost) ? "direct" : "custom").append("\","); - sb.append("\"imageBase\":\"").append(escape(imageAuto ? TmdbProxy.OFFICIAL_IMAGE : imageHost)).append("\","); - sb.append("\"imageAuto\":").append(imageAuto).append(','); - sb.append("\"imageRouteConfigured\":true,"); - sb.append("\"imageRouteMode\":\"").append(imageAuto ? "auto" : TmdbProxy.isOfficialImageHost(imageHost) ? "direct" : "custom").append("\","); + if (!TextUtils.isEmpty(apiHost)) { + sb.append("\"apiBase\":\"").append(escape(apiHost)).append("\","); + } + // Always persist image host when provided so sanitize can normalize scheme/size. + if (!TextUtils.isEmpty(imageHost)) { + sb.append("\"imageBase\":\"").append(escape(imageHost)).append("\","); + } if (!TextUtils.isEmpty(omdbApiKey)) { sb.append("\"omdbApiKey\":\"").append(escape(omdbApiKey)).append("\","); } @@ -553,13 +219,7 @@ public class TmdbSourceDialog { sb.append("\"allowedSites\":").append(toJsonArray(tempAllowedSites)).append(','); sb.append("\"disabledSites\":").append(toJsonArray(tempDisabledSites)); sb.append('}'); - String savedConfig = TmdbConfig.objectFrom(sb.toString()).toJson(); - Setting.putTmdbConfig(savedConfig); - if (apiAuto || imageAuto) { - String warmupApi = apiAuto ? TmdbProxy.AUTO : apiHost; - String warmupImage = imageAuto ? TmdbProxy.AUTO : imageHost; - Task.execute(() -> TmdbConfigTestService.test(apiKey, warmupApi, warmupImage, "", "")); - } + Setting.putTmdbConfig(TmdbConfig.objectFrom(sb.toString()).toJson()); } private static String text(EditText input) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TrackDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TrackDialog.java index b140a5d920..aad00331df 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TrackDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TrackDialog.java @@ -158,7 +158,6 @@ public final class TrackDialog extends BaseBottomSheetDialog implements TrackAda binding.search.setOnClickListener(this::onSearch); binding.ai.setOnClickListener(this::onAiTranslate); binding.realtimeAi.setOnClickListener(this::onRealtimeAi); - binding.realtimeLanguage.setOnClickListener(this::onRealtimeLanguage); binding.aiRegenerate.setOnClickListener(this::onAiRegenerate); binding.subtitle.setOnClickListener(this::onSubtitle); } @@ -176,8 +175,6 @@ public final class TrackDialog extends BaseBottomSheetDialog implements TrackAda binding.search.setVisibility(hasSearch() ? View.VISIBLE : View.GONE); binding.ai.setVisibility(hasAi() ? View.VISIBLE : View.GONE); binding.realtimeAi.setVisibility(hasRealtimeAi() ? View.VISIBLE : View.GONE); - binding.realtimeLanguage.setVisibility(hasRealtimeAi() ? View.VISIBLE : View.GONE); - updateRealtimeLanguage(); updateRealtimeButton(); binding.aiRegenerate.setVisibility(hasAiRegenerate() ? View.VISIBLE : View.GONE); binding.aiStatus.setVisibility(View.GONE); @@ -237,60 +234,29 @@ public final class TrackDialog extends BaseBottomSheetDialog implements TrackAda return; } if (!controller.isModelReady()) { - showRealtimeModelSelection(true); + showRealtimeModelSelection(); return; } enableRealtimeAi(); } private void showRealtimeModelSelection() { - showRealtimeModelSelection(true); - } - - private void onRealtimeLanguage(View view) { - showRealtimeModelSelection(false); - } - - private void showRealtimeModelSelection(boolean startAfterSelection) { String[] labels = ResUtil.getStringArray(R.array.select_realtime_subtitle_model); String[] values = ResUtil.getStringArray(R.array.select_realtime_subtitle_model_value); - String currentValue = Setting.getRealtimeSubtitleModel(); int checked = 0; for (int i = 0; i < values.length; i++) { - if (TextUtils.equals(values[i], currentValue)) { + if (TextUtils.equals(values[i], Setting.getRealtimeSubtitleModel())) { checked = i; break; } } SubtitleSettingsDialog.showRealtimeModel(requireActivity(), labels, checked, false, index -> { if (index < 0 || index >= values.length) return; - boolean changed = !TextUtils.equals(currentValue, values[index]); Setting.putRealtimeSubtitleModel(values[index]); - updateRealtimeLanguage(); - RealtimeSubtitleController controller = RealtimeSubtitleController.get(); - if (controller.isEnabled() || controller.isPreparing()) { - if (changed) controller.switchModel(player); - return; - } - if (startAfterSelection) enableRealtimeAi(); + enableRealtimeAi(); }, null); } - private void updateRealtimeLanguage() { - if (!isUiAlive()) return; - String[] labels = ResUtil.getStringArray(R.array.select_realtime_subtitle_language); - String[] values = ResUtil.getStringArray(R.array.select_realtime_subtitle_model_value); - String selected = Setting.getRealtimeSubtitleModel(); - int index = 0; - for (int i = 0; i < values.length; i++) { - if (TextUtils.equals(values[i], selected)) { - index = i; - break; - } - } - binding.realtimeLanguageText.setText(labels != null && index < labels.length ? labels[index] : selected); - } - private void enableRealtimeAi() { if (PlayerSetting.isAudioPassThrough(player.getPlayerType()) || PlayerSetting.isTunnelingEnabled()) { SubtitleSettingsDialog.showRealtimeCompatibility(requireActivity(), this::disableIncompatibleAudioModes); @@ -328,7 +294,6 @@ public final class TrackDialog extends BaseBottomSheetDialog implements TrackAda @Override public void onStateChanged(RealtimeSubtitleController.State state, String message) { realtimeState = state; - updateRealtimeLanguage(); updateRealtimeButton(); if (!isUiAlive()) return; switch (state) { @@ -337,10 +302,7 @@ public final class TrackDialog extends BaseBottomSheetDialog implements TrackAda if (progress > 0) showAiStatus(getString(R.string.subtitle_realtime_preparing_progress, progress)); else showAiStatus(R.string.subtitle_realtime_preparing); } - case ON -> { - if (TextUtils.isEmpty(message)) showAiStatus(R.string.subtitle_realtime_on); - else showAiMessage(getString(R.string.subtitle_realtime_failed, message)); - } + case ON -> showAiStatus(R.string.subtitle_realtime_on); case ERROR -> showAiMessage(getString(R.string.subtitle_realtime_failed, message)); case OFF -> { if (!aiTranslating) showAiStatus(""); @@ -591,9 +553,6 @@ public final class TrackDialog extends BaseBottomSheetDialog implements TrackAda if (trackGroup.getType() != type) continue; for (int j = 0; j < trackGroup.length; j++) { Format format = trackGroup.getTrackFormat(j); - // One subtitle cannot occupy both roles (the same rule is enforced by MPV). - if (type == C.TRACK_TYPE_TEXT && (secondarySubtitle - ? player.isPrimarySubtitleSelected(format) : player.isSecondarySubtitleSelected(format))) continue; String name = provider.getTrackName(format); Log.d("TrackDialog", "track type=" + type + " id=" + format.id + " label=" + format.label + " lang=" + format.language + " codec=" + format.codecs + " mime=" + format.sampleMimeType + " name=" + name); // Keep the player's native track id with the visible item. Runtime track diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/UpdateSettingsDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/UpdateSettingsDialog.java deleted file mode 100644 index 01e4656378..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/UpdateSettingsDialog.java +++ /dev/null @@ -1,223 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.app.Dialog; -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; -import android.view.Gravity; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; - -import androidx.fragment.app.FragmentActivity; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.databinding.DialogUpdateSettingsBinding; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.update.OciMirror; -import com.fongmi.android.tv.update.UpdateSource; -import com.fongmi.android.tv.update.UpdateUrl; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Util; -import com.google.android.material.tabs.TabLayout; - -public final class UpdateSettingsDialog { - - private static final int TAB_OCI = 0; - private static final int TAB_GITHUB = 1; - - private UpdateSettingsDialog() { - } - - public static void show(FragmentActivity activity) { - Dialog dialog = new Dialog(activity, R.style.Theme_WebHTV_Dialog); - DialogUpdateSettingsBinding binding = DialogUpdateSettingsBinding.inflate(LayoutInflater.from(dialog.getContext())); - State state = State.load(); - dialog.setContentView(binding.getRoot()); - setupTabs(activity, binding, state); - bind(activity, dialog, binding, state); - render(activity, binding, state); - dialog.setCanceledOnTouchOutside(false); - dialog.show(); - configureWindow(activity, dialog, binding); - configureTvFocus(binding, state); - } - - private static void bind(FragmentActivity activity, Dialog dialog, DialogUpdateSettingsBinding binding, State state) { - binding.close.setOnClickListener(view -> dialog.dismiss()); - binding.ociMirror.setOnClickListener(view -> chooseOci(activity, binding, state)); - binding.save.setOnClickListener(view -> save(activity, dialog, binding, state)); - } - - private static void setupTabs(FragmentActivity activity, DialogUpdateSettingsBinding binding, State state) { - binding.sourceTabs.setTabMode(TabLayout.MODE_FIXED); - binding.sourceTabs.setTabGravity(TabLayout.GRAVITY_FILL); - binding.sourceTabs.addTab(binding.sourceTabs.newTab().setText(R.string.update_source_oci), false); - binding.sourceTabs.addTab(binding.sourceTabs.newTab().setText(R.string.update_source_github), false); - binding.sourceTabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { - @Override - public void onTabSelected(TabLayout.Tab tab) { - state.source = tab.getPosition() == TAB_GITHUB ? UpdateSource.GITHUB : UpdateSource.OCI; - renderSource(binding, state); - } - - @Override public void onTabUnselected(TabLayout.Tab tab) { } - @Override public void onTabReselected(TabLayout.Tab tab) { } - }); - int position = UpdateSource.GITHUB.equals(state.source) ? TAB_GITHUB : TAB_OCI; - binding.sourceTabs.selectTab(binding.sourceTabs.getTabAt(position)); - } - - private static void chooseOci(FragmentActivity activity, DialogUpdateSettingsBinding binding, State state) { - OciMirror.Preset[] presets = OciMirror.presets(); - CharSequence[] labels = new CharSequence[presets.length]; - int selected = 0; - for (int i = 0; i < presets.length; i++) { - labels[i] = label(activity, presets[i].label, presets[i].id); - if (presets[i].id.equals(state.ociMirror)) selected = i; - } - ChoiceDialog.showSingle(activity, R.string.update_oci_mirror, labels, selected, which -> { - state.ociMirror = presets[which].id; - renderOci(activity, binding, state); - }); - } - - private static String label(FragmentActivity activity, String label, String id) { - if (OciMirror.DIRECT.equals(id)) return activity.getString(R.string.update_proxy_direct); - if (OciMirror.CUSTOM.equals(id)) return activity.getString(R.string.update_proxy_custom); - return label; - } - - private static void save(FragmentActivity activity, Dialog dialog, DialogUpdateSettingsBinding binding, State state) { - state.ociCustom = text(binding.ociCustom.getText()); - binding.ociCustomLayout.setError(null); - try { - if (UpdateSource.OCI.equals(state.source) && OciMirror.CUSTOM.equals(state.ociMirror)) UpdateUrl.requireHttpsOrigin(state.ociCustom); - } catch (Exception e) { - binding.ociCustomLayout.setError(activity.getString(R.string.update_proxy_invalid)); - return; - } - Setting.putUpdateSource(state.source); - Setting.putUpdateOciMirror(state.ociMirror); - Setting.putUpdateOciMirrorUrl(state.ociCustom); - dialog.dismiss(); - Notify.show(R.string.update_settings_saved); - } - - private static void render(FragmentActivity activity, DialogUpdateSettingsBinding binding, State state) { - binding.ociCustom.setText(state.ociCustom); - renderOci(activity, binding, state); - renderSource(binding, state); - } - - private static void renderSource(DialogUpdateSettingsBinding binding, State state) { - boolean github = UpdateSource.GITHUB.equals(state.source); - binding.ociPanel.setVisibility(github ? View.GONE : View.VISIBLE); - if (Util.isLeanback()) binding.sourceTabs.post(() -> configureTvFocus(binding, state)); - } - - private static void renderOci(FragmentActivity activity, DialogUpdateSettingsBinding binding, State state) { - OciMirror.Preset preset = OciMirror.find(state.ociMirror); - binding.ociMirror.setText(activity.getString(R.string.update_oci_mirror_value, label(activity, preset.label, preset.id))); - binding.ociCustomLayout.setVisibility(OciMirror.CUSTOM.equals(preset.id) ? View.VISIBLE : View.GONE); - } - - private static String text(CharSequence value) { - return value == null ? "" : value.toString().trim(); - } - - private static void configureWindow(FragmentActivity activity, Dialog dialog, DialogUpdateSettingsBinding binding) { - Window window = dialog.getWindow(); - if (window == null) return; - WindowManager.LayoutParams params = window.getAttributes(); - params.width = (int) (DialogSafeArea.width(activity) * (ResUtil.isLand(activity) ? 0.62f : 0.92f)); - params.height = WindowManager.LayoutParams.WRAP_CONTENT; - params.gravity = Gravity.CENTER; - params.dimAmount = 0.58f; - // Reserve the title, save button, margins and padding outside the scroll area. - binding.contentScroll.setMaxHeight(Math.max(1, - (int) (DialogSafeArea.height(activity) * 0.90f) - ResUtil.dp2px(150))); - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.getDecorView().setPadding(0, 0, 0, 0); - window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - window.setAttributes(params); - window.setLayout(params.width, WindowManager.LayoutParams.WRAP_CONTENT); - } - - private static void configureTvFocus(DialogUpdateSettingsBinding binding, State state) { - if (!Util.isLeanback()) return; - tvFocusable(binding.close); - tvFocusable(binding.save); - binding.close.setOnKeyListener((view, keyCode, event) -> event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_DOWN && focusSelectedTab(binding)); - binding.save.setOnKeyListener((view, keyCode, event) -> event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_UP && focusLastControl(binding, state)); - binding.ociMirror.setOnKeyListener((view, keyCode, event) -> focusFromPrimary(binding, keyCode, event)); - configureTabFocus(binding, state); - focusSelectedTab(binding); - } - - private static void configureTabFocus(DialogUpdateSettingsBinding binding, State state) { - if (binding.sourceTabs.getChildCount() == 0) return; - View strip = binding.sourceTabs.getChildAt(0); - if (!(strip instanceof ViewGroup tabs)) return; - for (int i = 0; i < tabs.getChildCount(); i++) { - View tab = tabs.getChildAt(i); - tvFocusable(tab); - tab.setBackgroundResource(R.drawable.selector_mpv_tab_focus); - tab.setOnKeyListener((view, keyCode, event) -> { - if (event.getAction() != KeyEvent.ACTION_DOWN) return false; - if (keyCode == KeyEvent.KEYCODE_DPAD_UP) return binding.close.requestFocus(); - if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) return focusPrimary(binding, state); - return false; - }); - } - } - - private static boolean focusFromPrimary(DialogUpdateSettingsBinding binding, int keyCode, KeyEvent event) { - return event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_UP && focusSelectedTab(binding); - } - - private static boolean focusSelectedTab(DialogUpdateSettingsBinding binding) { - if (binding.sourceTabs.getChildCount() == 0) return false; - View strip = binding.sourceTabs.getChildAt(0); - if (!(strip instanceof ViewGroup tabs)) return false; - int position = Math.max(TAB_OCI, binding.sourceTabs.getSelectedTabPosition()); - if (position >= tabs.getChildCount()) position = TAB_OCI; - return tabs.getChildAt(position).requestFocus(); - } - - private static boolean focusPrimary(DialogUpdateSettingsBinding binding, State state) { - // GitHub 标签下 ociPanel 是 GONE,对隐藏视图 requestFocus 恒返回 false, - // 遥控从标签条往下就走不动,保存按钮再也聚焦不到。此时直接跳到保存。 - if (UpdateSource.GITHUB.equals(state.source)) return binding.save.requestFocus(); - return binding.ociMirror.requestFocus(); - } - - private static boolean focusLastControl(DialogUpdateSettingsBinding binding, State state) { - if (UpdateSource.GITHUB.equals(state.source)) return focusSelectedTab(binding); - if (OciMirror.CUSTOM.equals(state.ociMirror)) return binding.ociCustom.requestFocus(); - return binding.ociMirror.requestFocus(); - } - - private static void tvFocusable(View view) { - view.setFocusable(true); - view.setFocusableInTouchMode(true); - } - - private static final class State { - - private String source; - private String ociMirror; - private String ociCustom; - - private static State load() { - State state = new State(); - state.source = Setting.getUpdateSource(); - state.ociMirror = Setting.getUpdateOciMirror(); - state.ociCustom = Setting.getUpdateOciMirrorUrl(); - return state; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/VideoAspectRatioDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/VideoAspectRatioDialog.java index aee9466848..6972bbdb10 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/VideoAspectRatioDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/VideoAspectRatioDialog.java @@ -15,8 +15,8 @@ import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; import com.fongmi.android.tv.player.VideoAspectMode; import com.fongmi.android.tv.setting.PlayerSetting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Util; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; @@ -63,7 +63,7 @@ public final class VideoAspectRatioDialog { container.setPadding(dp(activity, 20), dp(activity, 8), dp(activity, 20), 0); container.addView(row, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.aspect_ratio_title) .setMessage(R.string.aspect_ratio_hint) .setView(container) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingRecordSyncDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingRecordSyncDialog.java index c4f71dfd17..f626bb1abd 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingRecordSyncDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingRecordSyncDialog.java @@ -25,8 +25,6 @@ import com.fongmi.android.tv.playback.PlaybackRemoteSyncStore; import com.fongmi.android.tv.playback.PlaybackWebhookStore; import com.fongmi.android.tv.playback.RemoteSyncConfig; import com.fongmi.android.tv.playback.ViewingRecordSyncStore; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.button.MaterialButton; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -59,7 +57,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -69,8 +67,8 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = DialogSafeArea.width(requireActivity(), window); - int screenHeight = DialogSafeArea.height(requireActivity(), window); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); @@ -141,7 +139,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { } private View sectionTitle(int text) { - MaterialTextView view = text(getString(text), 12, ThemeController.current().colorOnSurfaceVariant(), true); + MaterialTextView view = text(getString(text), 12, Color.parseColor("#5F6368"), true); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.topMargin = binding.content.getChildCount() == 0 ? 0 : dp(12); view.setLayoutParams(params); @@ -151,7 +149,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { private View switchRow(String title, String detail, boolean enabled, View.OnClickListener listener) { LinearLayoutCompat root = rowRoot(); LinearLayoutCompat header = header(root); - header.addView(text(title, 15, ThemeController.current().colorOnSurface(), true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + header.addView(text(title, 15, Color.BLACK, true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); MaterialButton toggle = actionButton(enabled ? R.string.setting_enable : R.string.setting_disable, enabled, false); toggle.setOnClickListener(listener); header.addView(toggle, new LinearLayoutCompat.LayoutParams(dp(92), dp(36))); @@ -165,7 +163,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { root.setClickable(true); root.setOnClickListener(listener); LinearLayoutCompat header = header(root); - header.addView(text(title, 15, ThemeController.current().colorOnSurface(), true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + header.addView(text(title, 15, Color.BLACK, true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); MaterialButton action = actionButton(actionText, true, false); action.setOnClickListener(listener); header.addView(action, new LinearLayoutCompat.LayoutParams(dp(92), dp(36))); @@ -208,7 +206,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { } private void addDetail(LinearLayoutCompat root, String value) { - MaterialTextView view = text(value, 12, ThemeController.current().colorOnSurfaceVariant(), false); + MaterialTextView view = text(value, 12, Color.parseColor("#5F6368"), false); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.topMargin = dp(4); root.addView(view, params); @@ -232,7 +230,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { button.setMinimumHeight(dp(36)); button.setPadding(dp(6), 0, dp(6), 0); ColorStateList bg = ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_bg : R.color.dialog_outlined_button_bg); - ColorStateList fg = danger ? ColorStateList.valueOf(ThemeController.current().colorError()) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); + ColorStateList fg = danger ? ColorStateList.valueOf(Color.parseColor("#B3261E")) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); button.setBackgroundTintList(bg); button.setTextColor(fg); if (!tonal) { @@ -242,24 +240,10 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { return button; } - /** - * The row card fill and stroke follow the active palette. - * - *

These were fixed light literals ({@code #F5F6F7} fill, {@code #DADCE0} stroke), which - * no {@code webhtv_color_*} role ever equals, so {@code ThemeBinder}'s exact-match rewrite - * deliberately skipped them: under the night table the dialog panel went dark while every - * row stayed a near-white card with black text. {@code colorSurfaceContainerLow} is the - * semantic "card on a dialog panel" surface ({@code #F2F3F9} day / {@code #181C20} night) - * and {@code colorOutlineVariant} its paired hairline. - * - *

The previous two stroke greys ({@code #DADCE0} usable / {@code #E2E5E8} not) differed - * by 1.09:1, which carried no readable information - usability is already stated by the - * badge text - so both branches now share one semantic outline. - */ private GradientDrawable rowBackground() { GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(ThemeController.current().colorSurfaceContainerLow()); - drawable.setStroke(dp(1), ThemeController.current().colorOutlineVariant()); + drawable.setColor(Color.parseColor("#F5F6F7")); + drawable.setStroke(dp(1), Color.parseColor("#DADCE0")); drawable.setCornerRadius(dp(6)); return drawable; } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingReportRangeDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingReportRangeDialog.java index c0ff8259e1..6e33958221 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingReportRangeDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingReportRangeDialog.java @@ -13,8 +13,8 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogViewingReportRangeBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.viewing.ViewingReportRange; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.Arrays; @@ -35,7 +35,7 @@ public class ViewingReportRangeDialog { binding = DialogViewingReportRangeBinding.inflate(activity.getLayoutInflater()); adapter = new RangeAdapter(); binding.recycler.setAdapter(adapter); - dialog = new WebHtvAlertDialogBuilder(activity) + dialog = new MaterialAlertDialogBuilder(activity) .setTitle(R.string.viewing_report_select_range) .setView(binding.getRoot()) .setNegativeButton(R.string.dialog_negative, null) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDebugDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDebugDialog.java index d05bb90924..8ead5f0967 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDebugDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDebugDialog.java @@ -25,7 +25,6 @@ import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.databinding.DialogWebHomeExtensionDebugBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.web.HomeWebController; @@ -76,7 +75,7 @@ public class WebHomeExtensionDebugDialog extends BaseAlertDialog implements Home @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -88,8 +87,8 @@ public class WebHomeExtensionDebugDialog extends BaseAlertDialog implements Home Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = DialogSafeArea.width(requireActivity(), window); - int screenHeight = DialogSafeArea.height(requireActivity(), window); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); params.width = (int) (screenWidth * (ResUtil.isLand(requireContext()) ? 0.96f : 0.98f)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDialog.java index adf67a5373..f251cfb9d8 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDialog.java @@ -37,7 +37,6 @@ import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.databinding.DialogWebHomeExtensionBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.SettingClipboardOverlay; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.Notify; @@ -97,7 +96,7 @@ public class WebHomeExtensionDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -109,8 +108,8 @@ public class WebHomeExtensionDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = DialogSafeArea.width(requireActivity(), window); - int screenHeight = DialogSafeArea.height(requireActivity(), window); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeThemeDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeThemeDialog.java index 39546bca44..162d6e6e41 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeThemeDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeThemeDialog.java @@ -15,7 +15,6 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.web.WebHomeTarget; @@ -39,8 +38,8 @@ public final class WebHomeThemeDialog { activity.getString(R.string.setting_web_home_theme_eclipse), activity.getString(R.string.setting_web_home_theme_custom) }; - MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder( - activity, R.style.Theme_WebHTV_Dialog) + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder( + activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.setting_web_home_theme) .setNegativeButton(R.string.dialog_negative, null) .setSingleChoiceItems(items, selectedMode(), (dialog, which) -> { @@ -97,7 +96,7 @@ public final class WebHomeThemeDialog { int message = action == WebThemeManifestRollback.Action.ROLLBACK ? R.string.setting_web_home_theme_rollback_message : R.string.setting_web_home_theme_retry_message; - AlertDialog alert = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog alert = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.setting_web_home_theme_recovery) .setMessage(message) .setNegativeButton(R.string.dialog_negative, null) @@ -149,7 +148,7 @@ public final class WebHomeThemeDialog { container.setPadding(padding, 0, padding, 0); container.addView(input, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - AlertDialog alert = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog alert = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.setting_web_home_theme_custom) .setView(container) .setNegativeButton(R.string.dialog_negative, null) @@ -184,7 +183,7 @@ public final class WebHomeThemeDialog { } private static void showRemoteConfirmation(Activity activity, String url, Runnable onChanged, AlertDialog editor) { - AlertDialog warning = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) + AlertDialog warning = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.setting_web_home_theme_remote_warning_title) .setMessage(activity.getString(R.string.setting_web_home_theme_remote_warning_message, host(url))) .setNegativeButton(R.string.dialog_negative, null) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/EpisodeSeasonSnapshot.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/EpisodeSeasonSnapshot.java index 98fa3e0610..a87119a48a 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/EpisodeSeasonSnapshot.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/EpisodeSeasonSnapshot.java @@ -51,31 +51,6 @@ public final class EpisodeSeasonSnapshot { return structureFingerprint(episodes, null); } - /** - * Order-insensitive shape digest for manual bindings. - *

TMDB 富集会在会话中途重排选集(见 TmdbEpisodeSorter),排序前后按位置算出的摘要必然不同, - * 于是手动季度绑定每次重进都会被当成"源结构变了"而丢弃。这里比较 (集号, 名称) 的多重集合, - * 既不受排序影响,又仍能识别新增、删除与改名。 - */ - public static String stableStructureFingerprint(List episodes) { - MessageDigest digest = sha256(); - update(digest, "episodes-stable"); - if (episodes == null) { - update(digest, ""); - return hex(digest.digest()); - } - update(digest, Integer.toString(episodes.size())); - List entries = new ArrayList<>(episodes.size()); - for (Episode episode : episodes) { - String name = episode == null ? "" : Objects.toString(episode.getName(), ""); - int number = episode == null ? -1 : episode.getNumber(); - entries.add(number + "" + name); - } - entries.sort(Comparator.naturalOrder()); - for (String entry : entries) update(digest, entry); - return hex(digest.digest()); - } - /** Persistent route digest; includes TMDB season counts used by automatic slicing. */ public static String structureFingerprint(List episodes, Map seasonCounts) { MessageDigest digest = sha256(); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/PlayerControlFocusHelper.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/PlayerControlFocusHelper.java index a22870660e..bb7a9097e2 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/PlayerControlFocusHelper.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/PlayerControlFocusHelper.java @@ -4,26 +4,18 @@ import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; -import com.fongmi.android.tv.R; - public final class PlayerControlFocusHelper { private PlayerControlFocusHelper() { } - /** - * 明确传入的目标优先;只有当调用方没有给出可用目标时,才保留 root 内已有的焦点。 - * 若「root 里已经有焦点」就提前返回,唤出控制栏时想恢复「上次选择的那个地方」 - * 会被 root 中恰好持有的其它焦点静默覆盖,焦点看起来“自己跑到别的按钮上”。 - */ public static boolean ensureFocus(View root, View preferred) { if (!isVisible(root)) return false; - View target = firstFocusable(preferred); - if (target != null && isDescendant(root, target)) return target.requestFocus(); View current = root.findFocus(); if (isFocusable(current) && isDescendant(root, current)) return true; - View fallback = firstFocusable(root); - return fallback != null && fallback.requestFocus(); + View target = firstFocusable(preferred); + if (target == null || !isDescendant(root, target)) target = firstFocusable(root); + return target != null && target.requestFocus(); } public static boolean handleKey(View root, View preferred, KeyEvent event) { @@ -32,7 +24,6 @@ public final class PlayerControlFocusHelper { ensureFocus(root, preferred); return true; } - syncSeekReturnTarget(root); if (!isDirectionKey(event) || event.getAction() != KeyEvent.ACTION_DOWN) return false; View focus = root.findFocus(); View next = focus == null ? null : focus.focusSearch(direction(event)); @@ -40,52 +31,6 @@ public final class PlayerControlFocusHelper { return false; } - /** - * 进度条按 UP 时应该回到的控件。 - * - *

焦点已经落在进度条自己身上时返回 {@code null}:此时必须保留上一次的「回来目标」, - * 否则目标会被改写成 seek 自身,UP 就只能靠几何搜索乱跳。 - * - *

判定必须覆盖「焦点在 seek 子树内」与「焦点是 seek 的祖先」两个方向:进度条的容器 - * {@code CustomSeekView} 自身在构造函数里 {@code setFocusable(true)},只是当前它的 - * {@code requestFocus} 会把请求转发给 {@code timeBar},所以容器自己拿不到焦点。一旦上游把该 - * 转发改成「失败就回退到 {@code super.requestFocus}」,容器就会成为可达的焦点持有者; - * 那时若把它写成 UP 目标,焦点会被转发回进度条,表现为「按 UP 没反应」。这里把两个方向 - * 都拒掉,使该不变不依赖上游委托细节(由 - * {@code containerNeverHoldsFocusBecauseRequestFocusDelegatesToTheSeekBar} 锁住前提)。 - * - *

焦点不在控制栏内、或者控件没有 id / 不可聚焦时同样返回 {@code null}(保留现状)。 - */ - public static View seekReturnTarget(View seek, View controls, View focused) { - if (seek == null || controls == null || focused == null) return null; - if (focused.getId() == View.NO_ID || !focused.isFocusable()) return null; - if (isDescendant(seek, focused) || isDescendant(focused, seek)) return null; - if (!isDescendant(controls, focused)) return null; - return focused; - } - - /** - * 让进度条的 UP 目标跟着「刚才停留的那颗按钮」走。 - * - *

电视端控制栏里从按钮按 DOWN 落到进度条后,再按 UP 应当回到用户刚才停留的那颗按钮; - * 而 FocusFinder 只认进度条自己({@code timeBar})的 nextFocusUpId,所以每收到一个遥控键、 - * 且焦点还停在控制栏的某颗控件上时,就把它改写成那颗控件。焦点已经在进度条上时保持不动 - * (见 {@link #seekReturnTarget})。目标之后被隐藏时 FocusFinder 会跳过它并回退到几何搜索, - * 因此不需要清理失效记忆。 - */ - public static void syncSeekReturnTarget(View root) { - if (root == null) return; - syncSeekReturnTarget(root, root.findFocus()); - } - - static void syncSeekReturnTarget(View root, View focus) { - if (root == null) return; - View timeBar = root.findViewById(R.id.timeBar); - if (timeBar == null) return; - View target = seekReturnTarget(timeBar, root, focus); - if (target != null) timeBar.setNextFocusUpId(target.getId()); - } - public static boolean containsFocus(View root) { if (!isVisible(root)) return false; View focus = root.findFocus(); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/SourceEpisodeSeasonCache.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/SourceEpisodeSeasonCache.java index 1d259c6c5a..ac815637e7 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/SourceEpisodeSeasonCache.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/SourceEpisodeSeasonCache.java @@ -5,9 +5,7 @@ import com.fongmi.android.tv.bean.Flag; import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.bean.Vod; -import java.util.ArrayList; import java.util.IdentityHashMap; -import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.ToIntFunction; @@ -31,9 +29,8 @@ public final class SourceEpisodeSeasonCache { if (flag == null || flag.getEpisodes() == null) return -1; Integer cached = flagSeasons.get(flag); if (cached != null) return cached; - List episodes = snapshot(flag.getEpisodes()); Integer season = null; - for (Episode episode : episodes) { + for (Episode episode : flag.getEpisodes()) { int candidate = episodeResolver.applyAsInt(episode); if (candidate < 0) continue; if (season != null && season != candidate) { @@ -80,7 +77,7 @@ public final class SourceEpisodeSeasonCache { public boolean hasMixedSeasons(Flag flag) { if (flag == null || flag.getEpisodes() == null) return false; Integer season = null; - for (Episode episode : snapshot(flag.getEpisodes())) { + for (Episode episode : flag.getEpisodes()) { int candidate = episodeResolver.applyAsInt(episode); if (candidate < 0) continue; if (season != null && season != candidate) return true; @@ -89,12 +86,6 @@ public final class SourceEpisodeSeasonCache { return false; } - private static List snapshot(List episodes) { - synchronized (episodes) { - return new ArrayList<>(episodes); - } - } - private static int resolveEpisodeSeason(Episode episode) { int candidate = EpisodeSeasonPolicy.resolveExplicitSourceSeason(episode == null ? "" : episode.getName()); if (candidate >= 0) return candidate; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbBundle.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbBundle.java deleted file mode 100644 index 3b8c5969de..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbBundle.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.TmdbPerson; -import com.google.gson.JsonObject; - -import java.util.List; -import java.util.Map; - -/** UI-facing TMDB data assembled from source payload or the existing network path. */ -public record TmdbBundle( - TmdbItem item, - JsonObject detail, - List cast, - List creators, - List photos, - List related, - List seasons, - Map seasonCounts, - Map> seasonEpisodes, - Map> seasonCast, - Map> seasonPhotos -) { - - public boolean identityMatches(TmdbItem other) { - return item != null && other != null && item.getTmdbId() > 0 - && item.getTmdbId() == other.getTmdbId() - && item.getMediaType().equalsIgnoreCase(other.getMediaType()); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbCinemaTheme.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbCinemaTheme.java index 37c3785fee..ac58fdd3fa 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbCinemaTheme.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbCinemaTheme.java @@ -40,23 +40,23 @@ public final class TmdbCinemaTheme { private static Palette light() { return new Palette( - 0xFFEBE3DA, - 0x99FFFFFF, - 0xFFF6F1EA, + 0xFFF4F7FA, + 0xBFFFFFFF, + 0xFFE7EDF3, 0xD9FFFFFF, - 0xFFFFE9CC, - 0x33806E5F, - 0x66806E5F, + 0xFFE5F7EC, + 0x33424B57, + 0x66424B57, 0xFF12202D, - 0xCC1D2A38, - 0x991D2A38, - 0xE61D2A38, - 0xFFA8702F, - 0xFFB7802F, - 0xBFEBE3DA, + 0xCC12202D, + 0x9912202D, + 0xE612202D, + 0xFF1D8F5A, + 0xFF20B866, + 0xBFF4F7FA, 0xD9FFFFFF, - 0x33806E5F, - 0x1F1D2A38, + 0x33424B57, + 0x1F12202D, 0xD9FFFFFF ); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbDetailPrefetch.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbDetailPrefetch.java index 44aa397516..9a3e4e3ce2 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbDetailPrefetch.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbDetailPrefetch.java @@ -11,23 +11,17 @@ import java.util.List; import java.util.Locale; import java.util.concurrent.Callable; -/** - * TMDB 详情单槽预取:按规范化后的 {@code mediaType + tmdbId} 标识请求,负责复用、替换与取消。 - * - *

两个使用方:{@code TmdbUIAdapter}(视频详情页的原生详情路径)与 - * {@code TmdbDetailActivity}(原生 TMDB 详情页,站点详情与 TMDB 详情并行取回)。 - */ -public final class TmdbDetailPrefetch { +final class TmdbDetailPrefetch { private final ListeningExecutorService executor; private Identity identity; private ListenableFuture future; - public TmdbDetailPrefetch(ListeningExecutorService executor) { + TmdbDetailPrefetch(ListeningExecutorService executor) { this.executor = executor; } - public synchronized StartResult start(TmdbItem item, Callable task) { + synchronized StartResult start(TmdbItem item, Callable task) { Identity next = Identity.from(item); if (next == null) return null; if (next.equals(identity) && future != null && !future.isCancelled()) { @@ -40,7 +34,7 @@ public final class TmdbDetailPrefetch { return new StartResult(state, future); } - public synchronized ListenableFuture take(TmdbItem item) { + synchronized ListenableFuture take(TmdbItem item) { Identity expected = Identity.from(item); if (expected == null || !expected.equals(identity)) return null; ListenableFuture result = future; @@ -49,7 +43,7 @@ public final class TmdbDetailPrefetch { return result; } - public synchronized void cancel() { + synchronized void cancel() { cancelLocked(); } @@ -59,13 +53,13 @@ public final class TmdbDetailPrefetch { future = null; } - public enum StartState { + enum StartState { STARTED, REUSED, REPLACED } - public static final class StartResult { + static final class StartResult { private final StartState state; private final ListenableFuture future; @@ -75,36 +69,36 @@ public final class TmdbDetailPrefetch { this.future = future; } - public StartState getState() { + StartState getState() { return state; } - public ListenableFuture getFuture() { + ListenableFuture getFuture() { return future; } } - public static final class Result { + static final class Result { private final TmdbItem item; private final JsonObject detail; private final List cast; - public Result(TmdbItem item, JsonObject detail, List cast) { + Result(TmdbItem item, JsonObject detail, List cast) { this.item = item; this.detail = detail; this.cast = cast == null ? new ArrayList<>() : new ArrayList<>(cast); } - public TmdbItem getItem() { + TmdbItem getItem() { return item; } - public JsonObject getDetail() { + JsonObject getDetail() { return detail; } - public List getCast() { + List getCast() { return new ArrayList<>(cast); } } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbEpisodeGridPolicy.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbEpisodeGridPolicy.java index 80d2515c0f..34f95817e6 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbEpisodeGridPolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbEpisodeGridPolicy.java @@ -46,33 +46,6 @@ public final class TmdbEpisodeGridPolicy { return rowStart < lastRowStart ? itemCount - 1 : NO_FOCUS_TARGET; } - /** - * 选集网格内的水平(左右)焦点目标。只在本行内移动,行首向左、行尾向右都返回 - * {@link #NO_FOCUS_TARGET}:集数卡片根视图不可聚焦、也不声明水平 nextFocus, - * 系统 FocusFinder 找不到水平邻居时会让焦点跳出选集网格(例如落到上方的“简介”按钮)。 - */ - public static int horizontalFocusTarget(int position, int spanCount, int itemCount, boolean right) { - if (position < 0 || spanCount <= 0 || itemCount <= 0) return NO_FOCUS_TARGET; - int target = right ? position + 1 : position - 1; - int rowStart = position / spanCount * spanCount; - int rowEnd = Math.min(rowStart + spanCount, itemCount) - 1; - return target >= rowStart && target <= rowEnd ? target : NO_FOCUS_TARGET; - } - - /** - * 长按连续移动的基准位置。 - * - *

按住方向键时系统会持续发 {@code ACTION_DOWN}({@code repeatCount > 0}),而 - * {@code requestFocus()} 是异步的:焦点还没落到上一格时若仍以「当前焦点所在卡片」为基准, - * 每一个重复事件都会算出同一个目标,表现就是长按只前进一格(用户报告“长按遥控器不能快速选集”)。 - * 这里改用「上一次请求的目标」作为基准,让每个重复事件都真正推进一格;焦点一旦落地, - * 基准与焦点重合,状态自动收敛,不需要额外清理。 - */ - public static int holdBasePosition(int focusedPosition, int pendingTarget) { - if (pendingTarget < 0 || pendingTarget == focusedPosition) return focusedPosition; - return pendingTarget; - } - public static int nativeGridCardHeightDp(boolean phoneWidth) { return phoneWidth ? NATIVE_MOBILE_GRID_CARD_HEIGHT_DP : NATIVE_GRID_CARD_HEIGHT_DP; } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbMatcher.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbMatcher.java index 02db2c011c..c12a05c80f 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbMatcher.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbMatcher.java @@ -8,16 +8,13 @@ import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.service.TmdbService; import com.fongmi.android.tv.setting.Setting; import com.github.catvod.crawler.SpiderDebug; -import com.github.catvod.utils.Trans; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import java.util.ArrayList; -import java.util.IdentityHashMap; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Objects; import java.util.concurrent.CancellationException; import java.util.regex.Matcher; @@ -141,11 +138,11 @@ public class TmdbMatcher { } private TmdbItem chooseStrictMatch(List items, String keyword, Vod vod, int sourceYear) { - String normalized = compareKey(keyword); + String normalized = normalize(keyword); int season = sourceSeasonNumber(keyword, vod); List matches = new ArrayList<>(); for (TmdbItem item : items) { - if (!compareKey(item.getTitle()).equals(normalized)) continue; + if (!normalize(item.getTitle()).equals(normalized)) continue; if (sourceYear <= 0 || tmdbItemYear(item) == sourceYear || tmdbSeasonYearMatches(item, season, sourceYear)) matches.add(item); } if (matches.isEmpty()) return null; @@ -156,12 +153,12 @@ public class TmdbMatcher { private TmdbItem chooseContainedYearMatch(List items, String keyword, Vod vod, int sourceYear) { if (sourceYear <= 0) return null; - String normalized = compareKey(keyword); + String normalized = normalize(keyword); if (normalized.length() < 4) return null; for (TmdbItem item : items) { int itemYear = tmdbItemYear(item); if (itemYear != sourceYear) continue; - String title = compareKey(removeYearFromTitle(item.getTitle(), itemYear)); + String title = normalize(removeYearFromTitle(item.getTitle(), itemYear)); if (Math.min(title.length(), normalized.length()) < 4) continue; if (!title.contains(normalized) && !normalized.contains(title)) continue; if (isUnwantedSplitSeasonMatch(item, keyword, vod)) continue; @@ -171,15 +168,15 @@ public class TmdbMatcher { } private TmdbItem chooseSmartMatch(List items, String keyword, Vod vod, int sourceYear) { - String normalized = compareKey(keyword); + String normalized = normalize(keyword); TmdbItem sameTitle = null; TmdbItem closeYear = null; for (TmdbItem item : items) { - String title = compareKey(item.getTitle()); + String title = normalize(item.getTitle()); if (TextUtils.isEmpty(title) || TextUtils.isEmpty(normalized)) continue; int itemYear = tmdbItemYear(item); boolean titleMatch = title.equals(normalized); - boolean cleanedTitleMatch = compareKey(removeYearFromTitle(item.getTitle(), itemYear)).equals(normalized); + boolean cleanedTitleMatch = normalize(removeYearFromTitle(item.getTitle(), itemYear)).equals(normalized); if (!titleMatch && !cleanedTitleMatch) continue; if (sourceYear <= 0) { if (isUnwantedSplitSeasonMatch(item, keyword, vod)) continue; @@ -203,11 +200,8 @@ public class TmdbMatcher { private void sortSearchResults(List items, String keyword, int sourceYear) { if (items == null || items.size() < 2) return; - // 相似度里要做简繁归一,属于 O(n) 逐字转换;先在比较器外算一次,别在每次比较里重复转。 - Map scores = new IdentityHashMap<>(); - for (TmdbItem item : items) scores.put(item, titleSimilarityScore(item, keyword)); items.sort((a, b) -> { - int compare = Integer.compare(scores.getOrDefault(b, 0), scores.getOrDefault(a, 0)); + int compare = Integer.compare(titleSimilarityScore(b, keyword), titleSimilarityScore(a, keyword)); if (compare != 0) return compare; compare = compareYear(a, b, sourceYear); if (compare != 0) return compare; @@ -219,8 +213,8 @@ public class TmdbMatcher { private int titleSimilarityScore(TmdbItem item, String keyword) { int keywordYear = firstYear(keyword); - String query = compareKey(removeYearFromTitle(keyword, keywordYear)); - String title = compareKey(removeYearFromTitle(item.getTitle(), tmdbItemYear(item))); + String query = normalize(removeYearFromTitle(keyword, keywordYear)); + String title = normalize(removeYearFromTitle(item.getTitle(), tmdbItemYear(item))); if (TextUtils.isEmpty(query) || TextUtils.isEmpty(title)) return 0; if (title.equals(query)) return 1000; if (title.contains(query) || query.contains(title)) { @@ -323,9 +317,8 @@ public class TmdbMatcher { clean = clean.replaceAll("(?i)\\b(HD|4K|8K|1080P|2160P|720P|HDR|HDR10|DV|BluRay|WEB[- ]?DL|HDTV|BDRip|Remux|HEVC|H\\.?265|H\\.?264|x265|x264)\\b", ""); clean = clean.replaceAll("(?i)(?为什么需要它:站源标题在繁体界面下会被 {@code Vod.trans()} 逐字转成繁体, - * 而 TMDB 数据按 {@code TmdbConfig.language}(默认 zh-CN)返回简体标题。 - * 两侧字形不同,任何逐字符比较都会失配,所以比较前必须压成同一字形。 - * - *

归一化结果只用于比较,不能当作搜索关键词回传给 TMDB: - * 实测把归一化产物当查询词会直接搜出 0 条结果。 - */ - private String compareKey(String text) { - return normalize(canonicalTitle(text)); - } - - /** - * 把任意简/繁标题统一转成繁体字形。 - * - *

必须显式传 {@code pass = false}:{@code Trans.s2t(String)} 会按当前界面语言短路, - * 简体态下直接返回原文,等于没归一,测试也会假绿。 - * - *

方向只能单向转繁,不能转回简体:{@code T2S} 表在 10 组「多简对一繁」的字上是多对一 - * 坍缩(余/馀→餘),反向转换会把「余罪/强势/线上」这类常用简体词改写成异体字。 - */ - private static String canonicalTitle(String text) { - return Trans.s2t(false, Objects.toString(text, "")); - } - private String removeNoiseBrackets(String text) { Matcher matcher = Pattern.compile("[\\[【「『((]([^\\]】」』))]{1,40})[\\]】」』))]").matcher(Objects.toString(text, "")); StringBuffer buffer = new StringBuffer(); @@ -521,7 +487,7 @@ public class TmdbMatcher { String value = normalize(text); if (TextUtils.isEmpty(value)) return true; return value.matches("(?i).*(4k|8k|1080p|2160p|720p|hdr|hdr10|dv|webdl|bluray|bdrip|remux|hevc|h265|h264|x265|x264|aac|dts|ddp|atmos|nf|netflix|amzn|dsnp).*") - || value.matches(".*(真彩|臻彩|高码|高码率|高碼|高碼率|无水印|無水印|无台标|無台標|国语|國語|国配|國配|国粤|國粵|粤语|粵語|中字|字幕|内封|內封|简繁|簡繁|双语|雙語|官中|杜比|合集|全集|完结|完結|未删减|未刪減|加长版|加長版|修复版|修復版).*") + || value.matches(".*(真彩|臻彩|高码|高码率|无水印|无台标|国语|国配|国粤|粤语|中字|字幕|内封|简繁|双语|官中|杜比|合集|全集|完结|未删减|加长版|修复版).*") || value.matches(".*(国语版|国配版|普通话版|粤语版|台语版|闽南语版|原声版|配音版|中字版|字幕版|台版|台灣版|台湾版|港版|港澳版|大陆版|內地版|内地版|中国版|中國版|泰版|泰国版|泰國版|韩版|韩国版|韓國版|日版|日本版|美版|美国版|美國版|英版|英国版).*"); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbRowFocusChain.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbRowFocusChain.java deleted file mode 100644 index 88104e68ad..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbRowFocusChain.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import java.util.ArrayList; -import java.util.List; - -/** - * 原生增强播放页 TMDB 区块的纵向焦点链计算。 - * - * 只把「可见且非空」的行纳入链条,其余行被完全跳过:某行没有数据(或标签与行一起被隐藏)时, - * 它的上下邻居直接相连——剧照下面没有海报就落到相关视频,相关视频也没有就落到主创团队。 - * 这样焦点永远交给一个真实存在的行,而不是先跳到不可见的 View 再退回几何搜索。 - */ -public final class TmdbRowFocusChain { - - /** 表示该方向没有邻居;调用方按上下文回退到列表首/末项或详情按钮。 */ - public static final int NO_NEIGHBOR = -1; - - private TmdbRowFocusChain() { - } - - /** - * 计算每个候选行的纵向邻居。 - * - * @param present 每个候选行的「可见且非空」标记,数组下标顺序即屏幕上的视觉顺序 - * @return 与 {@code present} 等长的数组,第 i 项为 {@code {upIndex, downIndex}}; - * 不参与链条的行返回 {@code {NO_NEIGHBOR, NO_NEIGHBOR}} - */ - public static int[][] link(boolean[] present) { - if (present == null) return new int[0][]; - int[][] result = new int[present.length][2]; - List active = new ArrayList<>(); - for (int index = 0; index < present.length; index++) { - // Java 的 int 默认值是 0,必须先铺上 NO_NEIGHBOR, - // 否则被跳过的行会误报邻居为第 0 行。 - result[index][0] = NO_NEIGHBOR; - result[index][1] = NO_NEIGHBOR; - if (present[index]) active.add(index); - } - for (int position = 0; position < active.size(); position++) { - int row = active.get(position); - result[row][0] = position == 0 ? NO_NEIGHBOR : active.get(position - 1); - result[row][1] = position == active.size() - 1 ? NO_NEIGHBOR : active.get(position + 1); - } - return result; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAdapter.java deleted file mode 100644 index 7ed1eea92a..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAdapter.java +++ /dev/null @@ -1,498 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import com.fongmi.android.tv.utils.TmdbProxy; -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.TmdbPerson; -import com.fongmi.android.tv.bean.TmdbSourceDetail; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.bean.TmdbVideo; -import com.fongmi.android.tv.bean.Vod; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.utils.TmdbLanguagePolicy; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; - -/** Converts a validated C16 payload into the existing UI bundle without network access. */ -public final class TmdbSourceAdapter { - - private static final int MAX_CAST = 18; - private static final int MAX_CREATORS = 12; - private static final int MAX_PHOTOS = 24; - private static final int MAX_RELATED = 40; - - @Nullable - public static TmdbBundle toBundle(@Nullable TmdbSourcePayload payload, @Nullable Vod vod, @Nullable TmdbConfig config) { - TmdbSourcePayload valid = TmdbSourcePayloadParser.parse(payload); - if (valid == null) return null; - TmdbConfig effectiveConfig = config == null ? new TmdbConfig().sanitize() : config.sanitize(); - JsonObject detail = new TmdbSourceDetail(valid.getDetailJson()).object(); - if (!detail.has("media_type") && !valid.getMediaType().isEmpty()) detail.addProperty("media_type", valid.getMediaType()); - TmdbItem item = item(valid.getTmdbId(), valid.getMediaType(), detail, vod, effectiveConfig, valid.hasCapability(TmdbSourceCapabilityPlanner.CORE)); - List cast = cast(detail, effectiveConfig); - List creators = creators(detail, effectiveConfig); - List photos = photos(detail, effectiveConfig); - List related = related(valid.getMediaType(), detail, effectiveConfig); - SeasonData seasons = seasons(valid.getTmdbId(), valid.getSeasonNumber(), detail, effectiveConfig); - return new TmdbBundle(item, detail, cast, creators, photos, related, seasons.numbers, seasons.counts, seasons.episodes, seasons.cast, seasons.photos); - } - - public static TmdbBundle fromNetwork(@Nullable TmdbItem sourceItem, @Nullable JsonObject sourceDetail, @Nullable TmdbConfig config) { - if (sourceItem == null) return null; - TmdbConfig effectiveConfig = config == null ? new TmdbConfig().sanitize() : config.sanitize(); - JsonObject detail = sourceDetail == null ? new JsonObject() : sourceDetail.deepCopy(); - String mediaType = normalizeMediaType(sourceItem.getMediaType()); - if (mediaType.isEmpty()) mediaType = sourceItem.getMediaType(); - TmdbItem item = item(sourceItem.getTmdbId(), mediaType, detail, null, effectiveConfig, true); - item = fallbackItem(item, sourceItem); - List cast = cast(detail, effectiveConfig); - List creators = creators(detail, effectiveConfig); - List photos = photos(detail, effectiveConfig); - List related = related(mediaType, detail, effectiveConfig); - SeasonData seasons = seasons(sourceItem.getTmdbId(), 0, detail, effectiveConfig); - return new TmdbBundle(item, detail, cast, creators, photos, related, seasons.numbers, seasons.counts, seasons.episodes, seasons.cast, seasons.photos); - } - - public static List videos(@Nullable JsonObject detail, @Nullable String mediaType, int seasonNumber, int episodeNumber, @Nullable String language) { - List result = new ArrayList<>(); - if (detail == null) return result; - String normalized = normalizeMediaType(mediaType); - JsonObject season = findSeason(detail, seasonNumber); - JsonObject episode = findEpisode(season, episodeNumber); - if ("movie".equals(normalized) || seasonNumber < 0) { - addVideos(result, object(detail, "videos"), TmdbVideo.Scope.TITLE, -1, -1); - } else { - addVideos(result, object(season, "videos"), TmdbVideo.Scope.SEASON, seasonNumber, -1); - addVideos(result, object(episode, "videos"), TmdbVideo.Scope.EPISODE, seasonNumber, episodeNumber); - addVideos(result, object(detail, "videos"), TmdbVideo.Scope.TITLE, -1, -1); - } - return TmdbVideo.mergeAndRank(result, language == null ? "" : language, 12); - } - - private static TmdbItem item(int tmdbId, String mediaType, JsonObject detail, @Nullable Vod vod, TmdbConfig config, boolean coreComplete) { - boolean tv = "tv".equals(mediaType); - String titleKey = tv ? "name" : "title"; - String title = TmdbLanguagePolicy.bestDisplayValue(firstString(detail, tv ? new String[]{"name", "title"} : new String[]{"title", "name"}), - array(object(detail, "translations"), "translations"), titleKey, TmdbLanguagePolicy.requestLanguage(config, Setting.getLanguage())); - if (title.isEmpty() && vod != null && coreComplete) title = vod.getName(); - String date = firstString(detail, tv ? new String[]{"first_air_date", "release_date"} : new String[]{"release_date", "first_air_date"}); - double vote = number(detail, "vote_average", 0d); - String subtitle = subtitle(date, vote); - String poster = imageUrl(config.getImageBase(), string(detail, "poster_path")); - String backdrop = imageUrl(config.getBackdropBase(), string(detail, "backdrop_path")); - return new TmdbItem( - tmdbId, - mediaType, - title, - subtitle, - TmdbLanguagePolicy.bestDisplayValue(string(detail, "overview"), array(object(detail, "translations"), "translations"), "overview", TmdbLanguagePolicy.requestLanguage(config, Setting.getLanguage())), - poster, - backdrop, - "", - vote, - string(detail, "original_language"), - firstString(detail, new String[]{"origin_country"}), - genreIds(detail), - "", - vote, - 0d - ); - } - - private static TmdbItem fallbackItem(TmdbItem primary, TmdbItem fallback) { - return new TmdbItem( - primary.getTmdbId(), - primary.getMediaType(), - firstNonEmpty(primary.getTitle(), fallback.getTitle()), - firstNonEmpty(primary.getSubtitle(), fallback.getSubtitle()), - firstNonEmpty(primary.getOverview(), fallback.getOverview()), - firstNonEmpty(primary.getPosterUrl(), fallback.getPosterUrl()), - firstNonEmpty(primary.getBackdropUrl(), fallback.getBackdropUrl()), - firstNonEmpty(primary.getCredit(), fallback.getCredit()), - primary.getRating() > 0 ? primary.getRating() : fallback.getRating(), - firstNonEmpty(primary.getOriginalLanguage(), fallback.getOriginalLanguage()), - firstNonEmpty(primary.getOriginCountry(), fallback.getOriginCountry()), - primary.getGenreIds().isEmpty() ? fallback.getGenreIds() : primary.getGenreIds(), - firstNonEmpty(primary.getDepartment(), fallback.getDepartment()), - primary.getTmdbRating() > 0 ? primary.getTmdbRating() : fallback.getTmdbRating(), - primary.getDoubanRating() > 0 ? primary.getDoubanRating() : fallback.getDoubanRating(), - firstNonEmpty(primary.getRecommendationReason(), fallback.getRecommendationReason()) - ); - } - - private static List cast(JsonObject detail, TmdbConfig config) { - JsonArray aggregate = array(object(detail, "aggregate_credits"), "cast"); - if (!aggregate.isEmpty()) return castItems(aggregate, config, true); - return castItems(array(object(detail, "credits"), "cast"), config, false); - } - - private static List castItems(JsonArray array, TmdbConfig config, boolean aggregate) { - List result = new ArrayList<>(); - Set seen = new LinkedHashSet<>(); - for (JsonElement element : array) { - if (!element.isJsonObject()) continue; - JsonObject object = element.getAsJsonObject(); - int id = integer(object, "id", 0); - if (id <= 0 || !seen.add(id)) continue; - String role = aggregate ? firstRole(object) : firstString(object, new String[]{"character", "known_for_department"}); - result.add(new TmdbPerson( - id, - string(object, "name"), - role, - imageUrl(config.getImageBase(), string(object, "profile_path")), - string(object, "known_for_department"), - "" - )); - if (result.size() >= MAX_CAST) break; - } - return result; - } - - private static List creators(JsonObject detail, TmdbConfig config) { - Map crews = new LinkedHashMap<>(); - addCrew(crews, array(object(detail, "credits"), "crew"), config, false); - addCrew(crews, array(object(detail, "aggregate_credits"), "crew"), config, true); - List values = new ArrayList<>(crews.values()); - values.sort(Comparator.comparingInt(crew -> jobOrder(crew.jobs.isEmpty() ? "" : crew.jobs.get(0)))); - List result = new ArrayList<>(); - for (Crew crew : values) { - if (crew.jobs.isEmpty()) continue; - result.add(new TmdbPerson(crew.id, crew.name, String.join(" / ", crew.jobs), crew.profileUrl, crew.department, "")); - if (result.size() >= MAX_CREATORS) break; - } - return result; - } - - private static void addCrew(Map crews, JsonArray array, TmdbConfig config, boolean aggregate) { - for (JsonElement element : array) { - if (!element.isJsonObject()) continue; - JsonObject object = element.getAsJsonObject(); - int id = integer(object, "id", 0); - if (id <= 0) continue; - Crew crew = crews.get(id); - if (crew == null) { - crew = new Crew(id, string(object, "name"), imageUrl(config.getImageBase(), string(object, "profile_path")), string(object, "known_for_department")); - crews.put(id, crew); - } - if (aggregate) { - boolean added = false; - for (JsonElement job : array(object, "jobs")) { - if (job.isJsonObject()) { - addJob(crew.jobs, string(job.getAsJsonObject(), "job")); - added = true; - } - } - if (!added) addJob(crew.jobs, string(object, "job")); - } else { - addJob(crew.jobs, string(object, "job")); - } - } - } - - private static void addJob(List jobs, String job) { - if (!job.isEmpty() && !jobs.contains(job)) jobs.add(job); - } - - private static int jobOrder(String job) { - if ("Director".equalsIgnoreCase(job) || "导演".equals(job)) return 0; - if ("Writer".equalsIgnoreCase(job) || "Screenplay".equalsIgnoreCase(job) || "编剧".equals(job)) return 1; - if ("Producer".equalsIgnoreCase(job) || "制片".equals(job)) return 2; - return 3; - } - - private static List photos(JsonObject detail, TmdbConfig config) { - JsonObject images = object(detail, "images"); - List result = imageArray(array(images, "backdrops"), config.getBackdropBase()); - for (String url : imageArray(array(images, "posters"), config.getImageBase())) if (!result.contains(url)) result.add(url); - addImage(result, config.getBackdropBase(), string(detail, "backdrop_path")); - addImage(result, config.getImageBase(), string(detail, "poster_path")); - return limit(result, MAX_PHOTOS); - } - - private static List related(String mediaType, JsonObject detail, TmdbConfig config) { - Map result = new LinkedHashMap<>(); - addRelated(result, array(object(detail, "recommendations"), "results"), mediaType, config); - addRelated(result, array(object(detail, "similar"), "results"), mediaType, config); - return result.values().stream().limit(MAX_RELATED).toList(); - } - - private static void addRelated(Map result, JsonArray array, String defaultMediaType, TmdbConfig config) { - for (JsonElement element : array) { - if (!element.isJsonObject()) continue; - JsonObject object = element.getAsJsonObject(); - int id = integer(object, "id", 0); - String mediaType = normalizeMediaType(string(object, "media_type")); - if (mediaType.isEmpty()) mediaType = defaultMediaType; - if (id <= 0 || mediaType.isEmpty()) continue; - String title = firstString(object, mediaType.equals("movie") ? new String[]{"title", "name"} : new String[]{"name", "title"}); - String date = firstString(object, mediaType.equals("movie") ? new String[]{"release_date"} : new String[]{"first_air_date"}); - double vote = number(object, "vote_average", 0d); - result.putIfAbsent(mediaType + ':' + id, new TmdbItem( - id, - mediaType, - title, - subtitle(date, vote), - string(object, "overview"), - imageUrl(config.getImageBase(), string(object, "poster_path")), - imageUrl(config.getBackdropBase(), string(object, "backdrop_path")), - "", - vote, - string(object, "original_language"), - firstString(object, new String[]{"origin_country"}), - integers(array(object, "genre_ids")), - string(object, "department"), - vote, - 0d - )); - } - } - - private static SeasonData seasons(int tmdbId, int selectedSeasonNumber, JsonObject detail, TmdbConfig config) { - LinkedHashSet numbers = new LinkedHashSet<>(); - Map counts = new HashMap<>(); - Map> episodes = new HashMap<>(); - Map> casts = new HashMap<>(); - Map> photos = new HashMap<>(); - for (JsonElement element : array(detail, "seasons")) { - if (!element.isJsonObject()) continue; - JsonObject season = element.getAsJsonObject(); - int number = integer(season, "season_number", -1); - if (number < 0) continue; - numbers.add(number); - List seasonEpisodes = episodes(season, tmdbId, number, config); - if (!seasonEpisodes.isEmpty() || season.has("episodes")) episodes.put(number, seasonEpisodes); - counts.put(number, integer(season, "episode_count", seasonEpisodes.size())); - List seasonCast = cast(season, config); - if (!seasonCast.isEmpty() || hasCreditObject(season)) casts.put(number, seasonCast); - List seasonPhotos = photos(season, config); - if (!seasonPhotos.isEmpty() || season.has("images")) photos.put(number, seasonPhotos); - } - if (selectedSeasonNumber >= 0 && detail.has("episodes")) { - int seasonNumber = selectedSeasonNumber; - List selected = episodes(detail, tmdbId, seasonNumber, config); - if (selectedSeasonNumber > 0 || !selected.isEmpty()) { - numbers.add(seasonNumber); - episodes.put(seasonNumber, selected); - counts.putIfAbsent(seasonNumber, selected.size()); - } - } - List sorted = numbers.stream().sorted().toList(); - return new SeasonData(sorted, counts, episodes, casts, photos); - } - - private static List episodes(JsonObject season, int tmdbId, int seasonNumber, TmdbConfig config) { - List result = new ArrayList<>(); - // 语言策略每条分集都一样,提到循环外,避免按集重复读 Setting(一季几十集)。 - String target = TmdbLanguagePolicy.requestLanguage(config, Setting.getLanguage()); - for (JsonElement element : array(season, "episodes")) { - if (!element.isJsonObject()) continue; - JsonObject episode = element.getAsJsonObject(); - int number = integer(episode, "episode_number", result.size() + 1); - result.add(new TmdbEpisode( - number, - TmdbLanguagePolicy.bestDisplayValue(string(episode, "name"), array(object(episode, "translations"), "translations"), "name", target), - string(episode, "air_date"), - TmdbLanguagePolicy.bestDisplayValue(string(episode, "overview"), array(object(episode, "translations"), "translations"), "overview", target), - imageUrl(config.getBackdropBase(), string(episode, "still_path")), - number(episode, "vote_average", 0d), - integer(episode, "runtime", 0), - tmdbId, - seasonNumber - )); - } - return result; - } - - private static boolean hasCreditObject(JsonObject object) { - return object.has("credits") || object.has("aggregate_credits"); - } - - private static List genreIds(JsonObject detail) { - List result = new ArrayList<>(); - for (JsonElement element : array(detail, "genres")) { - if (!element.isJsonObject()) continue; - int id = integer(element.getAsJsonObject(), "id", 0); - if (id > 0) result.add(id); - } - return result; - } - - private static List integers(JsonArray array) { - List result = new ArrayList<>(); - for (JsonElement element : array) { - int value = integerElement(element, 0); - if (value > 0) result.add(value); - } - return result; - } - - private static List imageArray(JsonArray array, String base) { - List result = new ArrayList<>(); - for (JsonElement element : array) { - if (element.isJsonObject()) addImage(result, base, string(element.getAsJsonObject(), "file_path")); - } - return result; - } - - private static void addImage(List result, String base, String path) { - String url = imageUrl(base, path); - if (!url.isEmpty() && !result.contains(url)) result.add(url); - } - - private static List limit(List values, int max) { - return values.size() <= max ? values : new ArrayList<>(values.subList(0, max)); - } - - private static void addVideos(List target, JsonObject videos, TmdbVideo.Scope scope, int seasonNumber, int episodeNumber) { - for (JsonElement element : array(videos, "results")) { - if (!element.isJsonObject()) continue; - TmdbVideo video = TmdbVideo.from(element.getAsJsonObject(), scope, seasonNumber, episodeNumber); - if (video != null) target.add(video); - } - } - - private static JsonObject findSeason(JsonObject detail, int seasonNumber) { - if (seasonNumber < 0) return new JsonObject(); - for (JsonElement element : array(detail, "seasons")) { - if (!element.isJsonObject()) continue; - JsonObject season = element.getAsJsonObject(); - if (integer(season, "season_number", -1) == seasonNumber) return season; - } - return new JsonObject(); - } - - private static JsonObject findEpisode(JsonObject season, int episodeNumber) { - if (season == null || episodeNumber <= 0) return new JsonObject(); - for (JsonElement element : array(season, "episodes")) { - if (!element.isJsonObject()) continue; - JsonObject episode = element.getAsJsonObject(); - if (integer(episode, "episode_number", -1) == episodeNumber) return episode; - } - return new JsonObject(); - } - - private static String imageUrl(String base, String path) { - String normalized = TmdbSourcePayloadParser.normalizeImageUrl(path); - if (normalized.isEmpty()) return ""; - if (!normalized.startsWith("/")) return normalized; - String normalizedBase = base == null ? "" : base.trim(); - return normalizedBase.isEmpty() ? normalized : TmdbProxy.imageUrl(normalizedBase, normalized); - } - - private static String subtitle(String date, double vote) { - String rating = vote > 0 ? String.format(Locale.US, "%.1f", vote) : ""; - if (!date.isEmpty() && !rating.isEmpty()) return date + " · " + rating; - return date.isEmpty() ? rating : date; - } - - private static String firstNonEmpty(String first, String second) { - return first != null && !first.isEmpty() ? first : second == null ? "" : second; - } - - private static String firstRole(JsonObject object) { - for (JsonElement element : array(object, "roles")) { - if (!element.isJsonObject()) continue; - String role = string(element.getAsJsonObject(), "character"); - if (!role.isEmpty()) return role; - } - return firstString(object, new String[]{"character", "known_for_department"}); - } - - private static String normalizeMediaType(String value) { - String mediaType = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); - return "movie".equals(mediaType) || "tv".equals(mediaType) ? mediaType : ""; - } - - private static JsonObject object(@Nullable JsonObject object, String key) { - if (object == null || !object.has(key) || !object.get(key).isJsonObject()) return new JsonObject(); - return object.getAsJsonObject(key); - } - - private static JsonArray array(@Nullable JsonObject object, String key) { - if (object == null || !object.has(key) || !object.get(key).isJsonArray()) return new JsonArray(); - return object.getAsJsonArray(key); - } - - private static String string(JsonObject object, String key) { - if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive() || !object.getAsJsonPrimitive(key).isString()) return ""; - return object.get(key).getAsString(); - } - - private static long longValue(JsonObject object, String key, long fallback) { - if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive() || !object.getAsJsonPrimitive(key).isNumber()) return fallback; - try { - return object.get(key).getAsLong(); - } catch (RuntimeException ignored) { - return fallback; - } - } - - private static int integer(JsonObject object, String key, int fallback) { - long value = longValue(object, key, fallback); - return value > Integer.MAX_VALUE || value < Integer.MIN_VALUE ? fallback : (int) value; - } - - private static int integerElement(JsonElement element, int fallback) { - if (element == null || !element.isJsonPrimitive() || !element.getAsJsonPrimitive().isNumber()) return fallback; - try { - return element.getAsInt(); - } catch (RuntimeException ignored) { - return fallback; - } - } - - private static double number(JsonObject object, String key, double fallback) { - if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive() || !object.getAsJsonPrimitive(key).isNumber()) return fallback; - try { - double value = object.get(key).getAsDouble(); - return Double.isFinite(value) ? value : fallback; - } catch (RuntimeException ignored) { - return fallback; - } - } - - private static String firstString(JsonObject object, String[] keys) { - for (String key : keys) { - JsonElement value = object == null ? null : object.get(key); - if (value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { - String text = value.getAsString(); - if (text != null && !text.trim().isEmpty()) return text.trim(); - } - JsonArray array = array(object, key); - for (JsonElement item : array) { - if (item.isJsonPrimitive() && item.getAsJsonPrimitive().isString()) { - String text = item.getAsString(); - if (text != null && !text.trim().isEmpty()) return text.trim(); - } - } - } - return ""; - } - - private record Crew(int id, String name, String profileUrl, String department, List jobs) { - private Crew(int id, String name, String profileUrl, String department) { - this(id, name, profileUrl, department, new ArrayList<>()); - } - } - - private record SeasonData(List numbers, Map counts, Map> episodes, - Map> cast, Map> photos) { - } - - private TmdbSourceAdapter() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAvailability.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAvailability.java deleted file mode 100644 index dfb72992d3..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceAvailability.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.bean.Vod; -import com.fongmi.android.tv.setting.TmdbSourceState; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.util.List; -import java.util.Map; - -/** Classifies whether a source payload is merely valid identity data or can render TMDB UI. */ -public final class TmdbSourceAvailability { - - public static TmdbSourceState classify( - @Nullable Vod vod, - @Nullable TmdbSourcePayload payload, - @Nullable TmdbBundle bundle - ) { - TmdbSourcePayload normalized = TmdbSourcePayloadParser.parse(payload); - if (normalized == null || bundle == null || bundle.item() == null) return TmdbSourceState.ABSENT_OR_INVALID; - if (!matches(normalized, bundle.item().getTmdbId(), bundle.item().getMediaType())) { - return TmdbSourceState.ABSENT_OR_INVALID; - } - if (vod != null && vod.getTmdb() != null) { - TmdbSourcePayload vodPayload = TmdbSourcePayloadParser.parse(vod.getTmdb()); - if (vodPayload == null || !sameIdentity(normalized, vodPayload)) { - return TmdbSourceState.ABSENT_OR_INVALID; - } - } - return isRenderable(bundle) ? TmdbSourceState.RENDERABLE : TmdbSourceState.IDENTITY_ONLY; - } - - public static boolean isRenderable(@Nullable TmdbBundle bundle) { - if (bundle == null || bundle.item() == null) return false; - if (hasItems(bundle.cast()) || hasItems(bundle.creators()) || hasItems(bundle.photos()) || hasItems(bundle.related())) { - return true; - } - if (hasItems(bundle.seasons()) || hasItems(bundle.seasonCounts()) || hasItems(bundle.seasonEpisodes()) - || hasItems(bundle.seasonCast()) || hasItems(bundle.seasonPhotos())) { - return true; - } - return hasVisibleFields(bundle.detail()); - } - - private static boolean hasVisibleFields(@Nullable JsonObject detail) { - if (detail == null || detail.isEmpty()) return false; - if (hasNonBlank(detail, "title", "name", "original_title", "original_name", "overview", "tagline", "status", - "release_date", "first_air_date", "original_language", "poster_path", "backdrop_path")) { - return true; - } - if (hasPositive(detail, "vote_average", "vote_count", "runtime", "number_of_seasons", "number_of_episodes")) { - return true; - } - if (hasArrayItems(detail, "genres", "origin_country", "episode_run_time", "seasons", "episodes")) return true; - if (hasArrayItems(object(detail, "images"), "backdrops", "posters", "logos")) return true; - if (hasArrayItems(object(detail, "credits"), "cast", "crew")) return true; - if (hasArrayItems(object(detail, "aggregate_credits"), "cast", "crew")) return true; - if (hasArrayItems(object(detail, "videos"), "results")) return true; - if (hasArrayItems(object(detail, "recommendations"), "results")) return true; - if (hasArrayItems(object(detail, "similar"), "results")) return true; - return hasExternalIds(detail.get("external_ids")); - } - - private static boolean hasNonBlank(JsonObject object, String... keys) { - if (object == null) return false; - for (String key : keys) { - JsonElement value = object.get(key); - if (value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString() - && !value.getAsString().trim().isEmpty()) { - return true; - } - } - return false; - } - - private static boolean hasPositive(JsonObject object, String... keys) { - if (object == null) return false; - for (String key : keys) { - JsonElement value = object.get(key); - if (value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) { - try { - if (value.getAsDouble() > 0d) return true; - } catch (RuntimeException ignored) { - // Keep checking the remaining fields. - } - } - } - return false; - } - - private static boolean hasArrayItems(@Nullable JsonObject object, String... keys) { - if (object == null) return false; - for (String key : keys) { - JsonElement value = object.get(key); - if (value != null && value.isJsonArray() && !value.getAsJsonArray().isEmpty()) return true; - } - return false; - } - - private static boolean hasExternalIds(@Nullable JsonElement value) { - if (value == null || !value.isJsonObject()) return false; - JsonObject ids = value.getAsJsonObject(); - for (Map.Entry entry : ids.entrySet()) { - JsonElement id = entry.getValue(); - if (id == null || id.isJsonNull() || !id.isJsonPrimitive()) continue; - if (id.getAsJsonPrimitive().isString() && !id.getAsString().trim().isEmpty()) return true; - if (id.getAsJsonPrimitive().isNumber()) { - try { - if (id.getAsLong() > 0) return true; - } catch (RuntimeException ignored) { - // Keep checking the remaining IDs. - } - } - } - return false; - } - - private static JsonObject object(@Nullable JsonObject parent, String key) { - if (parent == null || !parent.has(key) || !parent.get(key).isJsonObject()) return null; - return parent.getAsJsonObject(key); - } - - private static boolean hasItems(@Nullable Iterable values) { - return values != null && values.iterator().hasNext(); - } - - private static boolean hasItems(@Nullable Map values) { - return values != null && !values.isEmpty(); - } - - private static boolean hasItems(@Nullable List values) { - return values != null && !values.isEmpty(); - } - - private static boolean matches(TmdbSourcePayload payload, int tmdbId, String mediaType) { - return tmdbId > 0 && payload.getTmdbId() == tmdbId - && payload.getMediaType().equalsIgnoreCase(mediaType == null ? "" : mediaType); - } - - private static boolean sameIdentity(TmdbSourcePayload first, TmdbSourcePayload second) { - return first.getTmdbId() == second.getTmdbId() - && first.getMediaType().equalsIgnoreCase(second.getMediaType()); - } - - private TmdbSourceAvailability() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceCapabilityPlanner.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceCapabilityPlanner.java deleted file mode 100644 index 79096b451b..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceCapabilityPlanner.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import android.text.TextUtils; - -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.utils.TmdbLanguagePolicy; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; - -/** Pure capability planner for source-embedded TMDB data. */ -public final class TmdbSourceCapabilityPlanner { - - public static final String CORE = "core"; - public static final String CREDITS = "credits"; - public static final String IMAGES = "images"; - public static final String EXTERNAL_IDS = "external_ids"; - public static final String VIDEOS = "videos"; - public static final String RECOMMENDATIONS = "recommendations"; - public static final String SIMILAR = "similar"; - - public record UiState(boolean core, boolean credits, boolean images, boolean externalIds, boolean videos, - boolean recommendations, boolean similar, int seasonNumber, int episodeNumber, - boolean seasonVideos, boolean episodeVideos) { - - public static UiState initialScreen() { - return new UiState(true, true, true, false, false, false, false, -1, -1, false, false); - } - - public UiState withSeason(int seasonNumber) { - return new UiState(core, credits, images, externalIds, videos, recommendations, similar, seasonNumber, episodeNumber, seasonVideos, episodeVideos); - } - - public UiState withEpisode(int seasonNumber, int episodeNumber) { - return new UiState(core, credits, images, externalIds, videos, recommendations, similar, seasonNumber, episodeNumber, seasonVideos, episodeVideos); - } - - public UiState withVideos(boolean detailVideos, boolean seasonVideos, boolean episodeVideos) { - return new UiState(core, credits, images, externalIds, detailVideos, recommendations, similar, seasonNumber, episodeNumber, seasonVideos, episodeVideos); - } - - public UiState withRelated(boolean recommendations, boolean similar) { - return new UiState(core, credits, images, externalIds, videos, recommendations, similar, seasonNumber, episodeNumber, seasonVideos, episodeVideos); - } - } - - public record Plan(Set required, Set available, Set missing) { - - public Plan { - required = Set.copyOf(required); - available = Set.copyOf(available); - missing = Set.copyOf(missing); - } - - public boolean hasInitialNetworkGaps() { - return missing.contains(CORE) || missing.contains(CREDITS) || missing.contains(IMAGES) || missing.contains(EXTERNAL_IDS); - } - - public boolean needsNetwork() { - return !missing.isEmpty(); - } - } - - public static Plan plan(@Nullable TmdbBundle bundle, @Nullable TmdbSourcePayload payload, @Nullable UiState state) { - return plan(bundle, payload, state, ""); - } - - public static Plan plan(@Nullable TmdbBundle bundle, @Nullable TmdbSourcePayload payload, @Nullable UiState state, - @Nullable String targetLanguage) { - UiState ui = state == null ? UiState.initialScreen() : state; - Set required = new TreeSet<>(); - if (ui.core()) required.add(CORE); - if (ui.credits()) required.add(CREDITS); - if (ui.images()) required.add(IMAGES); - if (ui.externalIds()) required.add(EXTERNAL_IDS); - if (ui.videos()) required.add(VIDEOS); - if (ui.recommendations()) required.add(RECOMMENDATIONS); - if (ui.similar()) required.add(SIMILAR); - if (ui.seasonNumber() >= 0) required.add(season(ui.seasonNumber())); - if (ui.episodeNumber() > 0 && ui.seasonNumber() >= 0) required.add(episode(ui.seasonNumber(), ui.episodeNumber())); - if (ui.seasonVideos() && ui.seasonNumber() >= 0) required.add(seasonVideos(ui.seasonNumber())); - if (ui.episodeVideos() && ui.seasonNumber() >= 0 && ui.episodeNumber() > 0) required.add(episodeVideos(ui.seasonNumber(), ui.episodeNumber())); - - Set available = new LinkedHashSet<>(); - for (String group : required) if (isAvailable(bundle, payload, group, ui.seasonNumber(), ui.episodeNumber(), targetLanguage)) available.add(group); - Set missing = new TreeSet<>(required); - missing.removeAll(available); - return new Plan(required, available, missing); - } - - public static boolean isAvailable(@Nullable TmdbBundle bundle, @Nullable TmdbSourcePayload payload, String group, int seasonNumber, int episodeNumber) { - return isAvailable(bundle, payload, group, seasonNumber, episodeNumber, ""); - } - - public static boolean isAvailable(@Nullable TmdbBundle bundle, @Nullable TmdbSourcePayload payload, String group, - int seasonNumber, int episodeNumber, @Nullable String targetLanguage) { - if (group == null || group.isEmpty()) return false; - if (group.equals(CORE) && payload != null && bundle != null && bundle.item() != null - && !coreLanguageAvailable(bundle.item(), bundle.detail(), payload, targetLanguage)) return false; - if (payload != null && payload.hasCapability(group)) return true; - if (bundle == null || bundle.item() == null) return false; - JsonObject detail = bundle.detail() == null ? new JsonObject() : bundle.detail(); - return switch (group) { - case CORE -> coreAvailable(bundle.item(), detail, payload, targetLanguage); - case CREDITS -> !bundle.cast().isEmpty() || !bundle.creators().isEmpty(); - case IMAGES -> !bundle.item().getPosterUrl().isEmpty() && !bundle.item().getBackdropUrl().isEmpty(); - case EXTERNAL_IDS -> externalIdsAvailable(detail); - case VIDEOS -> array(object(detail, "videos"), "results").size() > 0; - case RECOMMENDATIONS -> pageOneAvailable(detail, "recommendations"); - case SIMILAR -> pageOneAvailable(detail, "similar"); - default -> seasonalAvailable(bundle, detail, group, seasonNumber, episodeNumber); - }; - } - - public static String season(int seasonNumber) { - return "season:" + Math.max(0, seasonNumber); - } - - public static String seasonVideos(int seasonNumber) { - return "season_videos:" + Math.max(0, seasonNumber); - } - - public static String episode(int seasonNumber, int episodeNumber) { - return "episode:" + Math.max(0, seasonNumber) + ':' + Math.max(1, episodeNumber); - } - - public static String episodeVideos(int seasonNumber, int episodeNumber) { - return "episode_videos:" + Math.max(0, seasonNumber) + ':' + Math.max(1, episodeNumber); - } - - private static boolean seasonalAvailable(TmdbBundle bundle, JsonObject detail, String group, int seasonNumber, int episodeNumber) { - if (group.startsWith("season_videos:") || group.startsWith("episode_videos:")) return false; - if (group.startsWith("episode:")) { - int[] parsed = parseEpisode(group); - if (parsed == null) return false; - for (TmdbEpisode item : bundle.seasonEpisodes().getOrDefault(parsed[0], List.of())) if (item.getNumber() == parsed[1]) return true; - return false; - } - if (group.startsWith("season:")) { - int[] parsed = parseSeason(group); - if (parsed == null) return false; - List episodes = bundle.seasonEpisodes().get(parsed[0]); - return episodes != null && !episodes.isEmpty(); - } - return false; - } - - private static boolean coreAvailable(TmdbItem item, JsonObject detail, @Nullable TmdbSourcePayload payload, @Nullable String targetLanguage) { - if (item.getTmdbId() <= 0 || item.getMediaType().isEmpty() || item.getTitle().isEmpty()) return false; - if (!coreLanguageAvailable(item, detail, payload, targetLanguage)) return false; - if (string(detail, "overview").isEmpty() || date(detail).isEmpty() || !detail.has("vote_average") || !detail.has("genres")) return false; - return !item.isTv() || (detail.has("number_of_seasons") && detail.has("number_of_episodes")); - } - - private static boolean coreLanguageAvailable(TmdbItem item, JsonObject detail, @Nullable TmdbSourcePayload payload, @Nullable String targetLanguage) { - if (TextUtils.isEmpty(targetLanguage)) return true; - String normalized = TmdbLanguagePolicy.normalize(targetLanguage); - String declared = payload == null ? "" : payload.getLanguage(); - if (!TextUtils.isEmpty(declared)) return TmdbLanguagePolicy.isDisplayLanguageCompatible(normalized, declared); - return TmdbLanguagePolicy.looksTargetLanguage(normalized, item.getTitle()) || TmdbLanguagePolicy.looksTargetLanguage(normalized, item.getOverview()); - } - - private static boolean pageOneAvailable(JsonObject detail, String field) { - JsonObject value = object(detail, field); - if (value == null) return false; - JsonElement page = value.get("page"); - return page != null && page.isJsonPrimitive() && page.getAsJsonPrimitive().isNumber() && page.getAsInt() == 1 && value.has("results") && value.get("results").isJsonArray() && value.getAsJsonArray("results").size() > 0; - } - - private static boolean externalIdsAvailable(JsonObject detail) { - JsonObject externalIds = object(detail, "external_ids"); - if (externalIds == null) return false; - for (Map.Entry entry : externalIds.entrySet()) { - JsonElement value = entry.getValue(); - if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) continue; - if (value.getAsJsonPrimitive().isString() && !value.getAsString().trim().isEmpty()) return true; - if (value.getAsJsonPrimitive().isNumber()) { - try { - if (value.getAsLong() > 0) return true; - } catch (RuntimeException ignored) { - // Keep checking the remaining IDs. - } - } - } - return false; - } - - private static String date(JsonObject detail) { - String date = string(detail, "release_date"); - return date.isEmpty() ? string(detail, "first_air_date") : date; - } - - private static String string(JsonObject detail, String key) { - if (detail == null || !detail.has(key) || !detail.get(key).isJsonPrimitive() || !detail.getAsJsonPrimitive(key).isString()) return ""; - String value = detail.get(key).getAsString(); - return value == null ? "" : value.trim(); - } - - private static JsonObject object(JsonObject detail, String key) { - if (detail == null || !detail.has(key) || !detail.get(key).isJsonObject()) return null; - return detail.getAsJsonObject(key); - } - - private static JsonArray array(JsonObject detail, String key) { - if (detail == null || !detail.has(key) || !detail.get(key).isJsonArray()) return new JsonArray(); - return detail.getAsJsonArray(key); - } - - private static int[] parseSeason(String group) { - try { - return new int[]{Integer.parseInt(group.substring("season:".length()))}; - } catch (RuntimeException ignored) { - return null; - } - } - - private static int[] parseEpisode(String group) { - try { - String[] parts = group.substring("episode:".length()).split(":", -1); - return parts.length == 2 ? new int[]{Integer.parseInt(parts[0]), Integer.parseInt(parts[1])} : null; - } catch (RuntimeException ignored) { - return null; - } - } - - private TmdbSourceCapabilityPlanner() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceMerger.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceMerger.java deleted file mode 100644 index 32f2c1d461..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourceMerger.java +++ /dev/null @@ -1,303 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import android.text.TextUtils; - -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.TmdbPerson; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.utils.TmdbLanguagePolicy; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; - -/** Fill-only merge for source, local-cache and remote TMDB bundles. */ -public final class TmdbSourceMerger { - - public enum Origin { - SOURCE, - LOCAL_CACHE, - REMOTE_TMDB, - VOD_FALLBACK, - REMOTE_TMDB_LANGUAGE - } - - public record MergedBundle(TmdbBundle bundle, Map origins) { - - public MergedBundle { - origins = Collections.unmodifiableMap(new LinkedHashMap<>(origins)); - } - - public Origin origin(String field) { - return origins.getOrDefault(field, Origin.SOURCE); - } - } - - public static TmdbBundle fillOnly(@Nullable TmdbBundle source, @Nullable TmdbSourcePayload payload, @Nullable TmdbBundle network) { - return fillOnly(source, payload, network, ""); - } - - public static TmdbBundle fillOnly(@Nullable TmdbBundle source, @Nullable TmdbSourcePayload payload, - @Nullable TmdbBundle network, @Nullable String targetLanguage) { - return merge(source, payload, null, network, targetLanguage).bundle(); - } - - public static MergedBundle merge(@Nullable TmdbBundle source, @Nullable TmdbSourcePayload payload, - @Nullable TmdbBundle cache, @Nullable TmdbBundle network) { - return merge(source, payload, cache, network, ""); - } - - public static MergedBundle merge(@Nullable TmdbBundle source, @Nullable TmdbSourcePayload payload, - @Nullable TmdbBundle cache, @Nullable TmdbBundle network, - @Nullable String targetLanguage) { - TmdbBundle base = source == null ? empty() : source; - Map origins = new LinkedHashMap<>(); - base = mergePair(base, payload, cache, Origin.LOCAL_CACHE, origins, targetLanguage); - base = mergePair(base, payload, network, Origin.REMOTE_TMDB, origins, targetLanguage); - return new MergedBundle(base, origins); - } - - private static TmdbBundle mergePair(TmdbBundle base, @Nullable TmdbSourcePayload payload, @Nullable TmdbBundle lower, - Origin lowerOrigin, Map origins, @Nullable String targetLanguage) { - if (lower == null || lower.item() == null) return base; - if (base.item() != null && !identityMatches(base.item(), lower.item())) return base; - - TmdbItem item = mergeItem(base.item(), lower.item(), payload, origins, lowerOrigin, targetLanguage); - JsonObject detail = mergeObject(base.detail(), lower.detail(), payload, "detail", origins, lowerOrigin, targetLanguage); - List cast = mergeList(base.cast(), lower.cast(), "cast", cachedComplete(payload, "credits"), origins, lowerOrigin); - List creators = mergeList(base.creators(), lower.creators(), "creators", cachedComplete(payload, "credits"), origins, lowerOrigin); - List photos = mergeList(base.photos(), lower.photos(), "photos", cachedComplete(payload, "images"), origins, lowerOrigin); - List related = mergeList(base.related(), lower.related(), "related", cachedComplete(payload, "recommendations") && cachedComplete(payload, "similar"), origins, lowerOrigin); - List seasons = mergeSeasons(base.seasons(), lower.seasons()); - Map seasonCounts = mergeMap(base.seasonCounts(), lower.seasonCounts(), payload, "seasonCounts", origins, lowerOrigin); - Map> seasonEpisodes = mergeSeasonEpisodes(base.seasonEpisodes(), lower.seasonEpisodes(), payload, origins, lowerOrigin); - Map> seasonCast = mergeMap(base.seasonCast(), lower.seasonCast(), payload, "seasonCast", origins, lowerOrigin); - Map> seasonPhotos = mergeMap(base.seasonPhotos(), lower.seasonPhotos(), payload, "seasonPhotos", origins, lowerOrigin); - return new TmdbBundle(item, detail, cast, creators, photos, related, seasons, seasonCounts, seasonEpisodes, seasonCast, seasonPhotos); - } - - private static TmdbItem mergeItem(TmdbItem source, TmdbItem lower, @Nullable TmdbSourcePayload payload, - Map origins, Origin lowerOrigin, @Nullable String targetLanguage) { - if (source == null) return lower; - String title = chooseDisplay(source.getTitle(), lower.getTitle(), payload, targetLanguage, "item.title", origins, lowerOrigin); - String subtitle = choose(source.getSubtitle(), lower.getSubtitle(), "item.subtitle", origins, lowerOrigin); - String overview = chooseDisplay(source.getOverview(), lower.getOverview(), payload, targetLanguage, "item.overview", origins, lowerOrigin); - String poster = choose(source.getPosterUrl(), lower.getPosterUrl(), "item.poster", origins, lowerOrigin); - String backdrop = choose(source.getBackdropUrl(), lower.getBackdropUrl(), "item.backdrop", origins, lowerOrigin); - String credit = choose(source.getCredit(), lower.getCredit(), "item.credit", origins, lowerOrigin); - String language = choose(source.getOriginalLanguage(), lower.getOriginalLanguage(), "item.language", origins, lowerOrigin); - String country = choose(source.getOriginCountry(), lower.getOriginCountry(), "item.country", origins, lowerOrigin); - String department = choose(source.getDepartment(), lower.getDepartment(), "item.department", origins, lowerOrigin); - List genres = source.getGenreIds(); - if (genres.isEmpty() && !lower.getGenreIds().isEmpty()) { - genres = lower.getGenreIds(); - mark(origins, "item.genres", lowerOrigin); - } - boolean coreComplete = cachedComplete(payload, TmdbSourceCapabilityPlanner.CORE); - double rating = source.getRating() > 0 || coreComplete ? source.getRating() : lower.getRating(); - double tmdbRating = source.getTmdbRating() > 0 || coreComplete ? source.getTmdbRating() : lower.getTmdbRating(); - double doubanRating = source.getDoubanRating() > 0 ? source.getDoubanRating() : lower.getDoubanRating(); - return new TmdbItem(source.getTmdbId(), source.getMediaType(), title, subtitle, overview, poster, backdrop, credit, rating, language, country, genres, department, tmdbRating, doubanRating, source.getRecommendationReason()); - } - - private static JsonObject mergeObject(@Nullable JsonObject source, @Nullable JsonObject lower, @Nullable TmdbSourcePayload payload, String path, - Map origins, Origin lowerOrigin, @Nullable String targetLanguage) { - JsonObject result = source == null ? new JsonObject() : source.deepCopy(); - if (lower == null) return result; - for (Map.Entry entry : lower.entrySet()) { - String key = entry.getKey(); - String childPath = path.isEmpty() ? key : path + '.' + key; - JsonElement current = result.get(key); - JsonElement replacement = entry.getValue(); - boolean languageOverride = isCoreDisplayField(childPath) && !isEmptyValue(replacement, payload, childPath) - && shouldOverrideCoreDisplay(stringValue(current), stringValue(replacement), payload, targetLanguage); - if (current == null || languageOverride || isEmptyValue(current, payload, childPath)) { - result.add(key, replacement.deepCopy()); - mark(origins, childPath, languageOverride ? Origin.REMOTE_TMDB_LANGUAGE : lowerOrigin); - } else if (current.isJsonObject() && replacement.isJsonObject()) { - result.add(key, mergeObject(current.getAsJsonObject(), replacement.getAsJsonObject(), payload, childPath, origins, lowerOrigin, targetLanguage)); - } else if (current.isJsonArray() && replacement.isJsonArray()) { - JsonArray merged = mergeArray(current.getAsJsonArray(), replacement.getAsJsonArray(), payload, childPath, origins, lowerOrigin); - result.add(key, merged); - } - } - return result; - } - - private static JsonArray mergeArray(JsonArray source, JsonArray lower, @Nullable TmdbSourcePayload payload, String path, - Map origins, Origin lowerOrigin) { - JsonArray result = source.deepCopy(); - if (!source.isEmpty() || cachedComplete(payload, capabilityForField(path))) return result; - for (JsonElement element : lower) result.add(element.deepCopy()); - if (!lower.isEmpty()) mark(origins, path, lowerOrigin); - return result; - } - - private static boolean isEmptyValue(JsonElement value, @Nullable TmdbSourcePayload payload, String key) { - if (value == null || value.isJsonNull()) return true; - if (value.isJsonPrimitive()) { - if (value.getAsJsonPrimitive().isString()) return value.getAsString().trim().isEmpty(); - if (value.getAsJsonPrimitive().isNumber()) { - try { - return value.getAsDouble() == 0d && !cachedComplete(payload, capabilityForField(key)); - } catch (RuntimeException ignored) { - return true; - } - } - } - if (value.isJsonObject()) return value.getAsJsonObject().isEmpty() && !cachedComplete(payload, capabilityForField(key)); - if (value.isJsonArray()) return value.getAsJsonArray().isEmpty() && !cachedComplete(payload, capabilityForField(key)); - return false; - } - - private static List mergeList(@Nullable List source, @Nullable List lower, String field, boolean sourceComplete, - Map origins, Origin lowerOrigin) { - List base = source == null ? List.of() : source; - if (!base.isEmpty() || sourceComplete) return base; - List replacement = lower == null ? List.of() : lower; - if (!replacement.isEmpty()) mark(origins, field, lowerOrigin); - return replacement; - } - - private static List mergeSeasons(@Nullable List source, @Nullable List lower) { - Set merged = new TreeSet<>(); - if (source != null) merged.addAll(source); - if (lower != null) merged.addAll(lower); - return new ArrayList<>(merged); - } - - private static Map mergeMap(@Nullable Map source, @Nullable Map lower, - @Nullable TmdbSourcePayload payload, String field, Map origins, Origin lowerOrigin) { - Map result = source == null ? new LinkedHashMap<>() : new LinkedHashMap<>(source); - if (lower == null) return result; - for (Map.Entry entry : lower.entrySet()) { - T current = result.get(entry.getKey()); - if (current != null && !isEmpty(current)) continue; - result.put(entry.getKey(), entry.getValue()); - mark(origins, field + '.' + entry.getKey(), lowerOrigin); - } - return result; - } - - private static Map> mergeSeasonEpisodes(@Nullable Map> source, - @Nullable Map> lower, - @Nullable TmdbSourcePayload payload, - Map origins, Origin lowerOrigin) { - Map> result = source == null ? new LinkedHashMap<>() : new LinkedHashMap<>(source); - if (lower == null) return result; - for (Map.Entry> entry : lower.entrySet()) { - List current = result.get(entry.getKey()); - boolean complete = payload != null && payload.hasCapability(TmdbSourceCapabilityPlanner.season(entry.getKey())); - if (current != null && (!current.isEmpty() || complete)) continue; - result.put(entry.getKey(), entry.getValue()); - mark(origins, "seasonEpisodes." + entry.getKey(), lowerOrigin); - } - return result; - } - - private static boolean isEmpty(Object value) { - if (value instanceof List list) return list.isEmpty(); - if (value instanceof Map map) return map.isEmpty(); - return value == null; - } - - private static boolean cachedComplete(@Nullable TmdbSourcePayload payload, String group) { - return payload != null && payload.hasCapability(group); - } - - private static String capabilityForField(String path) { - String normalized = path == null ? "" : path; - if (normalized.contains("credits")) return TmdbSourceCapabilityPlanner.CREDITS; - if (normalized.contains("recommendations")) return TmdbSourceCapabilityPlanner.RECOMMENDATIONS; - if (normalized.contains("similar")) return TmdbSourceCapabilityPlanner.SIMILAR; - if (normalized.contains("videos")) return TmdbSourceCapabilityPlanner.VIDEOS; - if (normalized.contains("images")) return TmdbSourceCapabilityPlanner.IMAGES; - if (normalized.contains("external_ids")) return TmdbSourceCapabilityPlanner.EXTERNAL_IDS; - if (isCoreField(normalized)) return TmdbSourceCapabilityPlanner.CORE; - String field = path == null ? "" : path.substring(path.lastIndexOf('.') + 1); - return switch (field) { - case "credits", "aggregate_credits", "cast", "crew" -> TmdbSourceCapabilityPlanner.CREDITS; - case "images" -> TmdbSourceCapabilityPlanner.IMAGES; - case "external_ids" -> TmdbSourceCapabilityPlanner.EXTERNAL_IDS; - case "videos" -> TmdbSourceCapabilityPlanner.VIDEOS; - case "recommendations" -> TmdbSourceCapabilityPlanner.RECOMMENDATIONS; - case "similar" -> TmdbSourceCapabilityPlanner.SIMILAR; - default -> ""; - }; - } - - private static boolean isCoreField(String path) { - String field = path.substring(path.lastIndexOf('.') + 1); - return switch (field) { - case "id", "title", "name", "original_title", "original_name", "overview", "tagline", "status", - "release_date", "first_air_date", "vote_average", "vote_count", "genres", "origin_country", - "original_language", "runtime", "episode_run_time", "number_of_seasons", "number_of_episodes", - "poster_path", "backdrop_path" -> true; - default -> false; - }; - } - - private static String chooseDisplay(String source, String lower, @Nullable TmdbSourcePayload payload, - @Nullable String targetLanguage, String field, Map origins, Origin lowerOrigin) { - if (!TextUtils.isEmpty(source) && shouldOverrideCoreDisplay(source, lower, payload, targetLanguage)) { - mark(origins, field, Origin.REMOTE_TMDB_LANGUAGE); - return lower == null ? "" : lower; - } - return choose(source, lower, field, origins, lowerOrigin); - } - - private static boolean shouldOverrideCoreDisplay(String source, String lower, @Nullable TmdbSourcePayload payload, - @Nullable String targetLanguage) { - if (TextUtils.isEmpty(lower) || TextUtils.isEmpty(source)) return false; - if (TextUtils.isEmpty(targetLanguage)) return false; - String target = TmdbLanguagePolicy.normalize(targetLanguage); - String declared = payload == null ? "" : payload.getLanguage(); - if (!TmdbLanguagePolicy.isDisplayLanguageCompatible(target, declared)) { - return TmdbLanguagePolicy.looksTargetLanguage(target, lower); - } - return false; - } - - private static String stringValue(JsonElement value) { - if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) return ""; - String text = value.getAsString(); - return text == null ? "" : text.trim(); - } - - private static boolean isCoreDisplayField(String path) { - String field = path.substring(path.lastIndexOf('.') + 1); - return "title".equals(field) || "name".equals(field) || "overview".equals(field); - } - - private static String choose(String source, String lower, String field, Map origins, Origin lowerOrigin) { - if (source != null && !source.isEmpty()) return source; - if (lower != null && !lower.isEmpty()) mark(origins, field, lowerOrigin); - return lower == null ? "" : lower; - } - - private static void mark(Map origins, String field, Origin origin) { - origins.putIfAbsent(field, origin); - } - - private static boolean identityMatches(TmdbItem left, TmdbItem right) { - return left.getTmdbId() > 0 && left.getTmdbId() == right.getTmdbId() && left.getMediaType().equalsIgnoreCase(right.getMediaType()); - } - - private static TmdbBundle empty() { - return new TmdbBundle(null, new JsonObject(), List.of(), List.of(), List.of(), List.of(), List.of(), Map.of(), Map.of(), Map.of(), Map.of()); - } - - private TmdbSourceMerger() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourcePayloadParser.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourcePayloadParser.java deleted file mode 100644 index 9b96498910..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbSourcePayloadParser.java +++ /dev/null @@ -1,325 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.utils.TmdbLanguagePolicy; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.JsonPrimitive; - -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashSet; -import java.util.Locale; -import java.util.Set; -import java.util.TreeSet; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** Validates and normalizes source-provided C16 TMDB payloads without throwing into the detail page. */ -public final class TmdbSourcePayloadParser { - - public static final int MAX_PROTOCOL_BYTES = 2 * 1024 * 1024; - public static final int MAX_ARRAY_ITEMS = 500; - public static final int MAX_STRING_BYTES = 64 * 1024; - - private static final Pattern FETCHED_AT = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$"); - private static final Pattern SEASON = Pattern.compile("^season:(\\d+)$"); - private static final Pattern SEASON_VIDEOS = Pattern.compile("^season_videos:(\\d+)$"); - private static final Pattern EPISODE = Pattern.compile("^episode:(\\d+):(\\d+)$"); - private static final Pattern EPISODE_VIDEOS = Pattern.compile("^episode_videos:(\\d+):(\\d+)$"); - - private static final Set STATIC_CAPABILITIES = Set.of( - "core", "credits", "images", "external_ids", "videos", "recommendations", "similar" - ); - private static final Set IMAGE_FIELDS = Set.of( - "poster_path", "backdrop_path", "still_path", "profile_path", "logo_path", "file_path" - ); - private static final Set STRING_FIELDS = Set.of( - "title", "name", "original_title", "original_name", "overview", "tagline", "status", - "release_date", "first_air_date", "original_language", "poster_path", "backdrop_path", "still_path", - "profile_path", "logo_path", "file_path", "imdb_id", "iso_639_1", "iso_3166_1" - ); - private static final Set NUMBER_FIELDS = Set.of( - "id", "vote_average", "vote_count", "runtime", "number_of_seasons", "number_of_episodes", - "season_number", "episode_number", "order", "popularity" - ); - private static final Set OBJECT_FIELDS = Set.of( - "images", "credits", "aggregate_credits", "external_ids", "videos", "recommendations", "similar", - "translations", "content_ratings", "release_dates" - ); - private static final Set ARRAY_FIELDS = Set.of( - "genres", "origin_country", "episode_run_time", "seasons", "episodes", "results", "cast", "crew", - "guest_stars", "translations", "content_ratings", "release_dates", "backdrops", "posters", "logos", "profiles" - ); - private static final String[] TRIM_ORDER = { - "release_dates", "content_ratings", "translations", "similar", "recommendations", "videos", - "images", "credits", "aggregate_credits", "seasons", "episodes" - }; - - private static final Gson GSON = new Gson(); - - @Nullable - public static TmdbSourcePayload parseJson(@Nullable String json) { - if (json == null || json.isBlank()) return null; - if (json.getBytes(StandardCharsets.UTF_8).length > MAX_PROTOCOL_BYTES) return null; - try { - return parse(GSON.fromJson(json, TmdbSourcePayload.class)); - } catch (RuntimeException ignored) { - return null; - } - } - - @Nullable - public static TmdbSourcePayload parse(@Nullable TmdbSourcePayload source) { - if (source == null || source.getSchema() != TmdbSourcePayload.SCHEMA_VERSION || source.getTmdbId() <= 0) return null; - String mediaType = normalizeMediaType(source.getMediaType()); - if (mediaType.isEmpty()) return null; - int seasonNumber = source.getSeasonNumber(); - if (seasonNumber < 0 || ("movie".equals(mediaType) && seasonNumber != 0)) return null; - - TmdbSourcePayload result = source.copy(); - result.setMediaType(mediaType); - result.setSeasonNumber(seasonNumber); - result.setLanguage(normalizeLanguage(result.getLanguage())); - result.setFetchedAt(normalizeFetchedAt(result.getFetchedAt())); - result.setSourceKind(normalizeSourceKind(result.getSourceKind())); - - JsonObject detail = parseDetail(result.getDetailJson()); - JsonElement sanitized = sanitize(detail, ""); - JsonObject normalized = sanitized != null && sanitized.isJsonObject() ? sanitized.getAsJsonObject() : new JsonObject(); - normalized = normalizeIdentity(normalized, result); - if (normalized == null) return null; - - LinkedHashSet complete = canonicalCapabilities(result.getComplete()); - removeUnsupportedClaims(normalized, complete); - normalized = trimToLimit(normalized, complete); - if (normalized == null) return null; - - result.setComplete(complete); - result.setDetailJson(normalized.toString()); - if (result.estimatedParcelBytes() > MAX_PROTOCOL_BYTES) return null; - return result; - } - - /** Returns an empty string for invalid image references and otherwise the protocol-safe value. */ - public static String normalizeImageUrl(@Nullable String value) { - if (value == null) return ""; - String result = value.trim(); - if (result.isEmpty() || result.startsWith("//")) return ""; - if (result.startsWith("/")) return result.indexOf('\\') >= 0 || result.indexOf(' ') >= 0 ? "" : result; - try { - URI uri = URI.create(result); - if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getHost().isEmpty()) return ""; - return result; - } catch (IllegalArgumentException ignored) { - return ""; - } - } - - private static JsonObject parseDetail(String json) { - if (json == null || json.isBlank()) return new JsonObject(); - try { - JsonElement parsed = JsonParser.parseString(json); - return parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject(); - } catch (RuntimeException ignored) { - return new JsonObject(); - } - } - - @Nullable - private static JsonObject normalizeIdentity(JsonObject detail, TmdbSourcePayload payload) { - JsonElement id = detail.get("id"); - if (id != null) { - if (!id.isJsonPrimitive() || !id.getAsJsonPrimitive().isNumber()) { - detail.remove("id"); - } else { - try { - if (id.getAsInt() != payload.getTmdbId()) return null; - } catch (RuntimeException ignored) { - detail.remove("id"); - } - } - } - if ("tv".equals(payload.getMediaType())) { - JsonElement season = detail.get("season_number"); - if (season != null) { - if (!season.isJsonPrimitive() || !season.getAsJsonPrimitive().isNumber()) { - detail.remove("season_number"); - } else { - try { - if (season.getAsInt() != payload.getSeasonNumber()) return null; - } catch (RuntimeException ignored) { - detail.remove("season_number"); - } - } - } - } else { - detail.remove("season_number"); - } - return detail; - } - - @Nullable - private static JsonElement sanitize(@Nullable JsonElement element, String field) { - if (element == null || element.isJsonNull()) return null; - if (element.isJsonPrimitive()) { - JsonPrimitive primitive = element.getAsJsonPrimitive(); - if (primitive.isString()) { - String value = primitive.getAsString(); - if (value == null || value.getBytes(StandardCharsets.UTF_8).length > MAX_STRING_BYTES) return null; - if (IMAGE_FIELDS.contains(field)) { - String image = normalizeImageUrl(value); - return image.isEmpty() ? null : new JsonPrimitive(image); - } - return new JsonPrimitive(value); - } - if (NUMBER_FIELDS.contains(field) && !primitive.isNumber()) return null; - if (STRING_FIELDS.contains(field)) return null; - return element.deepCopy(); - } - if (ARRAY_FIELDS.contains(field) && !element.isJsonArray()) return null; - if (OBJECT_FIELDS.contains(field) && !element.isJsonObject()) return null; - if (element.isJsonArray()) { - JsonArray array = element.getAsJsonArray(); - if (array.size() > MAX_ARRAY_ITEMS) return null; - JsonArray result = new JsonArray(); - for (JsonElement item : array) { - JsonElement sanitized = sanitize(item, ""); - if (sanitized != null) result.add(sanitized); - } - return result; - } - if (element.isJsonObject()) { - JsonObject result = new JsonObject(); - for (String key : element.getAsJsonObject().keySet()) { - JsonElement sanitized = sanitize(element.getAsJsonObject().get(key), key); - if (sanitized != null) result.add(key, sanitized); - } - if (result.isEmpty() && !element.getAsJsonObject().isEmpty() && OBJECT_FIELDS.contains(field) && !"external_ids".equals(field)) return null; - return result; - } - return null; - } - - private static LinkedHashSet canonicalCapabilities(Set values) { - TreeSet sorted = new TreeSet<>(); - if (values != null) for (String value : values) { - String canonical = canonicalCapability(value); - if (!canonical.isEmpty()) sorted.add(canonical); - } - return new LinkedHashSet<>(sorted); - } - - private static String canonicalCapability(String value) { - if (value == null) return ""; - String group = value.trim().toLowerCase(Locale.ROOT); - if (STATIC_CAPABILITIES.contains(group)) return group; - Matcher season = SEASON.matcher(group); - if (season.matches()) return canonicalSeason("season", season.group(1)); - Matcher seasonVideos = SEASON_VIDEOS.matcher(group); - if (seasonVideos.matches()) return canonicalSeason("season_videos", seasonVideos.group(1)); - Matcher episode = EPISODE.matcher(group); - if (episode.matches()) return canonicalEpisode("episode", episode.group(1), episode.group(2)); - Matcher episodeVideos = EPISODE_VIDEOS.matcher(group); - if (episodeVideos.matches()) return canonicalEpisode("episode_videos", episodeVideos.group(1), episodeVideos.group(2)); - return ""; - } - - private static String canonicalSeason(String prefix, String number) { - try { - int value = Integer.parseInt(number); - return value < 0 ? "" : prefix + ':' + value; - } catch (NumberFormatException ignored) { - return ""; - } - } - - private static String canonicalEpisode(String prefix, String season, String episode) { - try { - int seasonNumber = Integer.parseInt(season); - int episodeNumber = Integer.parseInt(episode); - return seasonNumber < 0 || episodeNumber <= 0 ? "" : prefix + ':' + seasonNumber + ':' + episodeNumber; - } catch (NumberFormatException ignored) { - return ""; - } - } - - private static void removeUnsupportedClaims(JsonObject detail, Set complete) { - if (!hasAny(detail, "credits", "aggregate_credits")) complete.remove("credits"); - if (!hasAny(detail, "images", "poster_path", "backdrop_path")) complete.remove("images"); - if (!hasObject(detail, "external_ids")) complete.remove("external_ids"); - if (!hasObject(detail, "videos")) complete.remove("videos"); - if (!hasObject(detail, "recommendations")) complete.remove("recommendations"); - if (!hasObject(detail, "similar")) complete.remove("similar"); - if (!hasArray(detail, "seasons")) { - complete.removeIf(group -> group.startsWith("season:")); - complete.removeIf(group -> group.startsWith("season_videos:")); - } - } - - @Nullable - private static JsonObject trimToLimit(JsonObject detail, Set complete) { - for (String field : TRIM_ORDER) { - if (detail.toString().getBytes(StandardCharsets.UTF_8).length <= MAX_PROTOCOL_BYTES) return detail; - detail.remove(field); - removeCapabilityForField(complete, field); - } - return detail.toString().getBytes(StandardCharsets.UTF_8).length <= MAX_PROTOCOL_BYTES ? detail : null; - } - - private static void removeCapabilityForField(Set complete, String field) { - switch (field) { - case "credits", "aggregate_credits" -> complete.remove("credits"); - case "images" -> complete.remove("images"); - case "videos" -> complete.remove("videos"); - case "recommendations" -> complete.remove("recommendations"); - case "similar" -> complete.remove("similar"); - case "seasons" -> complete.removeIf(group -> group.startsWith("season:") || group.startsWith("season_videos:")); - case "episodes" -> complete.removeIf(group -> group.startsWith("episode:") || group.startsWith("episode_videos:")); - default -> { - } - } - } - - private static boolean hasAny(JsonObject object, String... fields) { - for (String field : fields) if (object.has(field) && !object.get(field).isJsonNull()) return true; - return false; - } - - private static boolean hasObject(JsonObject object, String field) { - return object.has(field) && object.get(field).isJsonObject(); - } - - private static boolean hasArray(JsonObject object, String field) { - return object.has(field) && object.get(field).isJsonArray(); - } - - private static String normalizeMediaType(String value) { - String result = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); - return "movie".equals(result) || "tv".equals(result) ? result : ""; - } - - private static String normalizeLanguage(String value) { - String result = value == null ? "" : value.trim(); - if (result.isEmpty() || result.length() > 64) return ""; - return TmdbLanguagePolicy.normalize(result); - } - - private static String normalizeFetchedAt(String value) { - String result = value == null ? "" : value.trim(); - return FETCHED_AT.matcher(result).matches() ? result : ""; - } - - private static String normalizeSourceKind(String value) { - return TmdbSourcePayload.CACHE_TMDB.equals(value) || TmdbSourcePayload.REMOTE_TMDB.equals(value) - ? value : TmdbSourcePayload.SOURCE_TMDB; - } - - private TmdbSourcePayloadParser() { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java index 1f66a2657a..2089c9a7b9 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbUIAdapter.java @@ -5,7 +5,6 @@ import android.text.TextUtils; import com.fongmi.android.tv.App; import com.fongmi.android.tv.api.SiteApi; -import com.fongmi.android.tv.api.config.SubscriptionTmdbCredentialStore; import com.fongmi.android.tv.bean.Episode; import com.fongmi.android.tv.bean.Flag; import com.fongmi.android.tv.bean.TmdbConfig; @@ -17,7 +16,6 @@ import com.fongmi.android.tv.bean.TmdbSeasonMatchCache; import com.fongmi.android.tv.bean.TmdbSeasonSegment; import com.fongmi.android.tv.bean.TmdbSeasonScope; import com.fongmi.android.tv.bean.TmdbPerson; -import com.fongmi.android.tv.bean.TmdbSourcePayload; import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.service.AiRecommendationService; @@ -34,7 +32,6 @@ import com.fongmi.android.tv.utils.EpisodeTitleFormatter; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.utils.TmdbDetailCache; -import com.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.fongmi.android.tv.utils.TmdbImageSelector; import com.fongmi.android.tv.utils.Util; import com.github.catvod.crawler.SpiderDebug; @@ -82,8 +79,8 @@ public class TmdbUIAdapter { private final Activity activity; private final TmdbService tmdbService; - private TmdbMatcher tmdbMatcher; - private TmdbConfig tmdbConfig; + private final TmdbMatcher tmdbMatcher; + private final TmdbConfig tmdbConfig; private final Runnable pendingVodRefresh = this::dispatchPendingVodRefresh; private final TmdbDetailPrefetch detailPrefetch; private final Task.Scope backgroundTasks; @@ -122,15 +119,11 @@ public class TmdbUIAdapter { private boolean relatedVideoLoading; private String relatedVideoContextKey = ""; private boolean loaded; - private boolean sourceOnly; - private TmdbBundle activeSourceBundle; - private TmdbSourcePayload activeSourcePayload; private volatile boolean episodeMetadataLoaded; private volatile int loadGeneration; private volatile int episodeMetadataGeneration; private volatile int relatedVideoGeneration; - private volatile SubscriptionTmdbCredentialStore.Scope subscriptionScope; private final Object episodeMetadataLock = new Object(); // 季级内存缓存:切季 / 切线路会反复要同一季的集数,磁盘缓存虽然命中但仍要读文件 + // 解析整季 JSON。缓存解析结果让重复访问零 IO。键为 tmdbId|mediaType|season。 @@ -153,7 +146,8 @@ public class TmdbUIAdapter { public TmdbUIAdapter(Activity activity) { this.activity = activity; this.tmdbService = new TmdbService(); - refreshRuntimeConfig(); + this.tmdbConfig = TmdbConfig.objectFrom(Setting.getTmdbConfig()); + this.tmdbMatcher = new TmdbMatcher(tmdbService, tmdbConfig); this.backgroundTasks = new Task.Scope(Task.recommendationExecutor()); // 选集元数据独立线程池:recommendationExecutor 只有 3 条线程,推荐 / 个性化 / AI 推荐 // 都挤在里面。原先 1200ms 延迟天然把选集和它们错开了,现在选集不再延迟,必须换到 @@ -163,17 +157,13 @@ public class TmdbUIAdapter { } public boolean isReady() { - return TmdbConfig.effectiveCurrent().isReady(); + return tmdbConfig.isReady(); } public boolean isLoaded() { return loaded; } - public boolean isSourceOnly() { - return sourceOnly; - } - public boolean isEpisodeMetadataLoaded() { return episodeMetadataLoaded; } @@ -231,15 +221,7 @@ public class TmdbUIAdapter { } if (keyed != null && (TextUtils.isEmpty(requestedEpisodeUrl) || containsEpisodeUrl(keyed, requestedEpisodeUrl))) return keyed; - Flag unique = uniqueFlagForEpisodeUrl(flags, requestedEpisodeUrl); - if (unique != null) return unique; - // 稳定键已经在当前线路表里命中了一条真实线路,只是那一集的 URL 不再属于它 - // (换源/源站刷新/带时效签名都会失配)。此时 keyed 仍是用户显式选过的那条线路, - // 不能像早期实现那样直接判罚为 null——调用方会把 null 当成「解析不了」而退回 - // flags.get(0),表现就是杀进程重进后线路强行回到第一条。线路名也一致时才认。 - if (keyed != null && !TextUtils.isEmpty(requestedFlagName) - && TextUtils.equals(requestedFlagName, keyed.getFlag())) return keyed; - return null; + return uniqueFlagForEpisodeUrl(flags, requestedEpisodeUrl); } if (!TextUtils.isEmpty(requestedEpisodeUrl)) { for (Flag flag : flags) { @@ -398,21 +380,10 @@ public class TmdbUIAdapter { detailPrefetch.cancel(); } - public void invalidateSubscription() { - refreshRuntimeConfig(); - subscriptionScope = SubscriptionTmdbCredentialStore.currentScope(); - loadGeneration++; - backgroundTasks.cancelAll(); - episodeTasks.cancelAll(); - cancelActivePrefetch(); - detailPrefetch.cancel(); - } - /** * Prefetch core TMDB detail for a known item without mutating or publishing a Vod. */ public void prefetch(TmdbItem item) { - refreshRuntimeConfig(); if (item == null || !isReady()) return; long start = System.currentTimeMillis(); // Intent 在主线程读:本方法由 prefetchDirectTmdbDetail 在主线程调用,而 Intent 内部是 @@ -468,7 +439,7 @@ public class TmdbUIAdapter { * 指定的季号,没有时退化为唯一正片季(单季剧最常见)。返回 < 0 表示证据不足,不猜。 */ static int likelySeasonNumber(JsonObject detail, int intentSeason) { - List options = parseSeasonOptions(detail, null); + List options = parseSeasonOptions(detail); if (options.isEmpty()) return -1; int target = intentSeason; if (target < 0) { @@ -513,19 +484,14 @@ public class TmdbUIAdapter { */ public void load(TmdbItem item, Vod vod) { if (item == null) return; - refreshRuntimeConfig(); - // resetLoadState 会清掉 sourceCacheTitle,而手动换条目时 vod.getName() 已被 - // enrichVod 改写成上一个 TMDB 标题。先留住站源标题,季度绑定的键才能跨会话一致。 - String sourceTitle = sourceCacheTitle; int generation = resetLoadState(); - captureSourceSeason(vod, sourceTitle); + captureSourceSeason(vod, null); cancelActivePrefetch(); this.tmdbItem = item; saveMatch(vod, item); TmdbDetailCache.Entry cached = takeTmdbDetailCache(item); if (cached != null) { detailPrefetch.cancel(); - seedSeasonEpisodes(cached); backgroundTasks.submit(() -> loadDetailSync(vod, cached.getItem(), cached.getDetail(), cached.getCast(), generation)); return; } @@ -583,170 +549,17 @@ public class TmdbUIAdapter { load(item, vod); return; } - refreshRuntimeConfig(); - String sourceTitle = sourceCacheTitle; int generation = resetLoadState(); - captureSourceSeason(vod, sourceTitle); + captureSourceSeason(vod, null); cancelActivePrefetch(); detailPrefetch.cancel(); this.tmdbItem = item; saveMatch(vod, item); - seedSeasonEpisodes(cached); backgroundTasks.submit(() -> loadDetailSync(vod, cached.getItem(), cached.getDetail(), cached.getCast(), generation)); } - /** - * 把详情页已经解析好的季集数据预填进内存缓存,让后续 {@link #seasonEpisodes} 直接命中内存。 - * - *

播放页的选集卡片模式取决于 {@code Episode.tmdbEpisode},而该字段只有成功拿到该季分集 - * 才会写入。详情页在跳转前已经取过同一季(否则它自己也画不出卡片),预填后播放页不必再发 - * 一次同样的请求:既省一次网络往返,也避免那次请求失败时详情页有卡片、播放页退化成纯文本。 - * 键与 {@link #seasonEpisodeKey} 保持一致,命中判据不变。 - */ - private void seedSeasonEpisodes(TmdbDetailCache.Entry cached) { - Map> episodes = cached == null ? null : cached.getSeasonEpisodes(); - if (episodes == null || episodes.isEmpty() || tmdbItem == null) return; - int seeded = 0; - for (Map.Entry> entry : episodes.entrySet()) { - if (entry.getKey() == null || entry.getKey() < 0 || entry.getValue() == null || entry.getValue().isEmpty()) continue; - seasonEpisodeCache.put(seasonEpisodeKey(tmdbItem, entry.getKey()), List.copyOf(entry.getValue())); - seeded++; - } - SpiderDebug.log("tmdb", "season episodes source=detail-handoff seasons=%d", seeded); - } - - /** - * Applies a validated C16 source bundle first. If an effective credential exists, only planner-reported - * initial gaps are filled from TMDB; complete source data stays offline and lazy capabilities remain on demand. - */ - public void loadSource(TmdbBundle bundle, Vod sourceVod, TmdbSourcePayload payload) { - if (bundle == null || bundle.item() == null || bundle.detail() == null || sourceVod == null) return; - refreshRuntimeConfig(); - String sourceTitle = sourceCacheTitle; - int generation = resetLoadState(); - sourceOnly = !tmdbConfig.isReady(); - captureSourceSeason(sourceVod, sourceTitle); - if (requestSeasonNumber < 0 && payload != null && payload.getSeasonNumber() >= 0) requestSeasonNumber = payload.getSeasonNumber(); - cancelActivePrefetch(); - detailPrefetch.cancel(); - applySourceBundle(bundle, sourceVod, payload); - TmdbSourceCapabilityPlanner.Plan plan = TmdbSourceCapabilityPlanner.plan(bundle, payload, TmdbSourceCapabilityPlanner.UiState.initialScreen(), TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); - boolean fillInitial = !sourceOnly && plan.hasInitialNetworkGaps(); - SpiderDebug.log("tmdb", "source loaded title=%s media=%s id=%d seasons=%d sourceOnly=%s fill=%s missing=%s", - tmdbItem.getTitle(), tmdbItem.getMediaType(), tmdbItem.getTmdbId(), seasonOptions.size(), sourceOnly, fillInitial, plan.missing()); - notifyVodChanged(sourceVod, generation, RefreshEvent.Type.VOD_CORE); - notifyLoadComplete(sourceVod, generation); - // applySourceBundle 已把内嵌分季数据写进 seasonEpisodeCache,但写缓存本身不会给 Episode - // 挂上 tmdbEpisode;不跑一次绑定,内嵌 payload 的站点在播放页就永远是纯文本选集 - // (详情页按季集映射表画卡片,所以表现为“详情页有卡片、播放页没有”)。 - // 这条路径命中内存缓存,不会发起网络请求。 - if (tmdbItem != null && tmdbItem.isTv()) scheduleStartupEpisodeLoad(sourceVod, tmdbItem, generation); - if (fillInitial) backgroundTasks.submit(() -> fillInitialSourceGaps(bundle, sourceVod, payload, plan, generation)); - } - - private void applySourceBundle(TmdbBundle bundle, Vod sourceVod, TmdbSourcePayload payload) { - vod = sourceVod; - activeSourceBundle = bundle; - activeSourcePayload = payload; - tmdbItem = bundle.item(); - tmdbDetail = bundle.detail(); - tmdbCast = new ArrayList<>(bundle.cast()); - recommendations = new ArrayList<>(bundle.related()); - relatedVideos = new ArrayList<>(); - personalTmdbRecommendations = new ArrayList<>(); - personalDoubanRecommendations = new ArrayList<>(); - personalAiRecommendations = new ArrayList<>(); - personalTmdbPage = PersonalRecommendationService.RecommendationPage.empty(""); - personalDoubanPage = PersonalRecommendationService.RecommendationPage.empty(""); - personalAiPage = PersonalRecommendationService.RecommendationPage.empty(""); - activeFlag = activeFlagFor(sourceVod); - activeFlagKey = flagKey(activeFlag, flagIndex(sourceVod == null ? null : sourceVod.getFlags(), activeFlag)); - - seasonEpisodeCache.clear(); - for (Map.Entry> entry : bundle.seasonEpisodes().entrySet()) { - if (entry.getKey() == null || entry.getKey() < 0 || entry.getValue() == null || entry.getValue().isEmpty()) continue; - seasonEpisodeCache.put(seasonEpisodeKey(tmdbItem, entry.getKey()), List.copyOf(entry.getValue())); - } - seasonOptions = sourceSeasonOptions(bundle); - if (tmdbItem.isTv()) { - List seasons = new ArrayList<>(); - Map counts = new LinkedHashMap<>(); - for (SeasonOption option : seasonOptions) { - seasons.add(option.getSeasonNumber()); - counts.put(option.getSeasonNumber(), option.getEpisodeCount()); - } - Flag sourceFlag = activeFlagFor(sourceVod); - seasonResolution = TmdbSeasonResolver.resolve( - requestSeasonNumber, - null, - explicitSourceSeasons(sourceFlag), - titleSeasonNumber, - seasons, - counts, - sourceEpisodeCount(sourceFlag), - sourceEpisodeNumbers(sourceFlag), - null, - false); - if (seasonResolution.getStatus() == TmdbSeasonResolver.Status.RESOLVED - && seasonResolution.getSelectedSeason() != null) { - sourceSeasonNumber = seasonResolution.getSelectedSeason(); - } - } else { - sourceSeasonNumber = -1; - seasonResolution = null; - seasonOptions = List.of(); - } - episodeInfo = TmdbEpisodeInfo.from(tmdbItem.getMediaType(), tmdbDetail, sourceSeasonNumber); - loaded = true; - episodeMetadataLoaded = true; - enrichVod(sourceVod, tmdbItem, tmdbDetail); - } - - private void fillInitialSourceGaps(TmdbBundle sourceBundle, Vod sourceVod, TmdbSourcePayload payload, - TmdbSourceCapabilityPlanner.Plan plan, int generation) { - long start = System.currentTimeMillis(); - TmdbConfig requestConfig = tmdbConfig; - try { - JsonObject detail = tmdbService.detailForSource(sourceBundle.item(), payload == null ? 0 : payload.getSeasonNumber(), requestConfig, plan.missing()); - if (!isCurrentGeneration(generation)) return; - TmdbBundle networkBundle = TmdbSourceAdapter.fromNetwork(sourceBundle.item(), detail, requestConfig); - TmdbBundle merged = TmdbSourceMerger.fillOnly(sourceBundle, payload, networkBundle, TmdbLanguagePolicy.requestLanguage(requestConfig, Setting.getLanguage())); - SpiderDebug.log("tmdb", "source fill finish cost=%dms missing=%s", System.currentTimeMillis() - start, plan.missing()); - if (activity == null) return; - activity.runOnUiThread(() -> { - if (!isCurrentGeneration(generation)) return; - applySourceBundle(merged, sourceVod, payload); - notifyVodChanged(sourceVod, generation, RefreshEvent.Type.VOD_CORE); - notifyLoadComplete(sourceVod, generation); - }); - } catch (Throwable e) { - SpiderDebug.log("tmdb", "source fill failed cost=%dms missing=%s error=%s", System.currentTimeMillis() - start, plan.missing(), e.getMessage()); - } - } - - private static String seasonEpisodeKey(TmdbItem item, int seasonNumber) { - return item.getTmdbId() + "|" + item.getMediaType() + "|" + seasonNumber; - } - - private static List sourceSeasonOptions(TmdbBundle bundle) { - List fromDetail = parseSeasonOptions(bundle.detail(), TmdbConfig.effectiveCurrent()); - if (!fromDetail.isEmpty()) return fromDetail; - List result = new ArrayList<>(); - for (Integer seasonNumber : bundle.seasons()) { - if (seasonNumber == null || seasonNumber < 0 || containsSeason(result, seasonNumber)) continue; - result.add(new SeasonOption(seasonNumber, "", "", bundle.seasonCounts().getOrDefault(seasonNumber, 0))); - } - return List.copyOf(result); - } - - private static boolean containsSeason(List options, int seasonNumber) { - for (SeasonOption option : options) if (option.getSeasonNumber() == seasonNumber) return true; - return false; - } - public void rememberManualMatch(Vod vod, TmdbItem item) { saveTitleLearning(vod, item); - saveManualMatch(vod, item); } /** @@ -760,7 +573,6 @@ public class TmdbUIAdapter { } public void autoMatch(String videoName, Vod vod, String searchKeyword) { - refreshRuntimeConfig(); int generation = resetLoadState(); captureSourceSeason(vod, videoName); cancelActivePrefetch(); @@ -786,8 +598,7 @@ public class TmdbUIAdapter { TmdbItem matched = getCachedMatch(vod); if (matched != null) { SpiderDebug.log("tmdb", "auto match cache hit title=%s cost=%dms", matched.getTitle(), System.currentTimeMillis() - start); - // 手动选择由用户拍板,分季变体过滤只针对自动匹配的误命中。 - if (!isManualMatch(vod) && isCachedSplitSeasonMismatch(videoName, vod, matched)) { + if (isCachedSplitSeasonMismatch(videoName, vod, matched)) { SpiderDebug.log("tmdb", "auto match cache skipped split-season variant title=%s id=%d name=%s", matched.getTitle(), matched.getTmdbId(), videoName); matched = null; } @@ -883,7 +694,6 @@ public class TmdbUIAdapter { } public List search(String keyword, Vod vod) throws Exception { - refreshRuntimeConfig(); return tmdbMatcher.search(keyword, vod); } @@ -901,14 +711,13 @@ public class TmdbUIAdapter { private TmdbDetailCache.Entry takeTmdbDetailCache(TmdbItem item) { if (activity == null || activity.getIntent() == null || item == null) return null; - TmdbDetailCache.Entry cached = TmdbDetailCache.take(activity.getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), item, TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); + TmdbDetailCache.Entry cached = TmdbDetailCache.take(activity.getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), item); if (cached != null) SpiderDebug.log("tmdb", "detail core memory-cache hit title=%s media=%s id=%d", cached.getItem().getTitle(), cached.getItem().getMediaType(), cached.getItem().getTmdbId()); return cached; } private int resetLoadState() { int generation = ++loadGeneration; - subscriptionScope = SubscriptionTmdbCredentialStore.currentScope(); episodeMetadataGeneration++; tmdbItem = null; tmdbDetail = null; @@ -941,9 +750,6 @@ public class TmdbUIAdapter { relatedVideoContextKey = ""; relatedVideoGeneration++; loaded = false; - sourceOnly = false; - activeSourceBundle = null; - activeSourcePayload = null; episodeMetadataLoaded = false; pendingVodRefreshVod = null; pendingVodRefreshGeneration = generation; @@ -1061,7 +867,7 @@ public class TmdbUIAdapter { private void resolveSeason(Vod sourceVod, TmdbItem item, JsonObject detail) { synchronized (Setting.class) { - seasonOptions = parseSeasonOptions(detail, tmdbConfig); + seasonOptions = parseSeasonOptions(detail); List tmdbSeasons = new ArrayList<>(); Map seasonCounts = new HashMap<>(); for (SeasonOption option : seasonOptions) { @@ -1289,7 +1095,7 @@ public class TmdbUIAdapter { public static String manualBindingFingerprint(String sourceTitle, Flag flag, String flagKey) { return (sourceTitle == null ? "" : sourceTitle) + "|" + (flagKey == null ? "" : flagKey) + "|" - + EpisodeSeasonSnapshot.stableStructureFingerprint(flag == null ? null : flag.getEpisodes()); + + EpisodeSeasonSnapshot.structureFingerprint(flag == null ? null : flag.getEpisodes()); } public static String sourceFingerprint(Flag flag, String flagKey, Map seasonCounts) { @@ -1312,7 +1118,7 @@ public class TmdbUIAdapter { } } - static List parseSeasonOptions(JsonObject detail, TmdbConfig config) { + static List parseSeasonOptions(JsonObject detail) { List result = new ArrayList<>(); if (detail == null || !detail.has("seasons") || !detail.get("seasons").isJsonArray()) return result; for (JsonElement element : detail.getAsJsonArray("seasons")) { @@ -1322,7 +1128,7 @@ public class TmdbUIAdapter { if (number < 0) continue; result.add(new SeasonOption( number, - TmdbLanguagePolicy.bestDisplayValue(jsonString(season, "name"), season.has("translations") && season.get("translations").isJsonObject() && season.getAsJsonObject("translations").has("translations") && season.getAsJsonObject("translations").get("translations").isJsonArray() ? season.getAsJsonObject("translations").getAsJsonArray("translations") : null, "name", TmdbLanguagePolicy.requestLanguage(config, Setting.getLanguage())), + jsonString(season, "name"), jsonString(season, "air_date"), Math.max(0, jsonInt(season, "episode_count", 0)))); } @@ -1374,12 +1180,7 @@ public class TmdbUIAdapter { return activity == null || activity.getIntent() == null ? "" : activity.getIntent().getStringExtra("name"); } private boolean isCurrentGeneration(int generation) { - return generation == loadGeneration && SubscriptionTmdbCredentialStore.isCurrent(subscriptionScope); - } - - private void refreshRuntimeConfig() { - tmdbConfig = TmdbConfig.effectiveCurrent(); - tmdbMatcher = new TmdbMatcher(tmdbService, tmdbConfig); + return generation == loadGeneration; } private void loadDetailSync(Vod vod, TmdbItem item, int generation) { @@ -1478,7 +1279,10 @@ public class TmdbUIAdapter { } private String detailTitle(TmdbItem item, JsonObject detail) { - return tmdbService.preferredTitle(item, detail, tmdbConfig); + if (detail == null) return ""; + String primary = "movie".equalsIgnoreCase(item.getMediaType()) ? jsonString(detail, "title") : jsonString(detail, "name"); + if (!TextUtils.isEmpty(primary)) return primary; + return "movie".equalsIgnoreCase(item.getMediaType()) ? jsonString(detail, "name") : jsonString(detail, "title"); } private static String jsonString(JsonObject object, String key) { @@ -1633,7 +1437,7 @@ public class TmdbUIAdapter { PersonalRecommendationService.RecommendationPages pages = PersonalRecommendationService.RecommendationPages.empty(); PersonalRecommendationService service = new PersonalRecommendationService(tmdbService, tmdbConfig); try { - pages = service.loadIndependentPages(vod, item, detail, 0, PersonalRecommendationService.DEFAULT_PAGE_SIZE); + pages = service.loadPage(vod, item, detail, 0, PersonalRecommendationService.DEFAULT_PAGE_SIZE); } catch (Throwable e) { SpiderDebug.log("tmdb", "initial personal recommendations failed error=%s", e.getMessage()); } @@ -1648,13 +1452,12 @@ public class TmdbUIAdapter { personalDoubanRecommendations = personalDoubanPage.getItems(); if (vod != null && (!personalTmdbRecommendations.isEmpty() || !personalDoubanRecommendations.isEmpty())) notifyVodChanged(vod, generation, RefreshEvent.Type.VOD_PERSONAL); }); - service.enrichTmdbPageRatingsAsync(loadedPages.getTmdb(), enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod, item, service)); + service.enrichTmdbPageRatingsAsync(loadedPages.getTmdb(), enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod)); }); loadPersonalAiRecommendationsAsync(vod, item, generation); } private void loadPersonalAiRecommendationsAsync(Vod vod, TmdbItem item, int generation) { - if (sourceOnly) return; if (vod == null || !Setting.isPersonalRecommendation() || personalAiLoading) return; personalAiLoading = true; backgroundTasks.submit(() -> { @@ -1742,7 +1545,6 @@ public class TmdbUIAdapter { SpiderDebug.log("tmdb", "season episodes source=memory season=%d count=%d", seasonNumber, cached.size()); return cached; } - if (sourceOnly) return List.of(); JsonObject season = tmdbService.season(item, seasonNumber, tmdbConfig); if (season == null) return List.of(); List episodes = tmdbService.episodes(season, tmdbConfig, item.getTmdbId(), seasonNumber); @@ -1754,52 +1556,9 @@ public class TmdbUIAdapter { } private Integer currentEpisodeMetadataSeason() { - if (seasonResolution != null && seasonResolution.getStatus() == TmdbSeasonResolver.Status.RESOLVED) { - Integer selectedSeason = seasonResolution.getSelectedSeason(); - if (selectedSeason != null && selectedSeason >= 0) return selectedSeason; - } - return soleOrdinarySeason(); - } - - /** - * 单季剧兜底:TMDB 元数据里只有一个正片季时,该季就是唯一可能的归属,无需解析器下结论。 - * - *

解析器在“请求季不在 TMDB 里”等情况下返回 AMBIGUOUS,此时旧实现直接放弃逐集绑定, - * 播放页就只剩纯文本按钮(详情页按季集映射表画卡片,所以表现为“详情页有卡片、播放页没有”)。 - * 本方法与 {@link TmdbSeasonResolver} 自身的 SINGLE_SEASON 启发式同口径:只有恰好一个正片季 - * 才成立,多季剧返回 null、行为完全不变。 - * - *

必须作为 {@link #currentEpisodeMetadataSeason()} 的组成部分而不是调用点局部变量: - * {@link #isCurrentEpisodeMetadataRequest} 与 {@code applyEpisodeTitles} 都用它做代次一致性校验, - * 只改调用点会让调度侧与执行侧看到不同的季号而互相否决。 - * - *

兜底只对“解析器没有可用结论、且没有相反证据”的来源生效:{@link TmdbSeasonResolver.Source#REQUEST} - * (请求季不在 TMDB)与 {@link TmdbSeasonResolver.Source#NONE}(源站路径显式关闭了启发式)。 - * 其余来源都代表解析器已经看到相反证据或用户显式选择,兜底覆盖它们只会帮倒忙: - * {@code MANUAL_FLAT} 是用户「保持原始剧集列表」的选择,覆盖后播放页会挂上 TMDB 集标题、 - * 并经 {@code TmdbEpisodeSorter.sort} 原地重排选集,与详情页(该模式下不画卡片)也不一致; - * {@code TITLE}/{@code EXPLICIT} 冲突表示源站剧名或集名明确指向 TMDB 不存在的季, - * 覆盖会把别的季的标题静默挂到这一季上。 - * - *

MULTI_SLICE 同样除外:解析器已经给出明确的分段结论时,兜底不得把它降级成单季路径。 - * 切片路径以 null 作为季号一致性校验的期望值({@code applyEpisodeTitlesForSlices} 传 null), - * 若兜底返回非 null,调度侧会改走单季绑定、执行侧的切片校验又必然否决, - * 结果是整个分段(含特别篇)一集都绑不上。 - */ - private Integer soleOrdinarySeason() { - if (isMultiSliceResolution()) return null; - if (seasonResolution != null) { - TmdbSeasonResolver.Source source = seasonResolution.getSource(); - if (source != TmdbSeasonResolver.Source.REQUEST && source != TmdbSeasonResolver.Source.NONE) return null; - } - if (seasonOptions == null || seasonOptions.isEmpty()) return null; - Integer sole = null; - for (SeasonOption option : seasonOptions) { - if (option == null || option.getSeasonNumber() <= 0) continue; - if (sole != null) return null; - sole = option.getSeasonNumber(); - } - return sole; + if (seasonResolution == null || seasonResolution.getStatus() != TmdbSeasonResolver.Status.RESOLVED) return null; + Integer selectedSeason = seasonResolution.getSelectedSeason(); + return selectedSeason != null && selectedSeason >= 0 ? selectedSeason : null; } private boolean isMultiSliceResolution() { @@ -1821,13 +1580,7 @@ public class TmdbUIAdapter { private TmdbItem getCachedMatch(Vod vod) { if (vod == null) return null; - TmdbMatchCache cache = Setting.getTmdbMatchCache(); - TmdbItem manual = cache.findManual(cacheSiteKey(vod), cacheVodId(vod), vod.getName(), TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); - return manual != null ? manual : cache.find(cacheSiteKey(vod), cacheVodId(vod), vod.getName(), TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); - } - - private boolean isManualMatch(Vod vod) { - return vod != null && Setting.getTmdbMatchCache().isManual(cacheSiteKey(vod), cacheVodId(vod), vod.getName()); + return Setting.getTmdbMatchCache().find(cacheSiteKey(vod), cacheVodId(vod), vod.getName()); } private boolean isCachedSplitSeasonMismatch(String videoName, Vod vod, TmdbItem item) { @@ -1850,51 +1603,9 @@ public class TmdbUIAdapter { private void saveMatch(Vod vod, TmdbItem item) { if (vod == null || item == null || item.getTmdbId() <= 0) return; - // 读-改-写要整体互斥:本方法在后台线程被调用,手动选择在主线程写, - // 不加锁会让后到的自动结果基于旧快照覆盖掉刚落盘的手动选择。 - synchronized (Setting.class) { - TmdbMatchCache cache = Setting.getTmdbMatchCache(); - cache.put(cacheSiteKey(vod), cacheVodId(vod), vod.getName(), item, TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); - Setting.putTmdbMatchCache(cache); - } - } - - /** - * 记录手动选择。必须在 load() 之前调用:load() 会重置 sourceCacheTitle, - * 而 enrichVod 之后 vod.getName() 已被改写成 TMDB 标题,不能作为唯一的键。 - */ - private void saveManualMatch(Vod vod, TmdbItem item) { - if (vod == null || item == null || item.getTmdbId() <= 0) return; - List aliases = manualMatchTitleAliases(vod); - synchronized (Setting.class) { - TmdbMatchCache cache = Setting.getTmdbMatchCache(); - cache.putManual(cacheSiteKey(vod), cacheVodId(vod), aliases, item, TmdbLanguagePolicy.requestLanguage(tmdbConfig, Setting.getLanguage())); - Setting.putTmdbMatchCache(cache); - } - } - - /** - * 别名只取站源侧信号。vod.getName() 在 enrichVod 之后已是"上一次"的 TMDB 标题, - * 无条件写进去会留下一条指向旧条目的精确键(A→B→C 连续切换后 key(标题A) 仍指向 B), - * 而历史记录里存的正是那个旧标题,反查就会读回旧选择。但它未被富集时又正是 - * getCachedMatch 的读取键,不能一概丢弃——用当前已加载条目的标题判断它是否已被改写。 - */ - private List manualMatchTitleAliases(Vod vod) { - List aliases = new ArrayList<>(); - addTitleAlias(aliases, sourceCacheTitle); - addTitleAlias(aliases, activityIntentTitle()); - String vodTitle = vod == null ? "" : vod.getName(); - if (!isEnrichedVodTitle(vodTitle)) addTitleAlias(aliases, vodTitle); - return aliases; - } - - private boolean isEnrichedVodTitle(String vodTitle) { - return tmdbItem != null && !TextUtils.isEmpty(vodTitle) && vodTitle.equals(tmdbItem.getTitle()); - } - - private static void addTitleAlias(List aliases, String title) { - if (TextUtils.isEmpty(title) || aliases.contains(title)) return; - aliases.add(title); + TmdbMatchCache cache = Setting.getTmdbMatchCache(); + cache.put(cacheSiteKey(vod), cacheVodId(vod), vod.getName(), item); + Setting.putTmdbMatchCache(cache); } private void saveTitleLearning(Vod vod, TmdbItem item) { @@ -2442,29 +2153,12 @@ public class TmdbUIAdapter { public void loadRelatedVideosAsync(int seasonNumber, int episodeNumber) { TmdbItem item = tmdbItem; Vod currentVod = vod; - if (!loaded || item == null) { - relatedVideos = new ArrayList<>(); - return; - } - String contextKey = item.getMediaType() + ":" + item.getTmdbId() + ":" + seasonNumber + ":" + episodeNumber + ":" + tmdbConfig.getLanguage(); - if (applySourceVideosIfAvailable(item, seasonNumber, episodeNumber, contextKey)) return; - if (sourceOnly) { - relatedVideoGeneration++; - relatedVideoLoading = false; - relatedVideoContextKey = contextKey; - relatedVideos = new ArrayList<>(TmdbSourceAdapter.videos( - tmdbDetail, - item.getMediaType(), - seasonNumber, - episodeNumber, - tmdbConfig.getLanguage())); - return; - } - if (!isReady()) { + if (!loaded || item == null || !isReady()) { relatedVideos = new ArrayList<>(); return; } int generation = loadGeneration; + String contextKey = item.getMediaType() + ":" + item.getTmdbId() + ":" + seasonNumber + ":" + episodeNumber + ":" + tmdbConfig.getLanguage(); if (contextKey.equals(relatedVideoContextKey) && (relatedVideoLoading || relatedVideos != null)) return; int videoGeneration = ++relatedVideoGeneration; relatedVideoContextKey = contextKey; @@ -2489,30 +2183,6 @@ public class TmdbUIAdapter { }); } - private boolean applySourceVideosIfAvailable(TmdbItem item, int seasonNumber, int episodeNumber, String contextKey) { - String group; - if (item.isMovie()) { - group = TmdbSourceCapabilityPlanner.VIDEOS; - } else if (episodeNumber > 0 && seasonNumber >= 0) { - group = TmdbSourceCapabilityPlanner.episodeVideos(seasonNumber, episodeNumber); - } else if (seasonNumber >= 0) { - group = TmdbSourceCapabilityPlanner.seasonVideos(seasonNumber); - } else { - group = TmdbSourceCapabilityPlanner.VIDEOS; - } - if (!TmdbSourceCapabilityPlanner.isAvailable(activeSourceBundle, activeSourcePayload, group, seasonNumber, episodeNumber)) return false; - relatedVideoGeneration++; - relatedVideoLoading = false; - relatedVideoContextKey = contextKey; - relatedVideos = new ArrayList<>(TmdbSourceAdapter.videos( - tmdbDetail, - item.getMediaType(), - seasonNumber, - episodeNumber, - tmdbConfig.getLanguage())); - return true; - } - public boolean hasMoreRecommendations() { return recommendationHasMore; } @@ -2530,10 +2200,6 @@ public class TmdbUIAdapter { } public void loadMoreRecommendations(LoadMoreCallback callback) { - if (sourceOnly) { - if (callback != null) callback.onLoaded(false); - return; - } if (recommendationLoading || !recommendationHasMore || tmdbItem == null || tmdbDetail == null) { if (callback != null) callback.onLoaded(false); return; @@ -2580,10 +2246,6 @@ public class TmdbUIAdapter { } public void refreshPersonalRecommendations(LoadMoreCallback callback) { - if (sourceOnly) { - if (callback != null) callback.onLoaded(false); - return; - } if (personalRefreshLoading || tmdbDetail == null) { if (callback != null) callback.onLoaded(false); return; @@ -2625,15 +2287,11 @@ public class TmdbUIAdapter { if (callback != null) callback.onLoaded(hasChanged); if (hasAiChanged) loadPersonalAiRecommendationsAsync(vod, tmdbItem, generation); }); - if (hasChanged) service.enrichTmdbPageRatingsAsync(loadedPages.getTmdb(), enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod, tmdbItem, service)); + if (hasChanged) service.enrichTmdbPageRatingsAsync(loadedPages.getTmdb(), enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod)); }); } private void loadMorePersonalRecommendations(boolean tmdb, LoadMoreCallback callback) { - if (sourceOnly) { - if (callback != null) callback.onLoaded(false); - return; - } PersonalRecommendationService.RecommendationPage page = tmdb ? personalTmdbPage : personalDoubanPage; if (page == null || !page.hasMore() || (tmdb ? personalTmdbLoading : personalDoubanLoading)) { if (callback != null) callback.onLoaded(false); @@ -2668,7 +2326,7 @@ public class TmdbUIAdapter { if (callback != null) callback.onLoaded(changed); } }); - if (tmdb) service.enrichTmdbPageRatingsAsync(loadedPage, enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod, tmdbItem, service)); + if (tmdb) service.enrichTmdbPageRatingsAsync(loadedPage, enriched -> applyPersonalTmdbRatingEnrichment(enriched, generation, vod)); }); } @@ -2679,13 +2337,10 @@ public class TmdbUIAdapter { }); } - private void applyPersonalTmdbRatingEnrichment( - PersonalRecommendationService.RecommendationPage enriched, int generation, Vod eventVod, - TmdbItem currentItem, PersonalRecommendationService service) { + private void applyPersonalTmdbRatingEnrichment(PersonalRecommendationService.RecommendationPage enriched, int generation, Vod eventVod) { activity.runOnUiThread(() -> { if (!isCurrentGeneration(generation) || enriched == null || !mergeRecommendationRatings(personalTmdbRecommendations, enriched.getItems())) return; if (personalTmdbPage != null) personalTmdbPage = personalTmdbPage.withItems(personalTmdbRecommendations); - if (service != null) service.writeCachedTmdbPage(eventVod, currentItem, personalTmdbPage); if (eventVod != null) notifyVodChanged(eventVod, generation, RefreshEvent.Type.VOD_PERSONAL); }); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java deleted file mode 100644 index dd89838343..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TouchOptimizationHelper.java +++ /dev/null @@ -1,253 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import android.app.Dialog; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.webkit.WebView; -import android.widget.EditText; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.utils.Util; - -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.Method; -import java.lang.reflect.Proxy; -import java.lang.ref.WeakReference; -import java.util.Collections; -import java.util.Map; -import java.util.Set; -import java.util.WeakHashMap; - -public final class TouchOptimizationHelper { - - private static final String BASE_GRID_VIEW = "androidx.leanback.widget.BaseGridView"; - private static final int FOCUS_SCROLL_ALIGNED = 0; - private static final int FOCUS_SCROLL_ITEM = 1; - private static final Map ORIGINAL_TOUCH_MODES = new WeakHashMap<>(); - private static final Map GRID_STATES = new WeakHashMap<>(); - private static final Set RECYCLERS_WITH_LISTENER = Collections.newSetFromMap(new WeakHashMap<>()); - - private TouchOptimizationHelper() { - } - - public static void sync(View root) { - if (!Util.isLeanback()) return; - if (root == null) return; - if (Setting.isTouchOptimized()) optimize(root); - else restore(root); - } - - public static void sync(Dialog dialog) { - if (!Util.isLeanback()) return; - if (dialog == null || dialog.getWindow() == null) return; - View decor = dialog.getWindow().getDecorView(); - decor.post(() -> sync(decor)); - } - - public static void optimize(View root) { - if (root == null || !Setting.isTouchOptimized()) return; - traverse(root, true); - } - - public static void restore(View root) { - if (root == null) return; - traverse(root, false); - } - - /** Returns whether a leanback grid is receiving a touch gesture or settling from one. */ - public static boolean isTouchActive(View view) { - if (view == null) return false; - synchronized (GRID_STATES) { - for (Map.Entry entry : GRID_STATES.entrySet()) { - View grid = entry.getKey(); - GridState state = entry.getValue(); - if (state == null || (!state.touchActive && !state.touchSettling) || grid == null) continue; - if (view == grid || isDescendantOf(view, grid)) return true; - } - } - return false; - } - - private static void traverse(View view, boolean optimize) { - if (isInputView(view)) return; - if (view instanceof RecyclerView recycler) { - if (optimize) attachListener(recycler); - else restoreGrid(recycler); - } - if (isBaseGridView(view)) { - if (optimize) attachGrid(view); - else restoreGrid(view); - } - if (optimize) { - if (view.isFocusableInTouchMode()) { - if (!ORIGINAL_TOUCH_MODES.containsKey(view)) ORIGINAL_TOUCH_MODES.put(view, true); - view.setFocusableInTouchMode(false); - } - } else { - Boolean original = ORIGINAL_TOUCH_MODES.remove(view); - if (Boolean.TRUE.equals(original)) view.setFocusableInTouchMode(true); - } - if (view instanceof ViewGroup group) { - for (int i = 0; i < group.getChildCount(); i++) traverse(group.getChildAt(i), optimize); - } - } - - private static void attachListener(RecyclerView recycler) { - if (!RECYCLERS_WITH_LISTENER.add(recycler)) return; - recycler.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() { - @Override - public void onChildViewAttachedToWindow(@NonNull View view) { - sync(view); - } - - @Override - public void onChildViewDetachedFromWindow(@NonNull View view) { - } - }); - } - - private static void attachGrid(View grid) { - synchronized (GRID_STATES) { - if (GRID_STATES.containsKey(grid)) return; - GridState state = new GridState(); - state.grid = new WeakReference<>(grid); - state.focusStrategy = invokeInt(grid, "getFocusScrollStrategy", FOCUS_SCROLL_ALIGNED); - state.focusSearchDisabled = invokeBoolean(grid, "isFocusSearchDisabled", false); - GRID_STATES.put(grid, state); - try { - Class touchListener = Class.forName(BASE_GRID_VIEW + "$OnTouchInterceptListener"); - Method setter = grid.getClass().getMethod("setOnTouchInterceptListener", touchListener); - setter.invoke(grid, Proxy.newProxyInstance(touchListener.getClassLoader(), new Class[]{touchListener}, handler(state))); - ((RecyclerView) grid).addOnScrollListener(new RecyclerView.OnScrollListener() { - @Override - public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { - if (newState == RecyclerView.SCROLL_STATE_IDLE && !state.touchActive) state.touchSettling = false; - } - }); - } catch (ReflectiveOperationException | RuntimeException ignored) { - GRID_STATES.remove(grid); - } - } - } - - private static InvocationHandler handler(GridState state) { - return (proxy, method, args) -> { - if ("onInterceptTouchEvent".equals(method.getName()) && args != null && args.length > 0) { - return onTouch(state, (MotionEvent) args[0]); - } - if (method.getReturnType() == boolean.class) return false; - return null; - }; - } - - private static boolean onTouch(GridState state, MotionEvent event) { - if (event == null || !Setting.isTouchOptimized()) { - state.touchGeneration++; - restoreGrid(state.grid.get()); - return false; - } - View grid = state.grid.get(); - if (grid == null) return false; - if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { - state.touchGeneration++; - state.touchSettling = false; - state.touchActive = true; - invoke(grid, "setFocusScrollStrategy", int.class, FOCUS_SCROLL_ITEM); - invoke(grid, "setFocusSearchDisabled", boolean.class, true); - } else if (event.getActionMasked() == MotionEvent.ACTION_UP) { - int generation = ++state.touchGeneration; - state.touchActive = false; - restoreGrid(grid); - state.touchSettling = true; - grid.post(() -> { - if (state.touchGeneration == generation && !state.touchActive - && (!(grid instanceof RecyclerView recycler) || recycler.getScrollState() == RecyclerView.SCROLL_STATE_IDLE)) { - state.touchSettling = false; - } - }); - } else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { - state.touchGeneration++; - restoreGrid(grid); - } - return false; - } - - private static void restoreGrid(RecyclerView recycler) { - if (!isBaseGridView(recycler)) return; - synchronized (GRID_STATES) { - GridState state = GRID_STATES.get(recycler); - if (state == null) return; - View grid = state.grid.get(); - if (grid != null) restoreGrid(grid); - } - } - - private static void restoreGrid(View grid) { - GridState state = GRID_STATES.get(grid); - if (state == null) return; - invoke(grid, "setFocusScrollStrategy", int.class, state.focusStrategy); - invoke(grid, "setFocusSearchDisabled", boolean.class, state.focusSearchDisabled); - state.touchActive = false; - state.touchSettling = false; - } - - private static boolean isBaseGridView(View view) { - try { - return Class.forName(BASE_GRID_VIEW).isInstance(view); - } catch (ClassNotFoundException e) { - return false; - } - } - - private static boolean isDescendantOf(View view, View parent) { - View current = view; - while (current != null) { - if (current == parent) return true; - if (!(current.getParent() instanceof View next)) return false; - current = next; - } - return false; - } - - private static int invokeInt(View target, String name, int fallback) { - try { - Object value = target.getClass().getMethod(name).invoke(target); - return value instanceof Integer ? (Integer) value : fallback; - } catch (ReflectiveOperationException | RuntimeException e) { - return fallback; - } - } - - private static boolean invokeBoolean(View target, String name, boolean fallback) { - try { - Object value = target.getClass().getMethod(name).invoke(target); - return value instanceof Boolean ? (Boolean) value : fallback; - } catch (ReflectiveOperationException | RuntimeException e) { - return fallback; - } - } - - private static void invoke(View target, String name, Class parameter, Object value) { - try { - target.getClass().getMethod(name, parameter).invoke(target, value); - } catch (ReflectiveOperationException | RuntimeException ignored) { - } - } - - private static boolean isInputView(View view) { - return view instanceof WebView || view instanceof EditText || view.onCheckIsTextEditor(); - } - - private static final class GridState { - private WeakReference grid; - private int focusStrategy; - private boolean focusSearchDisabled; - private boolean touchActive; - private boolean touchSettling; - private int touchGeneration; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TvFocusScale.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TvFocusScale.java deleted file mode 100644 index a3cde29b8c..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TvFocusScale.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import android.view.View; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.theme.ThemeController; - -/** - * 电视端「聚焦时放大」的唯一实现、唯一时长来源,以及两个有据可依的强调档位。 - * - *

改动前,同一个焦点缩放在 TV 端有四种写法:最近观看/收藏/历史用 {@code 1.1f} + - * {@code 150ms},TMDB 相关视频与详情页视频卡用 {@code 1.04f} + {@code 120ms}, - * 网络诊断面板用 {@code 1.035f} + {@code 100ms}。数值与时长都没有记录说明为什么不同, - * 用户学到的是「聚焦有时放大、有时不放大、幅度还不一样」。 - * - *

为什么保留两个档位而不是一个。收敛过程中查到一个反证: - * {@code TmdbDetailActivityLayoutTest} 记录 {@code TmdbEpisodeAdapter} 刻意**完全不缩放**, - * 理由是「detail rows clip enlarged cards」——详情页那些密集行会把放大的卡片裁掉。 - * 所以密集宿主上的小倍率不是随意漂移,而是为了避免裁切。把 {@code 1.04} 一律提到 - * {@code 1.1} 会引入真实的裁切回归。因此本类只做真正该做的收敛: - * - *

    - *
  • 一套实现:缩放/复位只有这里一份,站点不再各自写 animate 链;
  • - *
  • 一个时长:全部取 {@code @integer/webhtv_focus_scale_duration};
  • - *
  • 两个档位:常规宿主取主题 token 的倍率,密集宿主取本类显式声明的紧凑倍率。
  • - *
- * - *

明确不在收敛范围内的(它们不是焦点缩放,别顺手改):{@code AudioMiniPlayer} 的 - * {@code 1.03f/90ms} 是拖拽反馈、{@code 0.94f→1f/180ms} 是出现动画; - * {@code PlayerGesture} 的缩放是画面手势;{@code TmdbCardFocusHelper} / - * {@code TmdbEpisodeAdapter} 的 {@code setScaleX(1f)} 是刻意不缩放——那批卡片用 - * 焦点环 + 抬升表达焦点,是另一套已统一的机制。 - */ -public final class TvFocusScale { - - /** - * 密集宿主的紧凑倍率。 - * - *

用于 TMDB 相关视频/剧集这类行内卡片:放大后会被所在行裁切(见类注释的反证), - * 因此不能取主题的常规倍率。这个值是**唯一**允许写死缩放字面量的位置, - * 由 {@code TvFocusScaleContractTest} 钉住;若将来它也需要按主题调整, - * 应移入 {@code ThemeTokens} 而不是再散落到站点里。 - */ - private static final float COMPACT_SCALE = 1.04f; - - private TvFocusScale() { - } - - /** 常规卡片/列表项:聚焦放大到主题 token 的倍率,失焦回到 1.0。 */ - public static void apply(View view, boolean focused) { - animate(view, focused ? ThemeController.current().focusScale() : 1f); - } - - /** 密集宿主:聚焦放大到紧凑倍率,失焦回到 1.0。 */ - public static void applyCompact(View view, boolean focused) { - animate(view, focused ? COMPACT_SCALE : 1f); - } - - /** 不带动画地把视图复位,供 {@code onUnbindViewHolder} 之类的复用清理路径使用。 */ - public static void reset(View view) { - view.animate().cancel(); - view.setScaleX(1f); - view.setScaleY(1f); - } - - private static void animate(View view, float scale) { - view.animate().cancel(); - view.animate() - .scaleX(scale) - .scaleY(scale) - .setDuration(view.getResources().getInteger(R.integer.webhtv_focus_scale_duration)) - .start(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelReaderHost.java b/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelReaderHost.java index d53d29e11d..415bff1f21 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelReaderHost.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelReaderHost.java @@ -14,9 +14,6 @@ public interface NovelReaderHost { * 让播放器切换集数并执行解析(相当于点击了某集)。 * * @param chapterUrl 章节 URL(episode.getUrl()) - * @return 是否真的发出了解析请求。宿主只在「当前线路」的集数里按 URL 找章节, - * 而阅读器的章节表是跨线路合并的,点到别条线路的章节时会找不到而静默返回; - * 返回 false 让调用方立刻收尾,不必等在途标记靠时限过期。 */ - boolean labPlayEpisode(String chapterUrl); + void labPlayEpisode(String chapterUrl); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java b/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java index c9aa99ea04..07cab4a3a8 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java @@ -6,14 +6,11 @@ import android.content.Context; import android.content.Intent; import android.os.Handler; import android.os.Looper; -import android.text.TextUtils; import com.fongmi.android.tv.api.SiteApi; -import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.ComicSourceConfig; import com.fongmi.android.tv.bean.Episode; import com.fongmi.android.tv.bean.Flag; -import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.bean.NovelSourceConfig; import com.fongmi.android.tv.bean.Result; import com.fongmi.android.tv.bean.Vod; @@ -26,9 +23,6 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; /** * 集数点击 / 播放入口路由器:决定是「小说/漫画阅读」还是「普通播放」。 @@ -43,16 +37,7 @@ import java.util.concurrent.atomic.AtomicInteger; public final class NovelRouter { private static final ExecutorService executor = Executors.newSingleThreadExecutor(); - private static final AtomicInteger HISTORY_REQUESTS = new AtomicInteger(); - private static final AtomicInteger CONTENT_REQUESTS = new AtomicInteger(); - - private static Handler mainHandler() { - return MainHandler.INSTANCE; - } - - private static class MainHandler { - private static final Handler INSTANCE = new Handler(Looper.getMainLooper()); - } + private static final Handler main = new Handler(Looper.getMainLooper()); public interface Fallback { void run(); } @@ -76,13 +61,11 @@ public final class NovelRouter { pd.setMessage("正在识别内容…"); pd.setCancelable(true); pd.show(); - int request = CONTENT_REQUESTS.incrementAndGet(); - AtomicBoolean canceled = new AtomicBoolean(false); - Future future = executor.submit(() -> { + executor.execute(() -> { Runnable launch = null; try { Result r = SiteApi.playerContent(pure, flag == null ? "" : flag.getFlag(), episode.getUrl()); - String u = readerPayload(r); + String u = firstContent(r); if (isComic(u)) { final String p = u; launch = () -> openPics(ctx, siteKey, flag, vod, episode, p); @@ -103,18 +86,13 @@ public final class NovelRouter { } // startActivity 统一回主线程执行,与 dismiss 保持时序 final Runnable finalLaunch = launch; - mainHandler().post(() -> { + main.post(() -> { try { pd.dismiss(); } catch (Throwable ignore) {} - if (canceled.get() || request != CONTENT_REQUESTS.get() || isDead(ctx)) return; + if (isDead(ctx)) return; if (finalLaunch != null) finalLaunch.run(); else if (fallback != null) fallback.run(); }); }); - pd.setOnCancelListener(ignored -> { - canceled.set(true); - CONTENT_REQUESTS.compareAndSet(request, request + 1); - future.cancel(true); - }); return true; } @@ -144,21 +122,15 @@ public final class NovelRouter { // 阅读器已在前台 → 回传解析结果,不重复启动(切换章节场景) WebReaderActivity reader = currentReader; if (reader != null && !reader.isFinishing() && !reader.isDestroyed()) { - if (!reader.hasPendingHostChapterRequest() && NovelRouter.consumeStaleChapterResult()) return false; - // 不在这里清表:结清哪一条只有阅读器知道(它持有本次请求的令牌), - // 在这里猜或整表清空都会抹掉另一次仍在途的请求,返回键会重新失效。 - String payload = readerPayload(result); - if (TextUtils.isEmpty(payload)) return false; - reader.onEpisodeResolved(kind, payload, extractTitle(payload)); + reader.onEpisodeResolved(kind, result.getRealUrl(), extractTitle(result.getRealUrl())); return true; } - // 刚关闭 / 属于已关阅读器的在途切章:都是返回后残留的回调,不再拉起 - if (shouldSuppressRelaunch()) return false; + // 用户刚关闭阅读器(1.5 秒内):这是返回后残留的回调,不再拉起 + if (readerClosedAt > 0 && System.currentTimeMillis() - readerClosedAt < 1500) return false; if (ctx instanceof NovelReaderHost) setHost((NovelReaderHost) ctx); - String payload = readerPayload(result); - if (TextUtils.isEmpty(payload)) return false; + String payload = result.getRealUrl(); ArrayList ch = new ArrayList<>(); if (chapters != null) ch.addAll(chapters); int index = 0; @@ -194,7 +166,7 @@ public final class NovelRouter { } /** 从 Result 多字段中提取首个有效 play_url。优先返回带 novel:///pics:///manga:// 协议前缀的字段,避免内容藏在 msg/header 时漏判。 */ - public static String readerPayload(Result r) { + private static String firstContent(Result r) { if (r == null) return null; String playUrl = r.getPlayUrl(); String urlV = null; @@ -220,7 +192,7 @@ public final class NovelRouter { /** 判定 play_url 协议类型:0=非阅读/普通视频;1=novel:// 小说;2=pics:///manga:// 漫画。 */ public static int readerUrlKind(Result r) { - String u = readerPayload(r); + String u = firstContent(r); if (u == null) return 0; u = u.trim(); if (u.startsWith("novel://")) return 1; @@ -255,7 +227,7 @@ public final class NovelRouter { if (kind == 0) return false; Episode current = episodes == null || position < 0 || position >= episodes.size() ? null : episodes.get(position); - String payload = readerPayload(result); + String payload = result.getRealUrl(); String title = extractTitle(payload); if (title == null || title.isEmpty()) title = current == null ? vodName : current.getName(); @@ -264,15 +236,12 @@ public final class NovelRouter { // 阅读器已在前台 → 回传解析结果,不重复启动(切换章节场景) WebReaderActivity reader = currentReader; if (reader != null && !reader.isFinishing() && !reader.isDestroyed()) { - if (!reader.hasPendingHostChapterRequest() && NovelRouter.consumeStaleChapterResult()) return false; - // 不在这里清表:结清哪一条只有阅读器知道(它持有本次请求的令牌), - // 在这里猜或整表清空都会抹掉另一次仍在途的请求,返回键会重新失效。 reader.onEpisodeResolved(kind, payload, title); return true; } - // 刚关闭 / 属于已关阅读器的在途切章:都是返回后残留的 playerContent 回调,不再拉起 - if (shouldSuppressRelaunch()) return false; + // 用户刚关闭阅读器(1.5 秒内):这是返回后残留的 playerContent 回调,不再拉起 + if (readerClosedAt > 0 && System.currentTimeMillis() - readerClosedAt < 1500) return false; ArrayList ch = new ArrayList<>(); if (episodes != null) ch.addAll(episodes); @@ -306,232 +275,6 @@ public final class NovelRouter { return ComicSourceConfig.isEnabledByKey(pure) || NovelSourceConfig.isEnabledByKey(pure); } - /** Opens a readable history item directly, falling back when the source is not readable. */ - public static boolean openHistory(Activity activity, History history, int targetCid, Fallback fallback) { - return openHistory(activity, history, null, null, null, targetCid, fallback); - } - - /** Opens a resolved cross-source history item directly in the reader. */ - public static boolean openHistory(Activity activity, History history, Vod target, - Flag targetFlag, Episode targetEpisode, int targetCid, - Fallback fallback) { - if (activity == null || history == null) return false; - String siteKey = pureSiteKey(target == null ? history.getSiteKey() : target.getSiteKey()); - String vodId = target == null ? history.getVodId() : target.getId(); - if (TextUtils.isEmpty(vodId) || targetCid != VodConfig.getCid()) return false; - if (!isReadableHistoryCandidate(isReaderSite(siteKey), history, targetEpisode)) return false; - - ProgressDialog dialog = new ProgressDialog(activity); - dialog.setMessage("正在打开阅读器..."); - dialog.setCancelable(true); - dialog.setCanceledOnTouchOutside(true); - dialog.show(); - - int request = HISTORY_REQUESTS.incrementAndGet(); - AtomicBoolean canceled = new AtomicBoolean(false); - Future future = executor.submit(() -> { - ReaderData data = resolveHistory(history, target, targetFlag, targetEpisode, targetCid, canceled); - mainHandler().post(() -> { - try { - dialog.dismiss(); - } catch (Throwable ignore) { - } - if (canceled.get() || request != HISTORY_REQUESTS.get() - || targetCid != VodConfig.getCid() || isDead(activity)) return; - if (data != null) openReaderData(activity, data); - else if (fallback != null) fallback.run(); - }); - return data; - }); - dialog.setOnCancelListener(ignored -> { - synchronized (canceled) { - canceled.set(true); - } - future.cancel(true); - }); - return true; - } - - static boolean isReadableHistoryCandidate(boolean readerSite, History history, Episode targetEpisode) { - if (history == null) return false; - return isReaderUrl(history.getEpisodeUrl()) - || isReaderUrl(targetEpisode == null ? null : targetEpisode.getUrl()) - || readerSite - || ReaderHistory.isReaderRecord(history); - } - - private static ReaderData resolveHistory(History history, Vod target, - Flag targetFlag, Episode targetEpisode, int targetCid, - AtomicBoolean canceled) { - try { - if (canceled.get()) return null; - String siteKey = pureSiteKey(target == null ? history.getSiteKey() : target.getSiteKey()); - String vodId = target == null ? history.getVodId() : target.getId(); - Vod vod = target; - if (vod == null || vod.getFlags().isEmpty()) { - Result detail = SiteApi.detailContent(siteKey, vodId); - if (canceled.get()) return null; - vod = detail == null ? null : detail.getVod(); - if (vod == null || vod.getFlags().isEmpty()) return null; - vod.checkName(history.getVodName()); - vod.checkPic(history.getVodPic()); - } - if (TextUtils.isEmpty(vodId)) vodId = vod.getId(); - if (TextUtils.isEmpty(vodId) || vod.getFlags().isEmpty()) return null; - if (targetCid != VodConfig.getCid()) return null; - - Flag flag = targetFlag; - Episode episode = targetEpisode; - if (flag == null || episode == null) { - flag = com.fongmi.android.tv.ui.helper.TmdbUIAdapter.selectPlaybackFlag( - vod.getFlags(), history.getSourceBindingKey(), - history.getEpisodeUrl(), history.getVodFlag()); - if (flag != null) episode = findHistoryEpisode(flag, history); - } - if (flag == null || episode == null) { - for (Flag candidate : vod.getFlags()) { - episode = findHistoryEpisode(candidate, history); - if (episode != null) { - flag = candidate; - break; - } - } - } - if (flag == null || episode == null) { - if (!canDefaultToFirstChapter(vod, history)) return null; - flag = vod.getFlags().get(0); - episode = flag.getEpisodes().get(0); - } - if (TextUtils.isEmpty(episode.getUrl())) return null; - - String payload = episode.getUrl(); - int kind = isComic(payload) ? 2 : isNovel(payload) ? 1 : 0; - if (kind == 0) { - Result result = SiteApi.playerContent(siteKey, flag.getFlag(), payload); - if (canceled.get()) return null; - String content = readerPayload(result); - if (TextUtils.isEmpty(content)) return null; - kind = readerUrlKind(result); - if (kind == 0) kind = kindBySiteRule(siteKey, result); - if (kind == 0) return null; - payload = content; - } - - if (targetCid != VodConfig.getCid()) return null; - - String name = TextUtils.isEmpty(vod.getName()) ? history.getVodName() : vod.getName(); - String pic = TextUtils.isEmpty(vod.getPic()) ? history.getVodPic() : vod.getPic(); - seedCrossSourceHistory(history, siteKey, vodId, flag.getFlag(), episode, name, pic, targetCid, canceled); - ArrayList chapters = chaptersOf(flag, episode); - return new ReaderData(kind, payload, siteKey, flag.getFlag(), vodId, name, pic, - chapters, indexOf(chapters, episode)); - } catch (Throwable ignore) { - return null; - } - } - - static Episode findHistoryEpisode(Flag flag, History history) { - if (flag == null || history == null) return null; - String url = history.getEpisodeUrl(); - if (!TextUtils.isEmpty(url)) { - for (Episode episode : flag.getEpisodes()) { - if (episode != null && TextUtils.equals(url, episode.getUrl())) return episode; - } - } - String name = history.getVodRemarks(); - return TextUtils.isEmpty(name) ? null : flag.find(name, true); - } - - private static void seedCrossSourceHistory(History history, String siteKey, String vodId, - String flag, Episode episode, String name, String pic, - int targetCid) { - if (history == null || TextUtils.isEmpty(siteKey) || TextUtils.isEmpty(vodId)) return; - History target = ReaderHistory.find(targetCid, siteKey, vodId); - if (target != null) { - String expectedKey = ReaderHistory.buildKey(siteKey, vodId, targetCid); - target.replace(expectedKey); - alignResolvedHistoryProgress(target, history, episode.getUrl()); - target.setCid(targetCid); - target.setVodName(name); - target.setVodPic(pic); - target.setVodFlag(flag); - target.setVodRemarks(episode.getName()); - target.setEpisodeUrl(episode.getUrl()); - target.setMediaType(ReaderHistory.MEDIA_TYPE); - target.setCreateTime(System.currentTimeMillis()); - ReaderHistory.saveRow(target); - return; - } - - History seed = new History(); - seed.setKey(ReaderHistory.buildKey(siteKey, vodId, targetCid)); - seed.setCid(targetCid); - seed.setVodName(name); - seed.setVodPic(pic); - seed.setVodFlag(flag); - seed.setVodRemarks(episode.getName()); - alignResolvedHistoryProgress(seed, history, episode.getUrl()); - seed.setEpisodeUrl(episode.getUrl()); - seed.setTmdbId(0); - seed.setMediaType(ReaderHistory.MEDIA_TYPE); - seed.setTmdbSeasonNumber(0); - seed.setTmdbEpisodeNumber(0); - seed.setCreateTime(System.currentTimeMillis()); - ReaderHistory.saveRow(seed); - } - - private static void seedCrossSourceHistory(History history, String siteKey, String vodId, - String flag, Episode episode, String name, String pic, - int targetCid, AtomicBoolean canceled) { - synchronized (canceled) { - if (canceled.get()) return; - seedCrossSourceHistory(history, siteKey, vodId, flag, episode, name, pic, targetCid); - } - } - - /** - * A resolved chapter must not inherit the target row's progress when its URL changed. - * Keep progress only when the target row already represents the resolved chapter; otherwise - * use the source row's progress, or clear it when the source has no valid chapter position. - */ - static void alignResolvedHistoryProgress(History target, History source, String resolvedEpisodeUrl) { - if (target == null || source == null || TextUtils.isEmpty(resolvedEpisodeUrl)) return; - if (TextUtils.equals(target.getEpisodeUrl(), resolvedEpisodeUrl)) return; - if (ReaderHistory.isReaderRecord(source) && source.hasPlaybackTime()) { - target.setPosition(source.getPosition()); - target.setDuration(source.getDuration()); - } else { - target.resetPlaybackPosition(); - } - } - - private static boolean canDefaultToFirstChapter(Vod vod, History history) { - if (vod == null || vod.getFlags() == null || vod.getFlags().size() != 1) return false; - Flag flag = vod.getFlags().get(0); - if (flag == null || flag.getEpisodes() == null || flag.getEpisodes().size() != 1) return false; - return TextUtils.isEmpty(history.getEpisodeUrl()) - && TextUtils.isEmpty(history.getVodRemarks()); - } - - private static void openReaderData(Activity activity, ReaderData data) { - Intent intent = new Intent(activity, WebReaderActivity.class); - intent.putExtra(WebReaderActivity.EXTRA_KIND, data.kind); - intent.putExtra(WebReaderActivity.EXTRA_CACHE_KEY, - WebReaderActivity.cacheLargeData(data.payload, data.chapters)); - intent.putExtra(WebReaderActivity.EXTRA_SITE_KEY, data.siteKey); - intent.putExtra(WebReaderActivity.EXTRA_FLAG, data.flag); - intent.putExtra(WebReaderActivity.EXTRA_VOD_ID, data.vodId); - intent.putExtra(WebReaderActivity.EXTRA_VOD_NAME, data.name); - intent.putExtra(WebReaderActivity.EXTRA_VOD_PIC, data.pic); - intent.putExtra(WebReaderActivity.EXTRA_INDEX, data.index); - activity.startActivity(intent); - } - - private record ReaderData(int kind, String payload, String siteKey, String flag, - String vodId, String name, String pic, - ArrayList chapters, int index) { - } - /** * 站点级分流:命中小说 / 漫画源规则时,后台取详情 + 解析首章,直接进阅读器, * 不再打开播放页(解决「先进播放页再报错」)。 @@ -547,10 +290,8 @@ public final class NovelRouter { pd.setMessage("正在加载…"); pd.setCancelable(true); pd.show(); - int request = CONTENT_REQUESTS.incrementAndGet(); - AtomicBoolean canceled = new AtomicBoolean(false); - Future future = executor.submit(() -> { + executor.execute(() -> { String error = null; Runnable launch = null; try { @@ -567,7 +308,7 @@ public final class NovelRouter { if (kind == 0) { // 章节 url 不是阅读协议 → 解析首章内容 Result r = SiteApi.playerContent(pure, flag.getFlag(), ep.getUrl()); - String u = readerPayload(r); + String u = firstContent(r); if (u == null || u.isEmpty()) throw new IllegalStateException("章节内容为空"); kind = isComic(u) ? 2 : isNovel(u) ? 1 : 0; if (kind == 0) { @@ -599,19 +340,14 @@ public final class NovelRouter { final String finalError = error; final Runnable finalLaunch = launch; final boolean fallback = launch == null && error == null; - mainHandler().post(() -> { + main.post(() -> { try { pd.dismiss(); } catch (Throwable ignore) {} - if (canceled.get() || request != CONTENT_REQUESTS.get() || isDead(activity)) return; + if (isDead(activity)) return; if (finalLaunch != null) finalLaunch.run(); else if (fallback) openPlayerFallback(activity, key, id, name, pic, mark); else Notify.show(finalError); }); }); - pd.setOnCancelListener(ignored -> { - canceled.set(true); - CONTENT_REQUESTS.compareAndSet(request, request + 1); - future.cancel(true); - }); return true; } @@ -667,7 +403,7 @@ public final class NovelRouter { // 要求二次解析 → 内容还没解析出来,不是阅读数据 if (result.needParse()) return 0; - String u = readerPayload(result); + String u = firstContent(result); if (u == null || u.isEmpty()) return 0; u = u.trim(); @@ -704,103 +440,9 @@ public final class NovelRouter { java.lang.ref.WeakReference ref = hostRef; return ref == null ? null : ref.get(); } - /** 阅读器关闭时刻(单调时钟),用于拦截「返回后残留 playerContent 回调又重新拉起阅读器」。 */ + /** 阅读器关闭时间戳,用于拦截「返回后残留 playerContent 回调又重新拉起阅读器」。 */ public static volatile long readerClosedAt = 0L; - /** - * 阅读器关闭代号:每次交还前台自增一次。 - * - * 单靠 readerClosedAt 的 1500ms 窗口不够 —— 用户点了下一章又立刻返回时,爬虫可能几秒后才回, - * 这条迟到的结果落在窗口外就会重新拉起阅读器(就是「返回不了、只能强杀」的表现)。 - * 阅读器发起切章时记下代号,结果回来时比对:代号变过说明这期间阅读器被关过,结果作废。 - */ - private static volatile long readerCloseGen = 0L; - - /** - * 交给宿主解析、尚未收尾的切章请求数。 - * - * 只需要「数量」而不需要「身份」:抑制规则本身是「用户按返回那一刻若有请求在途, - * 它们的结果一律不许再拉起阅读器」。按身份追踪反而做不对 —— 结果送达那一刻 - * 拿不到「这是哪一章的」,之前按令牌 / 按最早 / 整表清空的写法都会删错条目。 - */ - private static final java.util.concurrent.atomic.AtomicInteger inFlightChapters = new java.util.concurrent.atomic.AtomicInteger(); - - /** 关闭时在途的请求数:它们的结果还在路上,必须逐个拦下。 */ - private static final java.util.concurrent.atomic.AtomicInteger staleChapterResults = new java.util.concurrent.atomic.AtomicInteger(); - - /** 上述待拦额度的失效时刻(单调时钟);过期即清,避免永久吞掉合法打开。 */ - private static volatile long staleUntil = 0L; - - /** - * 待拦结果的有效期。 - * - * 宿主解析有多条静默失败路径不回到本类(playerContent 报错走 onError、 - * 二次解析出来是普通视频地址),那些结果永远不会到达。用时限兜底, - * 否则额度下不去,用户之后主动打开别的书会被一直误吞。 - * 取 45s:HTML 侧切章看门狗 30s 就会放弃并回退章节。 - */ - private static final long PENDING_CHAPTER_TTL = 45_000L; - - /** 交还前台时调用:作废所有在途的切章结果。 */ - public static void markReaderClosed() { - // 单调时钟:wall clock 被 NTP 校正 / 用户改时间往回跳时, - // 「现在 - 关闭时刻」会变成大负数而恒小于窗口,静默期就永不结束, - // 之后所有阅读打开都被当成残留回调拦掉。 - readerClosedAt = android.os.SystemClock.elapsedRealtime(); - readerCloseGen++; - // 关闭这一刻仍在途的请求,其结果都属于「上一轮」,逐个拦下。 - // 没有在途请求时不留额度 —— 否则会无条件吞掉关闭后的第一次合法打开。 - int pending = inFlightChapters.getAndSet(0); - if (pending > 0) { - staleChapterResults.addAndGet(pending); - staleUntil = Math.max(staleUntil, readerClosedAt + PENDING_CHAPTER_TTL); - } - } - - /** 阅读器把切章交给宿主解析前调用。 */ - public static void noteChapterRequest() { - inFlightChapters.incrementAndGet(); - } - - /** 一次在途请求已收尾(结果送达或判失败)。 */ - public static void endChapterRequest() { - inFlightChapters.updateAndGet(n -> n > 0 ? n - 1 : 0); - } - - /** - * 这条结果是否属于「已经被关掉的那个阅读器」发起的切章。 - * 是则必须丢弃:用户已经返回,重新拉起阅读器就是返回键失效的根因。 - */ - private static boolean isStaleChapterResult() { - if (staleChapterResults.get() <= 0) return false; - if (android.os.SystemClock.elapsedRealtime() > staleUntil) { - staleChapterResults.set(0); - return false; - } - staleChapterResults.updateAndGet(n -> n > 0 ? n - 1 : 0); - return true; - } - - /** 公开一次性消费判定:true 表示这是已关闭阅读器的迟到结果。 */ - public static boolean consumeStaleChapterResult() { - return isStaleChapterResult(); - } - - - - /** 关闭后的静默期:刚返回时残留的回调一律不再拉起阅读器。 */ - private static boolean justClosed() { - return readerClosedAt > 0 && android.os.SystemClock.elapsedRealtime() - readerClosedAt < 1500; - } - - /** 前台没有阅读器时,判断这条结果该不该拉起新阅读器。 */ - private static boolean shouldSuppressRelaunch() { - // 两个判定都要执行,不能用 || 短路:isStaleChapterResult() 是一次性的读后清, - // 被短路掉标记就留了下来,等静默期过后用户主动打开另一本书时会被它误吞。 - boolean stale = isStaleChapterResult(); - return justClosed() || stale; - } - /** * 播放入口汇聚点(PlaybackActivity.startPlayer)调用: * playerContent 已返回 novel:// / pics:// / manga:// 这类「阅读内容协议」时, @@ -818,7 +460,7 @@ public final class NovelRouter { int kind = readerUrlKind(result); if (kind == 0) return false; - String payload = readerPayload(result); + String payload = result.getRealUrl(); String flag = result.getFlag() == null ? "" : result.getFlag(); // 关键修复:startPlayer 传入的 key 是 getHistoryKey()(siteKey@@@vodId@@@1), @@ -849,16 +491,13 @@ public final class NovelRouter { // 阅读器已在前台 → 回传解析结果,不重复启动(解决「切换章节回不到播放器」) WebReaderActivity reader = currentReader; if (reader != null && !reader.isFinishing() && !reader.isDestroyed()) { - if (!reader.hasPendingHostChapterRequest() && NovelRouter.consumeStaleChapterResult()) return false; - // 不在这里清表:结清哪一条只有阅读器知道(它持有本次请求的令牌), - // 在这里猜或整表清空都会抹掉另一次仍在途的请求,返回键会重新失效。 reader.onEpisodeResolved(kind, payload, extractTitle(payload)); return true; } // 用户刚关闭阅读器(1.5 秒内),说明这是返回后残留的 playerContent 回调, // 不再拉起阅读器,让播放器页面正常展示。 - if (shouldSuppressRelaunch()) { + if (readerClosedAt > 0 && System.currentTimeMillis() - readerClosedAt < 1500) { return false; } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/novel/ReaderHistory.java b/app/src/main/java/com/fongmi/android/tv/ui/novel/ReaderHistory.java index 882c0b3baa..95e35e7d84 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/novel/ReaderHistory.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/novel/ReaderHistory.java @@ -19,10 +19,8 @@ import java.util.Objects; * * 复用 History 表,因此阅读记录会和影视记录一起出现在历史列表里: * - episodeUrl / vodRemarks:上次读到的章节 URL 与章节名,用于重进时定位章节 - * - position / duration:章节内锚点序号与锚点总数(锚点即小说的段落、漫画的页)。 - * position/duration 恰好等于「已读比例」,历史列表的进度条无需特殊处理; - * 读到最后一个锚点时 position 记为 duration,让「读完」显示 100%。 - * 恢复时用 {@link #toAnchor(long, long)} 换回锚点序号。 + * - position / duration:章节内锚点序号与锚点总数。锚点即小说的段落、漫画的页, + * position/duration 恰好等于「已读比例」,历史列表的进度条无需特殊处理。 * * 记锚点序号而不记滚动百分比,是因为文档高度并不稳定:漫画图片分批懒加载且异步解码, * 小说段落用了 content-visibility:auto(屏外段落先按估算高度占位), @@ -35,21 +33,13 @@ public final class ReaderHistory { /** 旧版小说进度的放大倍数(现仅用于兼容历史记录)。 */ public static final long SCALE = 10000L; - /** 历史表是阅读与影视共用表,阅读记录必须显式标注类型。 */ - public static final String MEDIA_TYPE = "reader"; - - private static final String KEY_NAMESPACE = AppDatabase.SYMBOL + MEDIA_TYPE; private ReaderHistory() { } /** 阅读记录 key,与音频一致带上 cid,避免换配置后串记录。 */ public static String buildKey(String siteKey, String vodId) { - return buildKey(siteKey, vodId, VodConfig.getCid()); - } - - public static String buildKey(String siteKey, String vodId, int cid) { - return siteKey + AppDatabase.SYMBOL + vodId + AppDatabase.SYMBOL + cid + KEY_NAMESPACE; + return siteKey + AppDatabase.SYMBOL + vodId + AppDatabase.SYMBOL + VodConfig.getCid(); } private static String buildLegacyKey(String siteKey, String vodId) { @@ -63,24 +53,10 @@ public final class ReaderHistory { /** 查询已保存的阅读记录;null 表示没读过。 */ @Nullable public static History find(String siteKey, String vodId) { - return find(VodConfig.getCid(), siteKey, vodId); - } - - @Nullable - public static History find(int cid, String siteKey, String vodId) { if (!canUse(siteKey, vodId)) return null; - History history = History.find(cid, buildKey(siteKey, vodId, cid)); - if (isReaderRecord(history)) return history; - // 老版本阅读记录没有类型标记,且旧 key 可能与普通视频完全相同;无法可靠区分时 - // 宁可让旧进度失效,也不能把同键的影视进度当作阅读进度或被其覆盖。 - History legacy = History.find(cid, buildLegacyKey(siteKey, vodId)); - return isReaderRecord(legacy) ? legacy : null; - } - - static boolean isReaderRecord(@Nullable History history) { - if (history == null) return false; - if (MEDIA_TYPE.equals(history.getMediaType())) return true; - return history.getKey().endsWith(KEY_NAMESPACE); + History history = History.find(buildKey(siteKey, vodId)); + if (history == null) history = History.find(buildLegacyKey(siteKey, vodId)); + return history; } /** @@ -93,65 +69,33 @@ public final class ReaderHistory { if (Setting.isIncognito() || record == null || !record.canUse()) return; if (total <= 0) return; long duration = total; - long position = toPosition(anchor, duration); - int cid = VodConfig.getCid(); - Task.execute(() -> saveSync(cid, record, chapterName, chapterUrl, position, duration)); + long position = Math.max(0, Math.min(duration, anchor)); + Task.execute(() -> saveSync(record, chapterName, chapterUrl, position, duration)); } - /** - * 锚点序号 → 落库进度值。 - * - * 历史列表按 position/duration 画进度条。读到最后一个锚点时直接存 duration, - * 这样「读完」就是 100%(否则 2 页的漫画短章读完只显示 50%);其余情况沿用 - * 0 基序号,与升级前写入的存量记录同语义,不需要数据迁移。 - * - * 存量记录不会被误判成「读完」:旧代码的上限是 min(duration, anchor),而 anchor - * 最大只有 total-1,所以旧记录的 position 必然小于 duration。 - */ - static long toPosition(int anchor, long duration) { - long value = Math.max(0, Math.min(duration - 1, anchor)); - if (value < duration - 1) return value; - // 「读完」编码为 duration。但 duration 恰好等于 SCALE 时不能这么写: - // 那会让整条记录长得和旧版百分比记录(duration == SCALE)一模一样, - // 下次恢复走百分比分支,把锚点当成 0~1 的比例用。这种章少记一个锚点即可。 - return duration == SCALE ? duration - 1 : duration; - } - - /** 落库进度值 → 0 基锚点序号,供阅读器恢复定位。 */ - public static int toAnchor(long position, long duration) { - // total 无意义时不能把 position 原样当序号返回:那会让恢复逻辑去找一个不存在的锚点 - if (duration <= 0) return 0; - // position == duration 是「读完」的编码,换回最后一个锚点的序号 - if (position >= duration) return (int) (duration - 1); - return (int) Math.max(0, position); - } - - private static void saveSync(int cid, Record record, String chapterName, String chapterUrl, - long position, long duration) { - String key = buildKey(record.siteKey, record.vodId, cid); - History history = find(cid, record.siteKey, record.vodId); - if (history == null) { + private static void saveSync(Record record, String chapterName, String chapterUrl, long position, long duration) { + String key = buildKey(record.siteKey, record.vodId); + History history = find(record.siteKey, record.vodId); + boolean created = history == null; + if (created) { history = new History(); history.setKey(key); } else if (!key.equals(history.getKey())) { history.replace(key); + created = true; } - history.setCid(cid); + boolean changedChapter = !TextUtils.equals(chapterUrl, history.getEpisodeUrl()); + history.setCid(VodConfig.getCid()); history.setVodName(record.vodName); history.setVodPic(record.vodPic); history.setVodFlag(record.vodFlag); history.setVodRemarks(chapterName); history.setEpisodeUrl(chapterUrl); - history.setMediaType(MEDIA_TYPE); history.setPosition(position); history.setDuration(duration); history.setCreateTime(System.currentTimeMillis()); - saveRow(history); - App.post(RefreshEvent::history); - } - - static void saveRow(History history) { - AppDatabase.get().getHistoryDao().insertOrUpdate(history); + history.save(); + if (created || changedChapter) App.post(RefreshEvent::history); } /** 阅读身份(一本书 / 一部漫画)。 */ diff --git a/app/src/main/java/com/fongmi/android/tv/ui/player/ShortDramaQueueCoordinator.java b/app/src/main/java/com/fongmi/android/tv/ui/player/ShortDramaQueueCoordinator.java deleted file mode 100644 index 1bdb8ef544..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/player/ShortDramaQueueCoordinator.java +++ /dev/null @@ -1,440 +0,0 @@ -package com.fongmi.android.tv.ui.player; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -/** - * Pure, bounded state machine for the short-drama Exo playlist bridge. - * - *

The coordinator owns identity and event ordering only. It does not know about Android, - * Media3, Activity state, or a player implementation. A session contains one current episode; - * at most one resolved next episode may be retained. A caller must carry the generation from - * {@link #begin(Session)} through parsing, queue insertion, and transition handling.

- */ -public final class ShortDramaQueueCoordinator { - - private static final long FIRST_GENERATION = 1L; - - private long generation; - private Session session; - private int currentIndex = -1; - private QueueItem queued; - - /** Starts a new session and invalidates all work belonging to the previous one. */ - public synchronized Snapshot begin(Session nextSession) { - if (nextSession == null) throw new IllegalArgumentException("session == null"); - generation = nextGeneration(generation); - session = nextSession; - currentIndex = nextSession.currentIndex(); - queued = null; - return snapshotLocked(); - } - - /** Invalidates parsing and queued media for the current session without touching the player. */ - public synchronized long invalidate() { - generation = nextGeneration(generation); - session = null; - currentIndex = -1; - queued = null; - return generation; - } - - public synchronized Snapshot snapshot() { - return snapshotLocked(); - } - - /** Returns the only episode that may be parsed/queued next, or {@code null} at the end. */ - public synchronized ResolveRequest nextRequest() { - return nextRequestLocked(); - } - - /** - * Accepts a resolved next item only when it still belongs to the current generation and - * exactly matches the expected adjacent episode. Duplicate or ineligible results are - * rejected and cannot replace an already queued item. - */ - public synchronized QueueItem acceptResolved( - long requestGeneration, Episode episode, ResolvedItem resolved) { - ResolveRequest expected = nextRequestLocked(); - if (expected == null || queued != null) return null; - if (requestGeneration != generation || !sameEpisode(expected.episode(), episode)) return null; - if (resolved == null || !resolved.isEligible()) return null; - queued = new QueueItem(generation, expected.session(), expected.episode(), resolved); - return queued; - } - - /** - * Commits one natural AUTO transition. Manual/seek/repeat transitions and stale timeline - * callbacks are intentionally ignored. The successful call consumes the queue item, so a - * repeated callback cannot submit the same business transition twice. - */ - public synchronized Transition onMediaItemTransition( - long eventGeneration, String mediaId, TransitionReason reason) { - if (reason != TransitionReason.AUTO || queued == null) return null; - if (eventGeneration != generation || !Objects.equals(mediaId, queued.mediaId())) return null; - Episode previous = currentEpisodeLocked(); - QueueItem consumed = queued; - currentIndex = consumed.episode().index(); - queued = null; - return new Transition(generation, previous, consumed.episode(), nextRequestLocked()); - } - - /** A queued item is present; legacy near-end auto-advance must stay suppressed. */ - public synchronized boolean shouldSuppressLegacyAutoAdvance() { - return session != null && queued != null; - } - - private Snapshot snapshotLocked() { - return new Snapshot(generation, session, currentEpisodeLocked(), queued, nextRequestLocked()); - } - - private ResolveRequest nextRequestLocked() { - if (session == null) return null; - int nextIndex = session.nextIndex(currentIndex); - if (nextIndex < 0 || nextIndex >= session.episodes().size()) return null; - return new ResolveRequest(generation, session, session.episodes().get(nextIndex)); - } - - private Episode currentEpisodeLocked() { - if (session == null || currentIndex < 0 || currentIndex >= session.episodes().size()) return null; - return session.episodes().get(currentIndex); - } - - private static boolean sameEpisode(Episode left, Episode right) { - return left != null && right != null - && left.index() == right.index() - && Objects.equals(left.identity(), right.identity()); - } - - private static long nextGeneration(long current) { - return current == Long.MAX_VALUE ? FIRST_GENERATION : current + 1L; - } - - public enum TransitionReason { - AUTO, - MANUAL, - SEEK, - REPEAT, - UNKNOWN - } - - public enum MediaKind { - HLS, - PROGRESSIVE_MP4 - } - - public static final class Session { - private final String workId; - private final String siteKey; - private final String lineId; - private final String playFlag; - private final boolean reverse; - private final List episodes; - private final int currentIndex; - - public Session( - String workId, - String siteKey, - String lineId, - String playFlag, - List episodes, - int currentIndex, - boolean reverse) { - if (episodes == null || episodes.isEmpty()) throw new IllegalArgumentException("episodes empty"); - if (currentIndex < 0 || currentIndex >= episodes.size()) { - throw new IllegalArgumentException("currentIndex out of range"); - } - List copy = new ArrayList<>(episodes.size()); - for (int i = 0; i < episodes.size(); i++) { - Episode episode = episodes.get(i); - if (episode == null || episode.index() != i) { - throw new IllegalArgumentException("episodes must have contiguous indexes"); - } - copy.add(episode); - } - this.workId = value(workId); - this.siteKey = value(siteKey); - this.lineId = value(lineId); - this.playFlag = value(playFlag); - this.reverse = reverse; - this.episodes = Collections.unmodifiableList(copy); - this.currentIndex = currentIndex; - } - - public String workId() { - return workId; - } - - public String siteKey() { - return siteKey; - } - - public String lineId() { - return lineId; - } - - public String playFlag() { - return playFlag; - } - - public boolean reverse() { - return reverse; - } - - public List episodes() { - return episodes; - } - - public int currentIndex() { - return currentIndex; - } - - private int nextIndex(int current) { - return current + (reverse ? -1 : 1); - } - } - - public static final class Episode { - private final String id; - private final String title; - private final int index; - - public Episode(String id, String title, int index) { - if (index < 0) throw new IllegalArgumentException("index < 0"); - this.id = value(id); - this.title = value(title); - this.index = index; - } - - public String id() { - return id; - } - - public String title() { - return title; - } - - public int index() { - return index; - } - - public String identity() { - if (!id.isEmpty()) return id; - return index + ":" + title; - } - } - - public static final class ResolveRequest { - private final long generation; - private final Session session; - private final Episode episode; - - private ResolveRequest(long generation, Session session, Episode episode) { - this.generation = generation; - this.session = session; - this.episode = episode; - } - - public long generation() { - return generation; - } - - public Session session() { - return session; - } - - public Episode episode() { - return episode; - } - } - - public static final class ResolvedItem { - private final String uri; - private final Map headers; - private final String format; - private final MediaKind kind; - private final boolean drm; - private final boolean requiresSecondParse; - private final boolean live; - private final boolean specialEncryption; - private final boolean concatenated; - private final boolean engineSwitch; - - public ResolvedItem(String uri, Map headers, String format, MediaKind kind) { - this(uri, headers, format, kind, false, false, false, false, false, false); - } - - public ResolvedItem( - String uri, - Map headers, - String format, - MediaKind kind, - boolean drm, - boolean requiresSecondParse, - boolean live, - boolean specialEncryption, - boolean concatenated, - boolean engineSwitch) { - this.uri = value(uri); - this.headers = immutableHeaders(headers); - this.format = value(format); - this.kind = kind; - this.drm = drm; - this.requiresSecondParse = requiresSecondParse; - this.live = live; - this.specialEncryption = specialEncryption; - this.concatenated = concatenated; - this.engineSwitch = engineSwitch; - } - - public String uri() { - return uri; - } - - public Map headers() { - return headers; - } - - public String format() { - return format; - } - - public MediaKind kind() { - return kind; - } - - public boolean isEligible() { - if (uri.isEmpty() || kind == null || drm || requiresSecondParse || live - || specialEncryption || concatenated || engineSwitch) return false; - if (uri.contains("***") || uri.contains("|||")) return false; - try { - URI parsed = new URI(uri); - String scheme = parsed.getScheme(); - return "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme); - } catch (URISyntaxException e) { - return false; - } - } - } - - public static final class QueueItem { - private final long generation; - private final Session session; - private final Episode episode; - private final ResolvedItem resolved; - private final String mediaId; - - private QueueItem(long generation, Session session, Episode episode, ResolvedItem resolved) { - this.generation = generation; - this.session = session; - this.episode = episode; - this.resolved = resolved; - this.mediaId = generation + ":" + episode.identity(); - } - - public long generation() { - return generation; - } - - public Session session() { - return session; - } - - public Episode episode() { - return episode; - } - - public ResolvedItem resolved() { - return resolved; - } - - public String mediaId() { - return mediaId; - } - } - - public static final class Transition { - private final long generation; - private final Episode previous; - private final Episode current; - private final ResolveRequest nextRequest; - - private Transition(long generation, Episode previous, Episode current, ResolveRequest nextRequest) { - this.generation = generation; - this.previous = previous; - this.current = current; - this.nextRequest = nextRequest; - } - - public long generation() { - return generation; - } - - public Episode previous() { - return previous; - } - - public Episode current() { - return current; - } - - public ResolveRequest nextRequest() { - return nextRequest; - } - } - - public static final class Snapshot { - private final long generation; - private final Session session; - private final Episode current; - private final QueueItem queued; - private final ResolveRequest nextRequest; - - private Snapshot(long generation, Session session, Episode current, QueueItem queued, ResolveRequest nextRequest) { - this.generation = generation; - this.session = session; - this.current = current; - this.queued = queued; - this.nextRequest = nextRequest; - } - - public long generation() { - return generation; - } - - public Session session() { - return session; - } - - public Episode current() { - return current; - } - - public QueueItem queued() { - return queued; - } - - public ResolveRequest nextRequest() { - return nextRequest; - } - } - - private static Map immutableHeaders(Map headers) { - if (headers == null || headers.isEmpty()) return Collections.emptyMap(); - Map copy = new LinkedHashMap<>(); - for (Map.Entry entry : headers.entrySet()) { - if (entry.getKey() != null && entry.getValue() != null) copy.put(entry.getKey(), entry.getValue()); - } - return copy.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(copy); - } - - private static String value(String value) { - return value == null ? "" : value.trim(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/player/VodPlayerUiController.java b/app/src/main/java/com/fongmi/android/tv/ui/player/VodPlayerUiController.java index 2bdb87e26d..9d7727149e 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/player/VodPlayerUiController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/player/VodPlayerUiController.java @@ -62,11 +62,6 @@ public class VodPlayerUiController { public String getTitle() { return host.osdTitle(); } - - @Override - public boolean isFullscreen() { - return host.isFullscreen(); - } }, chrome.osdMiniSp ); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/player/VodPlayerUiHost.java b/app/src/main/java/com/fongmi/android/tv/ui/player/VodPlayerUiHost.java index b29d036b35..99fc66ce98 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/player/VodPlayerUiHost.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/player/VodPlayerUiHost.java @@ -15,15 +15,6 @@ public interface VodPlayerUiHost { return false; } - /** - * 当前是否全屏播放。媒体信息徽标只在全屏画面出现,因为半屏 / 小窗里它会把 - * 本来就拥挤的控件再挤一层;默认 false 让未显式覆盖的宿主(例如详情页内嵌小窗) - * 天然不显示。 - */ - default boolean isFullscreen() { - return false; - } - default boolean restoreDiagnosticsOnStart() { return true; } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/web/CatWebActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/web/CatWebActivity.java index ea49b7492a..90a9d383fc 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/web/CatWebActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/web/CatWebActivity.java @@ -20,8 +20,6 @@ import androidx.activity.OnBackPressedCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.appcompat.app.AppCompatActivity; -import androidx.webkit.WebSettingsCompat; -import androidx.webkit.WebViewFeature; import com.fongmi.android.tv.R; import com.fongmi.android.tv.utils.FileChooser; @@ -42,13 +40,9 @@ import java.io.File; */ public class CatWebActivity extends AppCompatActivity { - private static final String DESKTOP_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; - /** 不带 TV- 前缀:SpiderDebug 自己会加,与 cat-msg / cat-source 保持一致。 */ private static final String TAG = "cat-web"; private static final String EXTRA_URL = "url"; - private static final String EXTRA_TITLE = "title"; - private static final String EXTRA_LOADING_TEXT = "loadingText"; private WebView webView; private ProgressBar progress; @@ -61,18 +55,7 @@ public class CatWebActivity extends AppCompatActivity { private ValueCallback chooser; public static Intent intent(Context context, String url) { - return intent(context, url, "", ""); - } - - public static Intent browserIntent(Context context, String url, String title, String loadingText) { - return intent(context, url, title, loadingText); - } - - private static Intent intent(Context context, String url, String title, String loadingText) { - return new Intent(context, CatWebActivity.class) - .putExtra(EXTRA_URL, url) - .putExtra(EXTRA_TITLE, title) - .putExtra(EXTRA_LOADING_TEXT, loadingText); + return new Intent(context, CatWebActivity.class).putExtra(EXTRA_URL, url); } @SuppressLint("SetJavaScriptEnabled") @@ -86,8 +69,6 @@ public class CatWebActivity extends AppCompatActivity { finish(); return; } - String title = getIntent().getStringExtra(EXTRA_TITLE); - if (!TextUtils.isEmpty(title)) setTitle(title); // Android 13+ 手势返回与系统返回键都先让 WebView 回退,退到底再关页面 getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { @@ -101,8 +82,7 @@ public class CatWebActivity extends AppCompatActivity { webView = findViewById(R.id.web_view); progress = findViewById(R.id.progress); loading = findViewById(R.id.loading); - String loadingText = getIntent().getStringExtra(EXTRA_LOADING_TEXT); - ((TextView) findViewById(R.id.loading_text)).setText(TextUtils.isEmpty(loadingText) ? getString(R.string.cat_web_opening) : loadingText); + ((TextView) findViewById(R.id.loading_text)).setText(R.string.cat_web_opening); ((TextView) findViewById(R.id.address)).setText(url); configure(); @@ -118,27 +98,17 @@ public class CatWebActivity extends AppCompatActivity { WebSettings s = webView.getSettings(); s.setJavaScriptEnabled(true); s.setDomStorageEnabled(true); + s.setUseWideViewPort(true); + s.setLoadWithOverviewMode(true); s.setSupportZoom(true); s.setBuiltInZoomControls(true); s.setDisplayZoomControls(false); - s.setUserAgentString(DESKTOP_UA); - s.setUseWideViewPort(true); - s.setLoadWithOverviewMode(true); - s.setTextZoom(100); - // 外部站点自带浅色主题时,系统算法深色化会把加载框变成黑底灰字。 - // 内置页只负责承载网页,不强制替站点换肤。 - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(s, false); - } else if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { - WebSettingsCompat.setForceDark(s, WebSettingsCompat.FORCE_DARK_OFF); - } s.setAllowFileAccess(false); s.setAllowContentAccess(false); s.setCacheMode(WebSettings.LOAD_DEFAULT); // TV 上没有触摸,靠 D-pad 移动焦点;这两项让 WebView 参与焦点链 webView.setFocusable(true); webView.setFocusableInTouchMode(true); - webView.setInitialScale(100); webView.setBackgroundColor(0xFF101216); webView.setWebChromeClient(new WebChromeClient() { @Override diff --git a/app/src/main/java/com/fongmi/android/tv/ui/web/GameWebActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/web/GameWebActivity.java deleted file mode 100755 index 2ede0b09e4..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/web/GameWebActivity.java +++ /dev/null @@ -1,200 +0,0 @@ -package com.fongmi.android.tv.ui.web; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.os.Build; -import android.os.Bundle; -import android.view.View; -import android.view.WindowManager; -import android.webkit.RenderProcessGoneDetail; -import android.webkit.WebChromeClient; -import android.webkit.WebResourceRequest; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.widget.ProgressBar; -import android.widget.Toast; - -import androidx.appcompat.app.AppCompatActivity; - -import com.fongmi.android.tv.R; - -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; - -/** - * 实验室:小游戏内置 WebView 容器。 - * - * 与 WebReaderActivity(加载本地 reader.html 模板渲染 novel:// 数据)不同, - * 本页直接 loadUrl 远程游戏地址(H5 / 模拟器站),全屏沉浸运行。 - * 卡片 vod_id 内嵌 JSON 的 url / header(含自定义 UA)在此消费。 - */ -public class GameWebActivity extends AppCompatActivity { - - public static final String EXTRA_URL = "url"; - public static final String EXTRA_TITLE = "title"; - public static final String EXTRA_UA = "ua"; - public static final String EXTRA_HEADERS = "headers"; // HashMap 序列化 - - private WebView webView; - private ProgressBar progress; - private View loading; - private String loadUrl = ""; - private String ua = ""; - private Map extraHeaders = new HashMap<>(); - private boolean renderedGone; - - public static void start(android.app.Activity activity, String url, String title, String ua, Map headers) { - Intent it = new Intent(activity, GameWebActivity.class); - it.putExtra(EXTRA_URL, url == null ? "" : url); - it.putExtra(EXTRA_TITLE, title == null ? "" : title); - it.putExtra(EXTRA_UA, ua == null ? "" : ua); - if (headers != null) it.putExtra(EXTRA_HEADERS, new HashMap<>(headers)); - activity.startActivity(it); - } - - @SuppressLint({"SetJavaScriptEnabled"}) - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_web_reader); - applyImmersive(); - - // 兼容 Android 13+ enableOnBackInvokedCallback:手势/系统返回直接关闭游戏页 - getOnBackPressedDispatcher().addCallback(this, new androidx.activity.OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - finish(); - } - }); - - Intent it = getIntent(); - loadUrl = it.getStringExtra(EXTRA_URL) == null ? "" : it.getStringExtra(EXTRA_URL); - ua = it.getStringExtra(EXTRA_UA) == null ? "" : it.getStringExtra(EXTRA_UA); - String title = it.getStringExtra(EXTRA_TITLE) == null ? "" : it.getStringExtra(EXTRA_TITLE); - if (!title.isEmpty()) setTitle(title); - //noinspection unchecked - Map headers = (Map) it.getSerializableExtra(EXTRA_HEADERS); - if (headers != null) extraHeaders.putAll(headers); - // UA 单独传递优先;否则从 headers 里取 - if (ua.isEmpty() && extraHeaders.containsKey("User-Agent")) ua = extraHeaders.get("User-Agent"); - - webView = findViewById(R.id.web_view); - progress = findViewById(R.id.progress); - loading = findViewById(R.id.loading); - webView.setBackgroundColor(0xFF101010); - - WebSettings s = webView.getSettings(); - s.setJavaScriptEnabled(true); - s.setDomStorageEnabled(true); - s.setDatabaseEnabled(true); - s.setUseWideViewPort(true); - s.setLoadWithOverviewMode(true); - s.setSupportZoom(false); - s.setAllowFileAccess(false); - s.setAllowContentAccess(false); - s.setMediaPlaybackRequiresUserGesture(false); // 游戏音频自动播放 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - s.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); // 禁用混合内容 - } - if (!ua.isEmpty()) s.setUserAgentString(ua); - - webView.setWebChromeClient(new WebChromeClient() { - @Override - public void onProgressChanged(WebView view, int newProgress) { - if (progress != null) { - if (newProgress >= 100) progress.setVisibility(View.GONE); - else { progress.setVisibility(View.VISIBLE); progress.setProgress(newProgress); } - } - } - }); - webView.setWebViewClient(new WebViewClient() { - @Override - public void onPageFinished(WebView view, String url) { - hideLoading(); - } - - @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - String target = request.getUrl() == null ? "" : request.getUrl().toString(); - return !isWebUrl(target); // 游戏内网页跳转留在容器,外部 scheme 不交给 WebView - } - - @Override - public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) { - // 游戏页渲染进程崩溃:避免整 app 闪退,直接关闭游戏页 - renderedGone = true; - Toast.makeText(GameWebActivity.this, "游戏页面已崩溃退出", Toast.LENGTH_SHORT).show(); - finish(); - return true; - } - }); - - if (!isWebUrl(loadUrl)) { - Toast.makeText(this, loadUrl.isEmpty() ? "游戏地址为空" : "网页地址无效", Toast.LENGTH_SHORT).show(); - finish(); - return; - } - if (extraHeaders.isEmpty()) webView.loadUrl(loadUrl); - else webView.loadUrl(loadUrl, extraHeaders); - } - - private void hideLoading() { - if (loading != null) loading.setVisibility(View.GONE); - } - - private static boolean isWebUrl(String url) { - if (url == null) return false; - String value = url.trim().toLowerCase(Locale.ROOT); - return value.startsWith("http://") || value.startsWith("https://"); - } - - private void applyImmersive() { - getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); // 玩游戏保持常亮 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Android 11+ / targetSdk 35+:FLAG_FULLSCREEN 已失效,必须用 InsetsController 隐藏系统栏 - try { - getWindow().setDecorFitsSystemWindows(false); - android.view.WindowInsetsController c = getWindow().getInsetsController(); - if (c != null) { - c.hide(android.view.WindowInsets.Type.statusBars() | android.view.WindowInsets.Type.navigationBars()); - c.setSystemBarsBehavior(android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); - } - } catch (Throwable ignore) {} - } else { - try { - int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_FULLSCREEN - | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; - getWindow().getDecorView().setSystemUiVisibility(flags); - } catch (Throwable ignore) {} - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - try { - WindowManager.LayoutParams lp = getWindow().getAttributes(); - lp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; - getWindow().setAttributes(lp); - } catch (Throwable ignore) {} - } - } - - @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - // WebView 加载/游戏交互后焦点变化会把系统栏重新拉出来,必须重新隐藏 - if (hasFocus) applyImmersive(); - } - - @Override - protected void onDestroy() { - if (webView != null && !renderedGone) { - webView.loadUrl("about:blank"); - webView.destroy(); - } - super.onDestroy(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java index 3bc34f518f..c9d1832e5a 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java @@ -22,7 +22,6 @@ import androidx.appcompat.app.AppCompatActivity; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Episode; import com.fongmi.android.tv.bean.History; -import com.fongmi.android.tv.service.ReaderTtsService; import com.fongmi.android.tv.ui.novel.NovelReaderHost; import com.fongmi.android.tv.ui.novel.NovelRouter; import com.fongmi.android.tv.ui.novel.ReaderHistory; @@ -74,14 +73,13 @@ public class WebReaderActivity extends AppCompatActivity { String key = "reader_" + System.nanoTime(); if (payload != null) PAYLOAD_CACHE.put(key, payload); if (chapters != null) CHAPTER_CACHE.put(key, new ArrayList<>(chapters)); - // 单调时钟:wall clock 往回跳会让 now-创建时刻 变成负数而永不过期,缓存留到进程结束 - CACHE_TIME.put(key, android.os.SystemClock.elapsedRealtime()); + CACHE_TIME.put(key, System.currentTimeMillis()); return key; } /** 清理超过 TTL 的缓存:阅读器未真正启动时没人来取,否则会留到进程结束。 */ private static void evictStaleCache() { - long now = android.os.SystemClock.elapsedRealtime(); + long now = System.currentTimeMillis(); for (java.util.Map.Entry e : CACHE_TIME.entrySet()) { if (now - e.getValue() <= CACHE_TTL_MS) continue; String k = e.getKey(); @@ -166,25 +164,6 @@ public class WebReaderActivity extends AppCompatActivity { this.total = total; } } - /** - * 本页交给宿主解析、尚未收尾的切章请求数。 - * - * 只数不认身份:结果送达那一刻拿不到「这是哪一章的」,按身份追踪必然删错条目。 - * 抑制规则要的也只是「返回那一刻是否有请求在途」。 - */ - private final java.util.concurrent.atomic.AtomicInteger hostChapterRequests = new java.util.concurrent.atomic.AtomicInteger(); - - /** 收尾一次在途请求(结果送达或判失败);本页没有在途请求时什么都不做。 */ - private void endHostChapterRequest() { - if (hostChapterRequests.getAndUpdate(n -> n > 0 ? n - 1 : 0) > 0) { - NovelRouter.endChapterRequest(); - } - } - - public boolean hasPendingHostChapterRequest() { - return hostChapterRequests.get() > 0; - } - /** 待恢复的章节内锚点与总数(用完置 0)。 */ private long restoreAnchor = 0; private long restoreTotal = 0; @@ -1148,11 +1127,10 @@ public class WebReaderActivity extends AppCompatActivity { // 否则「传入的是不是目标章」会拿目标章和自己比,永远相等。 String incomingUrl = index >= 0 && index < chapters.size() ? chapters.get(index).getUrl() : null; index = at; - // position 是锚点序号(读完时记为 total),换回序号交给 HTML; - // 旧版小说记录存的是百分比×SCALE,HTML 侧按 total 是否等于 SCALE 兜底处理。 + // position/duration 即锚点序号/总数;旧版小说记录存的是百分比×SCALE, + // HTML 侧按 total 是否等于 SCALE 兜底处理。 + restoreAnchor = h.getPosition(); restoreTotal = h.getDuration(); - restoreAnchor = restoreTotal == ReaderHistory.SCALE - ? h.getPosition() : ReaderHistory.toAnchor(h.getPosition(), restoreTotal); String chapterName = h.getVodRemarks() == null ? "" : h.getVodRemarks(); lastProgress = new Progress(url, chapterName, (int) restoreAnchor, (int) restoreTotal); // 传入 payload 已是该章内容时无需重新解析 @@ -1186,15 +1164,8 @@ public class WebReaderActivity extends AppCompatActivity { /** 换章:HTML 点目录时回调。本地模式读目录章节,在线模式自行解析(无播放器时也能切章)。 */ @JavascriptInterface public void loadChapter(String chapterUrl) { - // 空 URL 与任何宿主请求无关:不能让它收尾别人那一笔(会放行迟到结果、返回键失效) - if (TextUtils.isEmpty(chapterUrl)) { chapterFailed(false); return; } + if (TextUtils.isEmpty(chapterUrl)) { chapterFailed(); return; } runOnUiThread(() -> { - // 本页已在交还前台:此刻再发宿主请求,它永远等不到下一次 markReaderClosed - // 把自己转成待拦额度,结果回来时就会把阅读器重新拉起(返回键失效)。 - if (isFinishing() || isDestroyed() || NovelRouter.currentReader != this) { - chapterFailed(false); - return; - } if (!localPath.isEmpty() && isLocalDir(chapterUrl)) { loadLocalChapter(chapterUrl); return; @@ -1206,14 +1177,8 @@ public class WebReaderActivity extends AppCompatActivity { return; } NovelReaderHost h = NovelRouter.getHost(); - // 记下本次切章所属的关闭代号:用户点了下一章又马上返回时,爬虫几秒后才回的结果 - // 会落在 1500ms 静默期之外,靠代号比对才能认出它已过期,不该再拉起阅读器。 - if (h == null) { chapterFailedWithToast(); return; } - NovelRouter.noteChapterRequest(); - hostChapterRequests.incrementAndGet(); - // 宿主只在当前线路里找章节,找不到会静默返回 —— 立刻收尾并报失败, - // 否则这一笔要等 45s 才过期,期间用户主动打开别的书会被误吞。 - if (!h.labPlayEpisode(chapterUrl)) chapterFailedWithToast(true); + if (h != null) h.labPlayEpisode(chapterUrl); + else chapterFailedWithToast(); }); } @@ -1222,20 +1187,6 @@ public class WebReaderActivity extends AppCompatActivity { * 少了这一步,HTML 会一直停在「切章中」,之后所有阅读进度都不再上报。 */ private void chapterFailed() { - chapterFailed(false); - } - - /** - * 通知 HTML 本次切章失败,并按令牌撤销在途标记。 - * - * @param ownHostRequest 这次失败是否对应本页发出的一笔宿主请求。空 URL、注入异常等 - * 与任何请求无关,传 false —— 否则会替别人收尾,让那一笔的迟到 - * 结果不再被拦,用户返回后又被重新拉起阅读器。 - */ - private void chapterFailed(boolean ownHostRequest) { - // 收尾放在 webView 判空之前:改的是 NovelRouter 的全局计数, - // 不该被本页的 view 引用是否还在决定(否则销毁中的页会让计数永久留着)。 - if (ownHostRequest) endHostChapterRequest(); if (webView == null) return; runOnUiThread(() -> { if (webView == null || isFinishing() || isDestroyed()) return; @@ -1246,12 +1197,8 @@ public class WebReaderActivity extends AppCompatActivity { } private void chapterFailedWithToast() { - chapterFailedWithToast(false); - } - - private void chapterFailedWithToast(boolean ownHostRequest) { Toast.makeText(this, R.string.reader_chapter_failed, Toast.LENGTH_SHORT).show(); - chapterFailed(ownHostRequest); + chapterFailed(); } /** @@ -1284,25 +1231,14 @@ public class WebReaderActivity extends AppCompatActivity { final boolean fh = needHost; runOnUiThread(() -> { if (isFinishing() || isDestroyed()) return; - boolean hostDispatched = false; if (fk != 0 && fp != null && !fp.isEmpty()) { if (at >= 0) index = at; - // 自解析成功:没经过宿主,不能替宿主请求收尾 - onEpisodeResolved(fk, fp, at >= 0 ? chapters.get(at).getName() : "", false); + onEpisodeResolved(fk, fp, at >= 0 ? chapters.get(at).getName() : ""); return; } NovelReaderHost h = NovelRouter.getHost(); - // 这条 parse=1 兜底才是实际会走到的宿主解析路径(loadChapter 里那条在 - // siteKey 非空时不可达,而所有真实启动入口都会带 siteKey)。它要走二次解析、 - // 耗时最长,最容易掉出关闭静默期,代号标记必须打在这里。 - if (fh && h != null) { - hostDispatched = true; - NovelRouter.noteChapterRequest(); - hostChapterRequests.incrementAndGet(); - if (!h.labPlayEpisode(chapterUrl)) chapterFailedWithToast(true); - return; - } - if (!hostDispatched) chapterFailedWithToast(); + if (fh && h != null) h.labPlayEpisode(chapterUrl); + else chapterFailedWithToast(); // 解析失败:放开占位层,并丢掉待恢复位置 —— 否则它会残留到用户下一次手动切章, // 把上一本/上一章的锚点套用到新章上(章短时直接跳到章末)。 restoreAnchor = 0; @@ -1367,18 +1303,6 @@ public class WebReaderActivity extends AppCompatActivity { * 把 novel:// / pics:// 解析成阅读数据注入 HTML。 */ public void onEpisodeResolved(int newKind, String payload, String title) { - onEpisodeResolved(newKind, payload, title, true); - } - - /** - * @param fromHost 结果是否来自宿主解析。自解析(resolveChapterSelf 直接拿到内容)时传 false: - * 那条路径没发过宿主请求,不能顺手把仍在途的宿主令牌丢掉 —— 丢了就再没有 - * 句柄去撤销它,只能等 45s TTL,期间用户主动打开别的书会被误吞。 - */ - private void onEpisodeResolved(int newKind, String payload, String title, boolean fromHost) { - // 宿主结果已到达,收尾一笔在途请求。只减计数、不认身份 —— 送达这一刻 - // 拿不到「这是哪一章的结果」,按身份删必然删错,反而放行别人的迟到结果。 - if (fromHost) endHostChapterRequest(); if (webView == null) return; // 漫画分支要下载 PDF(网络),不能在主线程做 RESOLVE_EXECUTOR.execute(() -> { @@ -1460,342 +1384,29 @@ public class WebReaderActivity extends AppCompatActivity { } } - /* ---------------- 朗读(TTS)JS 桥 ---------------- */ - - /** - * 朗读状态回调:把原生朗读的进度送回 WebView 面板(高亮跟随、状态文案、章末续读)。 - * 阅读页销毁时会清空(见 onDestroy),避免服务把状态发给已销毁的 WebView。 - */ - private final ReaderTtsService.WebCallback ttsWebCallback = new ReaderTtsService.WebCallback() { - @Override - public void onTtsState(String state, int paragraph, int total, String message, String title) { - JSONObject json = new JSONObject(); - try { - json.put("state", state); - json.put("paragraph", paragraph); - json.put("total", total); - json.put("message", message == null ? "" : message); - json.put("title", title == null ? "" : title); - } catch (Throwable ignore) { - } - evalJs("window.__onTtsState && window.__onTtsState(" + jsSafe(json.toString()) + ");"); - } - - @Override - public void onTtsParagraph(int paragraph, int total, String text) { - JSONObject json = new JSONObject(); - try { - json.put("paragraph", paragraph); - json.put("total", total); - json.put("text", text == null ? "" : text); - } catch (Throwable ignore) { - } - evalJs("window.__onTtsProgress && window.__onTtsProgress(" + jsSafe(json.toString()) + ");"); - } - - @Override - public void onTtsChapterEnd() { - evalJs("window.__onTtsChapterEnd && window.__onTtsChapterEnd();"); - } - - @Override - public void onTtsVoices(java.util.List voices) { - evalJs("window.__onTtsVoices && window.__onTtsVoices(" + jsSafe(voicesJson(voices)) + ");"); - } - }; - - private void evalJs(String script) { - runOnUiThread(() -> { - WebView view = webView; - if (view == null) return; - try { - view.evaluateJavascript(script, null); - } catch (Throwable ignore) { - } - }); - } - - private static String voicesJson(java.util.List voices) { - JSONArray array = new JSONArray(); - if (voices != null) { - for (com.fongmi.android.tv.tts.TtsVoice voice : voices) { - JSONObject json = new JSONObject(); - try { - json.put("id", voice.id); - json.put("name", voice.name.isEmpty() ? voice.id : voice.name); - json.put("lang", voice.lang); - } catch (Throwable ignore) { - } - array.put(json); - } - } - return array.toString(); - } - - /** - * 朗读面板初始化信息:当前状态 + 音源可用性 + 自定义引擎配置 + 内置音色。 - * 面板打开时调用一次,避免把长配置放在 HTML 里维护两份真相。 - */ - @JavascriptInterface - public String ttsInfo() { - JSONObject info = new JSONObject(); - try { - ReaderTtsService service = ReaderTtsService.get(); - info.put("state", service == null ? "stopped" : service.state()); - info.put("paragraph", service == null ? -1 : service.currentParagraph()); - info.put("total", service == null ? 0 : service.paragraphCount()); - info.put("timer", service == null ? 0 : service.timerMinutes()); - info.put("activeSource", service == null ? "" : service.sourceId()); - boolean systemAvailable = com.fongmi.android.tv.tts.TtsEngines.systemAvailable(this); - info.put("systemAvailable", systemAvailable); - info.put("systemDetail", systemAvailable ? "设备系统语音" : "设备未安装系统 TTS 引擎"); - info.put("edgeVoices", new JSONArray(voicesJson( - com.fongmi.android.tv.tts.TtsEngines.staticVoices(com.fongmi.android.tv.tts.TtsEngines.SOURCE_EDGE)))); - com.fongmi.android.tv.tts.TtsEngineConfig custom = - com.fongmi.android.tv.tts.TtsEngines.loadCustomConfig(); - info.put("custom", custom == null ? JSONObject.NULL : new JSONObject(custom.toJson())); - } catch (Throwable e) { - SpiderDebug.log(TAG, "ttsInfo failed %s", e.getMessage()); - } - return info.toString(); - } - - /** - * 取某个音源的音色列表。 - * - * 系统音色必须等 TextToSpeech 初始化完成(异步),因此这里返回空数组, - * 就绪后通过 {@code window.__onTtsVoices} 异步推送;其余音源为静态列表。 - */ - @JavascriptInterface - public String ttsVoices(String source) { - String id = source == null ? "" : source; - try { - if (com.fongmi.android.tv.tts.TtsEngines.SOURCE_SYSTEM.equals(id)) { - com.fongmi.android.tv.tts.TtsEngines.probeSystemVoices(this, voices -> - evalJs("window.__onTtsVoices && window.__onTtsVoices(" + jsSafe(voicesJson(voices)) + ");")); - return "[]"; - } - return voicesJson(com.fongmi.android.tv.tts.TtsEngines.staticVoices(id)); - } catch (Throwable e) { - SpiderDebug.log(TAG, "ttsVoices failed %s", e.getMessage()); - return "[]"; - } - } - - /** 保存自定义在线朗读引擎(legado 规则子集)。 */ - @JavascriptInterface - public boolean ttsSaveEngine(String json) { - com.fongmi.android.tv.tts.TtsEngineConfig config = - com.fongmi.android.tv.tts.TtsEngineConfig.fromJson(json); - if (config == null || !com.fongmi.android.tv.tts.TtsHttpRule.parse(config.url, config.contentType).isUsable()) { - return false; - } - com.fongmi.android.tv.tts.TtsEngines.saveCustomConfig(config); - return true; - } - - /** - * 开始朗读。 - * - * payload:{source, voice, rate, pitch, timer, index, title, rule, paragraphs:[...]} - * 正文段落不走 Intent(可能很长),而是经静态 StartRequest 传给服务。 - */ - @JavascriptInterface - public void ttsStart(String payload) { - try { - JSONObject json = new JSONObject(payload == null ? "{}" : payload); - String source = json.optString("source", com.fongmi.android.tv.tts.TtsEngines.SOURCE_SYSTEM); - float rate = (float) json.optDouble("rate", 1d); - float pitch = (float) json.optDouble("pitch", 1d); - String voice = json.optString("voice", ""); - int timer = json.optInt("timer", 0); - int index = Math.max(0, json.optInt("index", 0)); - String title = json.optString("title", vodName); - com.fongmi.android.tv.tts.TtsEngineConfig rule = null; - if (com.fongmi.android.tv.tts.TtsEngines.SOURCE_CUSTOM.equals(source)) { - rule = com.fongmi.android.tv.tts.TtsEngineConfig.fromJson(json.optString("rule", "")); - if (rule == null) { - pushTtsError("请先配置在线朗读引擎"); - return; - } - } - JSONArray array = json.optJSONArray("paragraphs"); - ArrayList paragraphs = new ArrayList<>(); - if (array != null) { - for (int i = 0; i < array.length(); i++) { - String text = array.optString(i, ""); - if (!TextUtils.isEmpty(text)) paragraphs.add(text); - } - } - if (paragraphs.isEmpty()) { - pushTtsError("没有可朗读的内容"); - return; - } - com.fongmi.android.tv.tts.TtsOptions options = - new com.fongmi.android.tv.tts.TtsOptions(rate, pitch, voice); - final com.fongmi.android.tv.tts.TtsEngineConfig startRule = rule; - final ReaderTtsService.StartRequest request = new ReaderTtsService.StartRequest( - source, options, startRule, paragraphs, index, timer, title); - // 下发任务同样回主线程:控制器/服务要求主线程语义(见 postToService 的注释), - // 在主线程内「先登记任务再 startForegroundService」也让这次交接天然有序。 - runOnUiThread(() -> { - ReaderTtsService.setPendingStart(request); - ReaderTtsService.setPendingWebCallback(ttsWebCallback); - ReaderTtsService service = ReaderTtsService.get(); - if (service != null) service.setWebCallback(ttsWebCallback); - Intent intent = new Intent(this, ReaderTtsService.class).setAction(ReaderTtsService.ACTION_START); - androidx.core.content.ContextCompat.startForegroundService(this, intent); - }); - } catch (Throwable e) { - SpiderDebug.log(TAG, "ttsStart failed %s", e.getMessage()); - pushTtsError("朗读启动失败:" + e.getMessage()); - } - } - - private void pushTtsError(String message) { - JSONObject json = new JSONObject(); - try { - json.put("state", "error"); - json.put("paragraph", -1); - json.put("total", 0); - json.put("message", message); - } catch (Throwable ignore) { - } - evalJs("window.__onTtsState && window.__onTtsState(" + jsSafe(json.toString()) + ");"); - } - - /** - * 朗读控制桥统一回主线程执行。 - * - * {@code @JavascriptInterface} 方法在 WebView 的 JavaBridge 线程被调用,而朗读控制器与 - * 前台服务都要求主线程语义(引擎回调也一律经 {@code onMain} 归一到主线程:下游要改 - * MediaPlayer、起/停前台通知、向 WebView 注入脚本)。若直接在 JS 线程改控制器状态, - * 就会与主线程的引擎回调并发:既可能读到被 {@code stop()} 换成空表的队列(控制器里 - * size 与 get 的竞态),也会让 startForeground/通知更新发生在非主线程。 - */ - private void postToService(java.util.function.Consumer action) { - runOnUiThread(() -> { - ReaderTtsService service = ReaderTtsService.get(); - if (service != null) action.accept(service); - }); - } - - @JavascriptInterface - public void ttsPause() { - postToService(ReaderTtsService::pauseReadAloud); - } - - @JavascriptInterface - public void ttsResume() { - postToService(ReaderTtsService::resumeReadAloud); - } - - @JavascriptInterface - public void ttsToggle() { - postToService(ReaderTtsService::toggleReadAloud); - } - - @JavascriptInterface - public void ttsPrev() { - postToService(ReaderTtsService::prevParagraph); - } - - @JavascriptInterface - public void ttsNext() { - postToService(ReaderTtsService::nextParagraph); - } - - @JavascriptInterface - public void ttsSeek(int paragraph) { - postToService(service -> service.seekParagraph(paragraph)); - } - - @JavascriptInterface - public void ttsSetRate(float rate) { - postToService(service -> service.setRate(rate)); - } - - @JavascriptInterface - public void ttsSetPitch(float pitch) { - postToService(service -> service.setPitch(pitch)); - } - - @JavascriptInterface - public void ttsSetVoice(String voice) { - postToService(service -> service.setVoice(voice)); - } - - @JavascriptInterface - public void ttsSetTimer(int minutes) { - postToService(service -> service.setTimer(minutes)); - } - - @JavascriptInterface - public void ttsStop() { - runOnUiThread(() -> { - ReaderTtsService service = ReaderTtsService.get(); - if (service != null) service.shutdown(); - else ReaderTtsService.stopAndQuit(); - }); - } - @Override public void onBackPressed() { // 直接关闭阅读页返回播放器(兼容旧 API;新 API 走 OnBackPressedDispatcher) finish(); } - @Override - protected void onResume() { - super.onResume(); - // 重新登记:onPause 里交还前台时会把注册清掉(见 markClosed 的注释), - // 只在 onCreate 注册的话,两个阅读器叠栈时下层那个再次回到前台就永久失去注册, - // 之后它自己的切章会另起一个阅读器实例压在自己上面。 - NovelRouter.currentReader = this; - } - @Override protected void onPause() { // 切后台 / 返回都先落库,避免进程被回收后丢进度 persistProgress(); - // 先把关闭瞬间仍在途的请求转成全局待拦额度,再清理本页计数;否则 - // markReaderClosed() 看不到这些请求,迟到结果可能在静默期后重新拉起阅读器。 - if (isFinishing()) markClosed(); - // 本页仍在途的宿主请求随本页一起作废:它们的结果不会再回到这里, - // 不收尾的话全局在途计数永久偏高,下一次关闭会凭虚高的数字多留待拦额度, - // 把用户之后主动打开的书误吞掉。 - int inFlight = hostChapterRequests.getAndSet(0); - for (int i = 0; i < inFlight; i++) NovelRouter.endChapterRequest(); - // 关闭时间戳必须在这里打,不能等 onDestroy:Android 的生命周期顺序是 - // 本页 onPause -> 宿主 onResume -> 本页 onStop -> 本页 onDestroy。 - // 宿主 onResume 会因 shouldReclaim() 重新派发上一次的 playerContent 结果, - // 那一刻若时间戳还没写,NovelRouter 的两道防线同时失效(currentReader - // 已 isFinishing() 判不出「在前台」,时间戳又还是 0),于是立刻又拉起阅读器, - // 表现为返回键完全无效、只能强杀 APP。 super.onPause(); } - /** 交还前台:清掉阅读器注册并记下关闭时间/代号,拦截返回后残留的解析回调。 */ - private void markClosed() { - if (NovelRouter.currentReader != this) return; - NovelRouter.currentReader = null; - NovelRouter.markReaderClosed(); - } - @Override protected void onDestroy() { persistProgress(); // 清理阅读器静态引用 + 标记关闭时间,避免残留的 playerContent 回调在返回后重新拉起阅读器。 // 只在自己仍是「当前阅读器」时清:两个阅读器叠栈时,旧实例销毁不能把前台新实例的注册抹掉。 - // onPause 已处理返回场景,这里兜住系统回收等不经过 finish() 的销毁。 - markClosed(); - picTokens.clear(); - // 朗读与阅读页同生命周期:离开阅读页就停播(通知栏控制只服务于仍在阅读的会话) - if (isFinishing()) { - ReaderTtsService service = ReaderTtsService.get(); - if (service != null) service.clearWebCallback(ttsWebCallback); - ReaderTtsService.stopAndQuit(); + if (NovelRouter.currentReader == this) { + NovelRouter.currentReader = null; + NovelRouter.readerClosedAt = System.currentTimeMillis(); } + picTokens.clear(); // 只在真正结束时清缓存:配置变更 / 系统回收导致的重建会再次用同一个 cacheKey // 读取正文与章节列表(Intent 里只带 key,不带数据),提前清掉会渲染成空章。 if (isFinishing() && !cacheKey.isEmpty()) { diff --git a/app/src/main/java/com/fongmi/android/tv/update/HttpUpdateTransfer.java b/app/src/main/java/com/fongmi/android/tv/update/HttpUpdateTransfer.java deleted file mode 100644 index 3e0c32ce59..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/HttpUpdateTransfer.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.fongmi.android.tv.update; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.utils.Task; -import com.github.catvod.utils.Path; - -import java.io.BufferedInputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.concurrent.Future; - -import okhttp3.Call; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; - -public final class HttpUpdateTransfer implements UpdateTransfer { - - private final String url; - private final File file; - private final long expectedSize; - private volatile boolean canceled; - private volatile Call call; - private Future future; - - public HttpUpdateTransfer(String url, File file, long expectedSize) { - this.url = UpdateUrl.requireHttpsUrl(url); - this.file = file; - this.expectedSize = Math.max(0, expectedSize); - } - - @Override - public void start(Callback callback) { - canceled = false; - future = Task.submit(() -> download(callback)); - } - - @Override - public void cancel() { - canceled = true; - if (call != null) call.cancel(); - if (future != null) future.cancel(true); - Path.clear(file); - } - - private void download(Callback callback) { - Request request = new Request.Builder().url(url).header("Accept-Encoding", "identity").build(); - call = UpdateHttp.client().newCall(request); - try (Response response = call.execute()) { - if (!response.isSuccessful()) throw new IOException("Download failed: HTTP " + response.code()); - ResponseBody body = response.body(); - if (body == null) throw new IOException("Download failed: empty response"); - if (expectedSize > 0 && body.contentLength() > 0 && body.contentLength() != expectedSize) throw new IOException("Download size mismatch"); - stream(body.byteStream(), body.contentLength(), callback); - if (canceled) return; - App.post(() -> { - if (!canceled) callback.success(file); - }); - } catch (Exception e) { - Path.clear(file); - if (canceled || isCanceled(e)) return; - App.post(() -> callback.error(message(e))); - } finally { - call = null; - future = null; - } - } - - private void stream(InputStream source, long length, Callback callback) throws IOException { - try (BufferedInputStream input = new BufferedInputStream(source); FileOutputStream output = new FileOutputStream(Path.create(file))) { - byte[] buffer = new byte[16384]; - long bytes = 0; - long start = System.currentTimeMillis(); - long lastTime = start; - long lastBytes = 0; - int lastProgress = -1; - postProgress(callback, length > 0 ? 0 : -1, 0, length, 0, 0); - int count; - while ((count = input.read(buffer)) != -1) { - if (canceled || Thread.currentThread().isInterrupted()) throw new IOException("Canceled"); - output.write(buffer, 0, count); - bytes += count; - if (expectedSize > 0 && bytes > expectedSize) throw new IOException("Download exceeds expected size"); - long now = System.currentTimeMillis(); - int progress = length > 0 ? (int) (bytes * 100 / length) : -1; - if (progress == lastProgress && now - lastTime < 1000) continue; - long speed = (bytes - lastBytes) * 1000 / Math.max(1, now - lastTime); - postProgress(callback, progress, bytes, length, speed, now - start); - lastProgress = progress; - lastTime = now; - lastBytes = bytes; - } - if (length > 0 && bytes != length) throw new IOException("Download incomplete"); - if (expectedSize > 0 && bytes != expectedSize) throw new IOException("Download size mismatch"); - } - } - - private void postProgress(Callback callback, int progress, long bytes, long total, long speed, long elapsed) { - App.post(() -> { - if (!canceled) callback.progress(progress, bytes, total, speed, elapsed); - }); - } - - private boolean isCanceled(Exception e) { - String message = e.getMessage(); - return "Canceled".equals(message) || "Socket closed".equals(message); - } - - private String message(Exception e) { - return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/OciArtifact.java b/app/src/main/java/com/fongmi/android/tv/update/OciArtifact.java deleted file mode 100644 index 801f6bdfea..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/OciArtifact.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.fongmi.android.tv.update; - -import java.util.Locale; -import java.util.regex.Pattern; - -public final class OciArtifact { - - private static final Pattern REPOSITORY = Pattern.compile("[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*"); - private static final Pattern DIGEST = Pattern.compile("sha256:[0-9a-fA-F]{64}"); - - public final String registry; - public final String repository; - public final String reference; - public final String manifestDigest; - public final String layerDigest; - public final long size; - - public OciArtifact(String registry, String repository, String reference, String manifestDigest, String layerDigest, long size) { - this.registry = normalizeRegistry(registry); - this.repository = repository == null ? "" : repository.trim().toLowerCase(Locale.ROOT); - this.reference = reference == null ? "" : reference.trim(); - this.manifestDigest = normalizeDigest(manifestDigest); - this.layerDigest = normalizeDigest(layerDigest); - this.size = size; - } - - public boolean isValid() { - return !registry.isEmpty() && REPOSITORY.matcher(repository).matches() && !reference.isEmpty() && isDigest(manifestDigest) && isDigest(layerDigest) && size > 0; - } - - public String getRegistryOrigin() { - return "https://" + registry; - } - - public static boolean isDigest(String value) { - return value != null && DIGEST.matcher(value).matches(); - } - - private static String normalizeRegistry(String value) { - String text = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); - if (text.startsWith("https://")) text = text.substring("https://".length()); - while (text.endsWith("/")) text = text.substring(0, text.length() - 1); - if (text.contains("/") || text.contains("@") || text.contains("?") || text.contains("#")) return ""; - return text; - } - - private static String normalizeDigest(String value) { - return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/OciAuthChallenge.java b/app/src/main/java/com/fongmi/android/tv/update/OciAuthChallenge.java deleted file mode 100644 index d4a0b8fc7c..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/OciAuthChallenge.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.fongmi.android.tv.update; - -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public final class OciAuthChallenge { - - private static final Pattern PARAM = Pattern.compile("([A-Za-z][A-Za-z0-9_-]*)=\"([^\"]*)\""); - - public final String realm; - public final String service; - public final String scope; - - private OciAuthChallenge(String realm, String service, String scope) { - this.realm = realm; - this.service = service; - this.scope = scope; - } - - public static OciAuthChallenge parse(String header) { - String value = header == null ? "" : header.trim(); - int space = value.indexOf(' '); - if (space <= 0 || !"bearer".equals(value.substring(0, space).toLowerCase(Locale.ROOT))) throw new IllegalArgumentException("Unsupported Registry authentication"); - String realm = ""; - String service = ""; - String scope = ""; - Matcher matcher = PARAM.matcher(value.substring(space + 1)); - while (matcher.find()) { - String key = matcher.group(1).toLowerCase(Locale.ROOT); - if ("realm".equals(key)) realm = matcher.group(2); - else if ("service".equals(key)) service = matcher.group(2); - else if ("scope".equals(key)) scope = matcher.group(2); - } - if (realm.isEmpty()) throw new IllegalArgumentException("Registry authentication realm is missing"); - return new OciAuthChallenge(realm, service, scope); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/OciMirror.java b/app/src/main/java/com/fongmi/android/tv/update/OciMirror.java deleted file mode 100644 index a514810659..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/OciMirror.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.fongmi.android.tv.update; - -import java.util.Arrays; - -public final class OciMirror { - - public static final String DIRECT = "direct"; - public static final String DEFAULT = "dockerproxy_net"; - public static final String CUSTOM = "custom"; - - private static final Preset[] PRESETS = { - new Preset(DEFAULT, "dockerproxy.net", "https://dockerproxy.net"), - new Preset("hubfast", "free.hubfast.cn", "https://free.hubfast.cn"), - new Preset("jiaxin", "docker.jiaxin.site", "https://docker.jiaxin.site"), - new Preset(DIRECT, "Registry direct", ""), - new Preset(CUSTOM, "Custom", ""), - }; - - private OciMirror() { - } - - public static Preset[] presets() { - return Arrays.copyOf(PRESETS, PRESETS.length); - } - - public static Preset find(String id) { - for (Preset preset : PRESETS) if (preset.id.equals(id)) return preset; - return PRESETS[0]; - } - - public static String resolve(String id, String customUrl, OciArtifact artifact) { - Preset preset = find(id); - if (DIRECT.equals(preset.id)) return artifact.getRegistryOrigin(); - if (CUSTOM.equals(preset.id)) return UpdateUrl.requireHttpsOrigin(customUrl); - return preset.baseUrl; - } - - public static final class Preset { - - public final String id; - public final String label; - public final String baseUrl; - - private Preset(String id, String label, String baseUrl) { - this.id = id; - this.label = label; - this.baseUrl = baseUrl; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/OciRegistryClient.java b/app/src/main/java/com/fongmi/android/tv/update/OciRegistryClient.java deleted file mode 100644 index e54ed602fb..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/OciRegistryClient.java +++ /dev/null @@ -1,256 +0,0 @@ -package com.fongmi.android.tv.update; - -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import java.io.BufferedInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InterruptedIOException; -import java.security.MessageDigest; -import java.util.Locale; - -import okhttp3.Call; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; - -public final class OciRegistryClient { - - static final String MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json"; - static final String ARTIFACT_TYPE = "application/vnd.webhtv.apk.v1"; - static final String CONFIG_MEDIA_TYPE = "application/vnd.oci.empty.v1+json"; - static final String APK_MEDIA_TYPE = "application/vnd.android.package-archive"; - private static final int MAX_MANIFEST_BYTES = 1024 * 1024; - private static final int MAX_REDIRECTS = 5; - - private final OkHttpClient client; - - public OciRegistryClient(OkHttpClient client) { - this.client = client; - } - - public void pull(String endpoint, OciArtifact artifact, File file, Observer observer) throws Exception { - if (artifact == null || !artifact.isValid()) throw new IOException("Invalid OCI artifact descriptor"); - HttpUrl manifestUrl = artifactUrl(endpoint, artifact.repository, "manifests", artifact.manifestDigest); - Request manifestRequest = new Request.Builder() - .url(manifestUrl) - .header("Accept", MANIFEST_MEDIA_TYPE) - .header("Accept-Encoding", "identity") - .build(); - byte[] manifest; - try (Response response = executeAuthorized(manifestRequest, artifact, observer)) { - requireSuccess(response, "OCI manifest"); - String contentType = response.header("Content-Type", ""); - if (!contentType.isEmpty() && !contentType.startsWith(MANIFEST_MEDIA_TYPE)) throw new IOException("Unexpected OCI manifest content type"); - manifest = readLimited(response.body(), MAX_MANIFEST_BYTES); - } - verifyDigest(manifest, artifact.manifestDigest, "OCI manifest"); - validateManifest(manifest, artifact); - downloadBlob(endpoint, artifact, file, observer); - } - - private void downloadBlob(String endpoint, OciArtifact artifact, File file, Observer observer) throws Exception { - HttpUrl blobUrl = artifactUrl(endpoint, artifact.repository, "blobs", artifact.layerDigest); - Request request = new Request.Builder().url(blobUrl).header("Accept-Encoding", "identity").build(); - try (Response response = executeAuthorized(request, artifact, observer)) { - requireSuccess(response, "OCI blob"); - ResponseBody body = response.body(); - if (body == null) throw new IOException("OCI blob is empty"); - long responseLength = body.contentLength(); - if (responseLength > 0 && responseLength != artifact.size) throw new IOException("OCI blob size mismatch"); - streamBlob(body.byteStream(), artifact, file, observer); - } - } - - private void streamBlob(InputStream source, OciArtifact artifact, File file, Observer observer) throws Exception { - File parent = file.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs()) throw new IOException("Unable to create update directory"); - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - try (BufferedInputStream input = new BufferedInputStream(source); FileOutputStream output = new FileOutputStream(file)) { - byte[] buffer = new byte[16384]; - long bytes = 0; - long start = System.currentTimeMillis(); - long lastTime = start; - long lastBytes = 0; - int lastProgress = -1; - observer.onProgress(0, 0, artifact.size, 0, 0); - int count; - while ((count = input.read(buffer)) != -1) { - checkCanceled(observer); - output.write(buffer, 0, count); - digest.update(buffer, 0, count); - bytes += count; - if (bytes > artifact.size) throw new IOException("OCI blob exceeds expected size"); - long now = System.currentTimeMillis(); - int progress = (int) (bytes * 100 / artifact.size); - if (progress == lastProgress && now - lastTime < 1000) continue; - long speed = (bytes - lastBytes) * 1000 / Math.max(1, now - lastTime); - observer.onProgress(progress, bytes, artifact.size, speed, now - start); - lastProgress = progress; - lastTime = now; - lastBytes = bytes; - } - if (bytes != artifact.size) throw new IOException("OCI blob download is incomplete"); - String actual = "sha256:" + hex(digest.digest()); - if (!actual.equals(artifact.layerDigest)) throw new IOException("OCI blob digest mismatch"); - } - } - - private Response executeAuthorized(Request request, OciArtifact artifact, Observer observer) throws Exception { - Response response = execute(request, observer); - if (response.code() != 401) return response; - String header = response.header("WWW-Authenticate"); - Request challengedRequest = response.request(); - response.close(); - OciAuthChallenge challenge = OciAuthChallenge.parse(header); - String token = token(challenge, artifact, challengedRequest.url().host(), observer); - return execute(challengedRequest.newBuilder().header("Authorization", "Bearer " + token).build(), observer); - } - - private String token(OciAuthChallenge challenge, OciArtifact artifact, String requestHost, Observer observer) throws Exception { - String realm = UpdateUrl.requireHttpsUrl(challenge.realm); - String realmHost = UpdateUrl.host(realm); - if (!allowedAuthHost(realmHost, requestHost, artifact.registry)) throw new IOException("Untrusted Registry authentication host"); - HttpUrl parsed = HttpUrl.parse(realm); - if (parsed == null) throw new IOException("Invalid Registry authentication URL"); - HttpUrl.Builder builder = parsed.newBuilder(); - String expectedScope = "repository:" + artifact.repository + ":pull"; - if (!challenge.scope.isEmpty() && !expectedScope.equals(challenge.scope)) throw new IOException("Unexpected Registry authentication scope"); - if (!challenge.service.isEmpty()) builder.addQueryParameter("service", challenge.service); - builder.addQueryParameter("scope", expectedScope); - Request request = new Request.Builder().url(builder.build()).header("Accept", "application/json").header("Accept-Encoding", "identity").build(); - try (Response response = execute(request, observer)) { - requireSuccess(response, "Registry token"); - byte[] bytes = readLimited(response.body(), MAX_MANIFEST_BYTES); - JsonObject object = JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); - String token = string(object, "token"); - if (token.isEmpty()) token = string(object, "access_token"); - if (token.isEmpty()) throw new IOException("Registry token is missing"); - return token; - } - } - - private Response execute(Request initial, Observer observer) throws Exception { - Request request = initial; - for (int redirect = 0; redirect <= MAX_REDIRECTS; redirect++) { - checkCanceled(observer); - Call call = client.newCall(request); - observer.onCall(call); - Response response = call.execute(); - if (!isRedirect(response.code())) return response; - String location = response.header("Location"); - HttpUrl next = location == null ? null : response.request().url().resolve(location); - response.close(); - if (next == null || !next.isHttps()) throw new IOException("Unsafe OCI redirect"); - boolean sameOrigin = sameOrigin(request.url(), next); - Request.Builder builder = request.newBuilder().url(next); - if (!sameOrigin) builder.removeHeader("Authorization"); - request = builder.build(); - } - throw new IOException("Too many OCI redirects"); - } - - void validateManifest(byte[] bytes, OciArtifact artifact) throws IOException { - try { - JsonObject object = JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); - if (number(object, "schemaVersion") != 2) throw new IOException("Unsupported OCI schema version"); - if (!MANIFEST_MEDIA_TYPE.equals(string(object, "mediaType"))) throw new IOException("Unexpected OCI manifest media type"); - if (!ARTIFACT_TYPE.equals(string(object, "artifactType"))) throw new IOException("Unexpected OCI artifact type"); - JsonObject config = object.getAsJsonObject("config"); - if (config == null || !CONFIG_MEDIA_TYPE.equals(string(config, "mediaType"))) throw new IOException("Unexpected OCI config type"); - JsonArray layers = object.getAsJsonArray("layers"); - if (layers == null || layers.size() != 1) throw new IOException("OCI artifact must contain exactly one APK layer"); - JsonObject layer = layers.get(0).getAsJsonObject(); - if (!APK_MEDIA_TYPE.equals(string(layer, "mediaType"))) throw new IOException("Unexpected OCI layer media type"); - if (!artifact.layerDigest.equals(string(layer, "digest").toLowerCase(Locale.ROOT))) throw new IOException("OCI layer digest mismatch"); - if (longNumber(layer, "size") != artifact.size) throw new IOException("OCI layer size mismatch"); - } catch (IOException e) { - throw e; - } catch (Exception e) { - throw new IOException("Invalid OCI manifest", e); - } - } - - private HttpUrl artifactUrl(String endpoint, String repository, String operation, String value) throws IOException { - HttpUrl base = HttpUrl.parse(UpdateUrl.requireHttpsOrigin(endpoint)); - if (base == null) throw new IOException("Invalid OCI endpoint"); - return base.newBuilder().addPathSegment("v2").addPathSegments(repository).addPathSegment(operation).addPathSegment(value).build(); - } - - private byte[] readLimited(ResponseBody body, int limit) throws IOException { - if (body == null) throw new IOException("Response body is empty"); - long length = body.contentLength(); - if (length > limit) throw new IOException("Response body is too large"); - try (InputStream input = body.byteStream(); ByteArrayOutputStream output = new ByteArrayOutputStream(length > 0 ? (int) length : 8192)) { - byte[] buffer = new byte[8192]; - int total = 0; - int count; - while ((count = input.read(buffer)) != -1) { - total += count; - if (total > limit) throw new IOException("Response body is too large"); - output.write(buffer, 0, count); - } - return output.toByteArray(); - } - } - - private void verifyDigest(byte[] bytes, String expected, String label) throws Exception { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - String actual = "sha256:" + hex(digest.digest(bytes)); - if (!actual.equals(expected)) throw new IOException(label + " digest mismatch"); - } - - private void requireSuccess(Response response, String label) throws IOException { - if (!response.isSuccessful()) throw new IOException(label + " failed: HTTP " + response.code()); - } - - private void checkCanceled(Observer observer) throws InterruptedIOException { - if (observer.isCanceled() || Thread.currentThread().isInterrupted()) throw new InterruptedIOException("Canceled"); - } - - private boolean allowedAuthHost(String realm, String request, String registry) { - return realm.equalsIgnoreCase(request) || realm.equalsIgnoreCase(registry) || "auth.docker.io".equals(realm) || "token.docker.io".equals(realm); - } - - private boolean sameOrigin(HttpUrl first, HttpUrl second) { - return first.scheme().equals(second.scheme()) && first.host().equals(second.host()) && first.port() == second.port(); - } - - private boolean isRedirect(int code) { - return code == 301 || code == 302 || code == 303 || code == 307 || code == 308; - } - - private String string(JsonObject object, String name) { - return object != null && object.has(name) && !object.get(name).isJsonNull() ? object.get(name).getAsString() : ""; - } - - private int number(JsonObject object, String name) { - return object != null && object.has(name) ? object.get(name).getAsInt() : 0; - } - - private long longNumber(JsonObject object, String name) { - return object != null && object.has(name) ? object.get(name).getAsLong() : 0; - } - - private String hex(byte[] bytes) { - StringBuilder builder = new StringBuilder(bytes.length * 2); - for (byte value : bytes) builder.append(String.format(Locale.ROOT, "%02x", value)); - return builder.toString(); - } - - public interface Observer { - - boolean isCanceled(); - - void onCall(Call call); - - void onProgress(int progress, long bytes, long total, long speed, long elapsed); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/OciUpdateTransfer.java b/app/src/main/java/com/fongmi/android/tv/update/OciUpdateTransfer.java deleted file mode 100644 index 1f2a0cac2b..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/OciUpdateTransfer.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.fongmi.android.tv.update; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.utils.Task; -import com.github.catvod.utils.Path; - -import java.io.File; -import java.util.concurrent.Future; - -import okhttp3.Call; - -public final class OciUpdateTransfer implements UpdateTransfer, OciRegistryClient.Observer { - - private final UpdateTarget target; - private final File file; - private volatile boolean canceled; - private volatile Call call; - private Future future; - private Callback callback; - - public OciUpdateTransfer(UpdateTarget target, File file) { - if (target == null || target.kind != UpdateTarget.Kind.OCI) throw new IllegalArgumentException("OCI target required"); - this.target = target; - this.file = file; - } - - @Override - public void start(Callback callback) { - this.callback = callback; - this.canceled = false; - this.future = Task.submit(this::pull); - } - - @Override - public void cancel() { - canceled = true; - if (call != null) call.cancel(); - if (future != null) future.cancel(true); - Path.clear(file); - } - - private void pull() { - try { - new OciRegistryClient(UpdateHttp.ociClient()).pull(target.endpoint, target.artifact, file, this); - if (canceled) return; - App.post(() -> { - if (!canceled) callback.success(file); - }); - } catch (Exception e) { - Path.clear(file); - if (canceled || "Canceled".equals(e.getMessage())) return; - String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); - App.post(() -> callback.error(message)); - } finally { - call = null; - future = null; - } - } - - @Override - public boolean isCanceled() { - return canceled; - } - - @Override - public void onCall(Call call) { - this.call = call; - if (canceled) call.cancel(); - } - - @Override - public void onProgress(int progress, long bytes, long total, long speed, long elapsed) { - App.post(() -> { - if (!canceled) callback.progress(progress, bytes, total, speed, elapsed); - }); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/UpdateHttp.java b/app/src/main/java/com/fongmi/android/tv/update/UpdateHttp.java deleted file mode 100644 index bf561c6cad..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/UpdateHttp.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.fongmi.android.tv.update; - -import java.io.IOException; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; - -public final class UpdateHttp { - - private static final int MAX_TEXT_BYTES = 2 * 1024 * 1024; - private static final OkHttpClient CLIENT = new OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(45, TimeUnit.SECONDS) - .writeTimeout(45, TimeUnit.SECONDS) - .followRedirects(true) - .followSslRedirects(false) - .build(); - private static final OkHttpClient OCI_CLIENT = CLIENT.newBuilder() - .followRedirects(false) - .followSslRedirects(false) - .build(); - - private UpdateHttp() { - } - - public static OkHttpClient client() { - return CLIENT; - } - - public static OkHttpClient ociClient() { - return OCI_CLIENT; - } - - public static String string(String url, Map headers, long timeoutMs) throws IOException { - Request.Builder builder = new Request.Builder().url(UpdateUrl.requireHttpsUrl(url)).header("Accept-Encoding", "identity"); - if (headers != null) headers.forEach(builder::header); - OkHttpClient client = CLIENT.newBuilder().callTimeout(timeoutMs, TimeUnit.MILLISECONDS).build(); - try (Response response = client.newCall(builder.build()).execute()) { - if (!response.isSuccessful()) throw new IOException("HTTP " + response.code()); - ResponseBody body = response.body(); - if (body == null) throw new IOException("Empty response"); - long length = body.contentLength(); - if (length > MAX_TEXT_BYTES) throw new IOException("Response is too large"); - byte[] bytes = body.bytes(); - if (bytes.length > MAX_TEXT_BYTES) throw new IOException("Response is too large"); - return new String(bytes, java.nio.charset.StandardCharsets.UTF_8); - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/UpdateRoutePlanner.java b/app/src/main/java/com/fongmi/android/tv/update/UpdateRoutePlanner.java deleted file mode 100644 index 8e91ace826..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/UpdateRoutePlanner.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.fongmi.android.tv.update; - -import com.fongmi.android.tv.utils.GithubProxy; - -import java.util.ArrayList; -import java.util.List; - -public final class UpdateRoutePlanner { - - private UpdateRoutePlanner() { - } - - public static List plan(String source, String githubUrl, OciArtifact artifact, GithubProxy.Config githubProxy, String ociEndpoint) { - List routes = new ArrayList<>(); - String normalized = UpdateSource.normalize(source); - if (UpdateSource.GITHUB.equals(normalized)) { - addGithub(routes, githubUrl, githubProxy); - addOci(routes, artifact, ociEndpoint); - } else { - addOci(routes, artifact, ociEndpoint); - addGithub(routes, githubUrl, githubProxy); - } - return routes; - } - - private static void addGithub(List routes, String githubUrl, GithubProxy.Config proxy) { - if (githubUrl == null || githubUrl.trim().isEmpty()) return; - try { - routes.add(UpdateTarget.github(proxy.rewrite(githubUrl))); - } catch (Exception ignored) { - } - } - - private static void addOci(List routes, OciArtifact artifact, String endpoint) { - if (artifact == null || !artifact.isValid() || endpoint == null || endpoint.trim().isEmpty()) return; - try { - routes.add(UpdateTarget.oci(endpoint, artifact)); - } catch (Exception ignored) { - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/UpdateSource.java b/app/src/main/java/com/fongmi/android/tv/update/UpdateSource.java deleted file mode 100644 index dd13920235..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/UpdateSource.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.fongmi.android.tv.update; - -public final class UpdateSource { - - public static final String GITHUB = "github"; - public static final String OCI = "oci"; - - private UpdateSource() { - } - - public static String normalize(String value) { - if (GITHUB.equals(value)) return GITHUB; - return OCI; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/UpdateTarget.java b/app/src/main/java/com/fongmi/android/tv/update/UpdateTarget.java deleted file mode 100644 index 4a87e02e9f..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/UpdateTarget.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.fongmi.android.tv.update; - -public final class UpdateTarget { - - public enum Kind { - GITHUB, - OCI - } - - public final Kind kind; - public final String url; - public final String endpoint; - public final OciArtifact artifact; - - private UpdateTarget(Kind kind, String url, String endpoint, OciArtifact artifact) { - this.kind = kind; - this.url = url; - this.endpoint = endpoint; - this.artifact = artifact; - } - - public static UpdateTarget github(String url) { - return new UpdateTarget(Kind.GITHUB, UpdateUrl.requireHttpsUrl(url), "", null); - } - - public static UpdateTarget oci(String endpoint, OciArtifact artifact) { - if (artifact == null || !artifact.isValid()) throw new IllegalArgumentException("Invalid OCI artifact"); - return new UpdateTarget(Kind.OCI, "", UpdateUrl.requireHttpsOrigin(endpoint), artifact); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/UpdateTransfer.java b/app/src/main/java/com/fongmi/android/tv/update/UpdateTransfer.java deleted file mode 100644 index bb47b0becb..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/UpdateTransfer.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.fongmi.android.tv.update; - -import java.io.File; - -public interface UpdateTransfer { - - void start(Callback callback); - - void cancel(); - - interface Callback { - - void progress(int progress, long bytes, long total, long speed, long elapsed); - - void error(String message); - - void success(File file); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/update/UpdateUrl.java b/app/src/main/java/com/fongmi/android/tv/update/UpdateUrl.java deleted file mode 100644 index ccb0f34bf4..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/update/UpdateUrl.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.fongmi.android.tv.update; - -import java.net.URI; -import java.util.Locale; - -public final class UpdateUrl { - - private UpdateUrl() { - } - - public static String requireHttpsOrigin(String value) { - try { - String text = value == null ? "" : value.trim(); - while (text.endsWith("/")) text = text.substring(0, text.length() - 1); - URI uri = URI.create(text); - if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException("HTTPS required"); - if (uri.getHost() == null || uri.getHost().isEmpty()) throw new IllegalArgumentException("Host required"); - if (uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null) throw new IllegalArgumentException("Credentials, query and fragment are not allowed"); - String path = uri.getPath(); - if (path != null && !path.isEmpty() && !"/".equals(path)) throw new IllegalArgumentException("Origin must not contain a path"); - return text; - } catch (IllegalArgumentException e) { - throw e; - } catch (Exception e) { - throw new IllegalArgumentException("Invalid HTTPS origin", e); - } - } - - public static String requireHttpsUrl(String value) { - try { - String text = value == null ? "" : value.trim(); - URI uri = URI.create(text); - if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException("HTTPS required"); - if (uri.getHost() == null || uri.getHost().isEmpty()) throw new IllegalArgumentException("Host required"); - if (uri.getUserInfo() != null) throw new IllegalArgumentException("Credentials are not allowed"); - return text; - } catch (IllegalArgumentException e) { - throw e; - } catch (Exception e) { - throw new IllegalArgumentException("Invalid HTTPS URL", e); - } - } - - public static String host(String value) { - try { - String host = URI.create(value).getHost(); - return host == null ? "" : host.toLowerCase(Locale.ROOT); - } catch (Exception e) { - return ""; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/AdBlockTimeFormatter.java b/app/src/main/java/com/fongmi/android/tv/utils/AdBlockTimeFormatter.java deleted file mode 100644 index 23e05f0e28..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/AdBlockTimeFormatter.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.fongmi.android.tv.utils; - -import java.util.Locale; - -/** Formats HLS segment positions using the same clock style as the player timeline. */ -public final class AdBlockTimeFormatter { - - private static final long TENTHS_PER_SECOND = 10L; - private static final long TENTHS_PER_MINUTE = 60L * TENTHS_PER_SECOND; - private static final long TENTHS_PER_HOUR = 60L * TENTHS_PER_MINUTE; - - private AdBlockTimeFormatter() { - } - - /** - * Formats a media position or duration as {@code HH:MM:SS.t}. - * - *

The stored HLS timings contain milliseconds, so retaining one decimal place avoids - * hiding the difference between adjacent short segments while still looking like a player - * timeline. Rounding is done before splitting the fields so values such as 59.96 seconds - * correctly carry into the next minute.

- */ - public static String formatSeconds(double seconds) { - double safeSeconds = Double.isFinite(seconds) ? Math.max(0d, seconds) : 0d; - long tenths = Math.round(safeSeconds * TENTHS_PER_SECOND); - long hours = tenths / TENTHS_PER_HOUR; - long minutes = (tenths % TENTHS_PER_HOUR) / TENTHS_PER_MINUTE; - long wholeSeconds = (tenths % TENTHS_PER_MINUTE) / TENTHS_PER_SECOND; - long fractionalSecond = tenths % TENTHS_PER_SECOND; - return String.format(Locale.US, "%02d:%02d:%02d.%d", - hours, minutes, wholeSeconds, fractionalSecond); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/AppBackup.java b/app/src/main/java/com/fongmi/android/tv/utils/AppBackup.java index 29a59fcdff..8c5099d6bb 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/AppBackup.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/AppBackup.java @@ -268,7 +268,7 @@ public final class AppBackup { private static void reload() { VodConfig.get().clear("app-backup").init().load(new Callback()); LiveConfig.get().clear().init().load(); - WallConfig.get().initPreservingSelection().load(); + WallConfig.get().init().load(); ConfigEvent.common(); RefreshEvent.keep(); RefreshEvent.history(); diff --git a/app/src/main/java/com/fongmi/android/tv/utils/AppVersion.java b/app/src/main/java/com/fongmi/android/tv/utils/AppVersion.java index 3e7ba33780..bebc15e2f8 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/AppVersion.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/AppVersion.java @@ -2,21 +2,8 @@ package com.fongmi.android.tv.utils; import com.fongmi.android.tv.BuildConfig; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - public final class AppVersion { - /** - * Release tags carry a build stamp, for example {@code v5.6.0-202609221328} (stable) or - * {@code v5.6.0-beta-202610091755} (beta). The stamp is the release moment, so it is the only - * reliable way to order two builds that publish the same {@code versionCode}. - */ - private static final Pattern BUILD_STAMP = Pattern.compile("(?An older or identical tag is never an update. That matters because the stable and beta - * channels of the same release line can publish the same {@code versionCode}: treating - * "different tag" as "newer" made an older package look installable over a newer one, so the - * app kept offering a downgrade and the two channels overwrote each other in a loop. - * - *

Tags without a usable stamp keep the historical "different means update" behavior. Two - * usable stamps are always ordered against each other, also when one carries seconds and the - * other does not; only a missing stamp falls back to "different means update". - */ - public static boolean isNewer(String current, String target) { - String currentTag = stripPrefix(current); - String targetTag = stripPrefix(target); - if (targetTag.isEmpty() || targetTag.equals(currentTag)) return false; - String currentStamp = buildStamp(currentTag); - String targetStamp = buildStamp(targetTag); - if (currentStamp.isEmpty() || targetStamp.isEmpty()) return true; - return padStamp(targetStamp).compareTo(padStamp(currentStamp)) > 0; - } - - /** - * Widens a {@code yyyyMMddHHmm} stamp to {@code yyyyMMddHHmmss} so two stamps of different - * lengths stay comparable; a minute stamp means second zero of that minute. - */ - private static String padStamp(String stamp) { - StringBuilder padded = new StringBuilder(stamp); - while (padded.length() < STAMP_LENGTH) padded.append('0'); - return padded.toString(); - } - - /** Returns the trailing build stamp ({@code yyyyMMddHHmm[ss]}) of a release tag, or {@code ""}. */ - public static String buildStamp(String value) { - if (value == null) return ""; - Matcher matcher = BUILD_STAMP.matcher(value); - String stamp = ""; - while (matcher.find()) stamp = matcher.group(1); - return stamp; - } - public static String stripPrefix(String value) { if (value == null) return ""; value = value.trim(); diff --git a/app/src/main/java/com/fongmi/android/tv/utils/CrashLogSession.java b/app/src/main/java/com/fongmi/android/tv/utils/CrashLogSession.java deleted file mode 100644 index f14a36751a..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/CrashLogSession.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.fongmi.android.tv.utils; - -import android.content.Context; - -import com.github.catvod.crawler.DebugLogStore; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; - -/** - * Builds the crash-session log served by the existing debug log page. - * - *

Runs on a worker thread: it reads the report dropped by the crashed process, appends the - * same-UID system logcat tail (the only pre-crash evidence when the debug log was off), starts a - * temporary in-memory diagnostic session and seeds it. Everything is best-effort; the crash - * screen must survive any failure here. - */ -public final class CrashLogSession { - - private static final int MAX_LOG_LINES = 800; - private static final int MAX_LOG_CHARS = 192 << 10; - private static final long LOGCAT_TIMEOUT_MS = 1500; - private static boolean prepared; - - private CrashLogSession() { - } - - public static synchronized void prepare(Context context) { - if (prepared) return; - prepared = true; - try { - boolean temporary = DebugLogStore.beginCrashSession(); - String report = CrashReport.readRecent(context); - List logcat = logcatTail(CrashLogText.reportPid(report)); - String body = CrashLogText.composeSession(report, logcat, logcat != null && !logcat.isEmpty(), report != null, !temporary); - for (String line : body.split("\n", -1)) { - if (!line.isEmpty()) DebugLogStore.appendCrashContext(line); - } - } catch (Throwable error) { - try { - DebugLogStore.appendCrashContext("# crash-session unavailable: " + error.getClass().getName()); - } catch (Throwable ignored) { - // The debug service still starts; the page then reports the original state. - } - } - } - - private static List logcatTail(int pid) { - Process process = null; - Thread watchdog = null; - StringBuilder output = new StringBuilder(); - try { - process = new ProcessBuilder("logcat", "-d", "-v", "threadtime", "-t", "4000") - .redirectErrorStream(true).start(); - watchdog = timeout(process); - watchdog.start(); - try (InputStream input = process.getInputStream()) { - byte[] buffer = new byte[8192]; - int read; - while (output.length() < MAX_LOG_CHARS && (read = input.read(buffer)) > 0) { - output.append(new String(buffer, 0, read, StandardCharsets.UTF_8)); - } - } - } catch (IOException | RuntimeException error) { - return null; - } finally { - if (watchdog != null) watchdog.interrupt(); - if (process != null) { - try { - process.destroy(); - } catch (RuntimeException ignored) { - } - } - } - if (output.length() == 0) return List.of(); - List raw = new ArrayList<>(); - for (String line : output.toString().split("\n")) if (!line.isEmpty()) raw.add(line); - if (raw.isEmpty()) return List.of(); - return CrashLogText.filterLogcat(raw, pid, MAX_LOG_LINES, MAX_LOG_CHARS); - } - - private static Thread timeout(Process process) { - Thread watchdog = new Thread(() -> { - try { - Thread.sleep(LOGCAT_TIMEOUT_MS); - } catch (InterruptedException ignored) { - return; - } - try { - process.destroy(); - } catch (RuntimeException ignored) { - } - }, "crash-logcat-timeout"); - watchdog.setDaemon(true); - return watchdog; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/CrashLogText.java b/app/src/main/java/com/fongmi/android/tv/utils/CrashLogText.java deleted file mode 100644 index 8774019917..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/CrashLogText.java +++ /dev/null @@ -1,234 +0,0 @@ -package com.fongmi.android.tv.utils; - -import com.github.catvod.crawler.diagnostics.DiagnosticText; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.regex.Pattern; - -/** - * Pure text assembly for the crash-time log session. No Android types: every Android fact is - * passed in, so the bounds, redaction and freshness rules stay unit-testable. - */ -public final class CrashLogText { - - public static final String REPORT_HEADER = "# webhtv-crash-report v1"; - public static final String SESSION_HEADER = "# crash-session v1"; - public static final int MAX_REPORT_CHARS = 96 * 1024; - public static final int MAX_STACK_CHARS = 48 * 1024; - public static final long FRESH_WINDOW_MS = 5 * 60_000L; - public static final int MAX_SESSION_LINES = 1200; - public static final int MAX_SESSION_CHARS = 256 * 1024; - private static final int MAX_FIELD_CHARS = 4 * 1024; - private static final Pattern LINES = Pattern.compile("\\r\\n|\\r|\\n"); - - private CrashLogText() { - } - - public record Facts( - String appVersion, - String wallTime, - long elapsedRealtimeMs, - String processName, - int pid, - int uid, - String thread, - boolean debugLogEnabled, - String device, - String system, - String javaClass, - String message, - String stack) { - } - - /** Crash report written by the dying process. Every field is redacted and bounded. */ - public static String format(Facts facts) { - StringBuilder out = new StringBuilder(MAX_REPORT_CHARS >> 2); - line(out, REPORT_HEADER); - line(out, "# Written by the crashed process; read by the crash screen process."); - line(out, "appVersion=" + oneLine(facts == null ? null : facts.appVersion())); - line(out, "wallTime=" + oneLine(facts == null ? null : facts.wallTime())); - line(out, "elapsedMs=" + (facts == null ? 0L : facts.elapsedRealtimeMs())); - line(out, "process=" + oneLine(facts == null ? null : facts.processName())); - line(out, "pid=" + (facts == null ? 0 : facts.pid())); - line(out, "uid=" + (facts == null ? 0 : facts.uid())); - line(out, "thread=" + oneLine(facts == null ? null : facts.thread())); - line(out, "debugLogEnabled=" + (facts != null && facts.debugLogEnabled())); - line(out, "device=" + oneLine(facts == null ? null : facts.device())); - line(out, "system=" + oneLine(facts == null ? null : facts.system())); - line(out, "javaClass=" + oneLine(facts == null ? null : facts.javaClass())); - line(out, "message=" + oneLine(facts == null ? null : facts.message())); - line(out, "--- stack ---"); - appendStack(out, facts == null ? null : facts.stack()); - return out.toString(); - } - - private static void appendStack(StringBuilder out, String stack) { - int budget = Math.min(MAX_STACK_CHARS, MAX_REPORT_CHARS - out.length()); - int used = 0; - boolean truncated = false; - if (stack != null) { - for (String raw : LINES.split(stack)) { - String text = oneLine(raw); - if (text.isEmpty()) continue; - if (used + text.length() + 1 > budget) { - truncated = true; - break; - } - line(out, text); - used += text.length() + 1; - } - } - if (stack == null || stack.isEmpty()) line(out, "# stack unavailable"); - else if (truncated) line(out, "# [truncated stack]"); - } - - /** elapsedRealtime recorded in a report, or -1 when absent/invalid. */ - public static long reportElapsed(String report) { - return reportValue(report, "elapsedMs=", -1L); - } - - /** pid recorded in a report, or -1 when absent/invalid. */ - public static int reportPid(String report) { - long value = reportValue(report, "pid=", -1L); - return value > 0 && value <= Integer.MAX_VALUE ? (int) value : -1; - } - - private static long reportValue(String report, String prefix, long fallback) { - if (report == null || report.isEmpty()) return fallback; - for (String line : LINES.split(report)) { - if (!line.startsWith(prefix)) continue; - try { - return Long.parseLong(line.substring(prefix.length()).trim()); - } catch (NumberFormatException ignored) { - return fallback; - } - } - return fallback; - } - - public static boolean isFresh(long reportElapsedMs, long nowElapsedMs, long windowMs) { - if (reportElapsedMs <= 0 || nowElapsedMs <= 0 || windowMs <= 0) return false; - long delta = nowElapsedMs - reportElapsedMs; - return delta >= 0 && delta <= windowMs; - } - - /** Keep only lines emitted by the crashed process and return its bounded tail. */ - public static List filterLogcat(List lines, int pid, int maxLines, int maxBytes) { - if (lines == null || lines.isEmpty() || maxLines <= 0 || maxBytes <= 0) return List.of(); - List kept = new ArrayList<>(); - int bytes = 0; - for (int i = lines.size() - 1; i >= 0 && kept.size() < maxLines; i--) { - String line = lines.get(i); - if (line == null || line.isEmpty()) continue; - if (pid > 0 && linePid(line) != pid) continue; - int cost = line.length() + 1; - if (bytes + cost > maxBytes) break; - bytes += cost; - kept.add(line); - } - Collections.reverse(kept); - return kept; - } - - /** System-logcat process id in a {@code -v threadtime} line, or -1. */ - static int linePid(String line) { - int field = 0; - int start = -1; - for (int i = 0; i <= line.length(); i++) { - boolean separator = i == line.length() || line.charAt(i) == ' ' || line.charAt(i) == '\t'; - if (!separator) { - if (start < 0) start = i; - continue; - } - if (start < 0) continue; - if (field == 2) return positiveInt(line, start, i); - field++; - start = -1; - } - return -1; - } - - private static int positiveInt(String text, int start, int end) { - int value = 0; - if (end <= start) return -1; - for (int i = start; i < end; i++) { - char c = text.charAt(i); - if (c < '0' || c > '9') return -1; - value = value * 10 + (c - '0'); - if (value > Integer.MAX_VALUE / 10) return -1; - } - return value > 0 ? value : -1; - } - - /** Body injected into the existing diagnostic log buffer for the crash session. */ - public static String composeSession(String report, List logcat, boolean logcatAvailable, - boolean reportFresh, boolean debugLogEnabled) { - StringBuilder out = new StringBuilder(4096); - line(out, SESSION_HEADER); - line(out, "# 崩溃会话日志:由崩溃界面自动开启,仅在本界面存活期间提供下载。"); - line(out, "# coverage: report=" + (reportFresh && report != null && !report.isEmpty() ? "fresh" : "missing-or-stale") - + " logcat=" + (logcatAvailable ? "own-uid-tail" : "unavailable") - + " debugLog=" + (debugLogEnabled ? "on" : "off")); - if (!debugLogEnabled) { - line(out, "# 调试日志在崩溃前未开启:崩溃前只能提供系统日志(同应用 UID 尾部),可能不完整。"); - } - line(out, "# === crash report ==="); - if (report != null && !report.isEmpty()) { - for (String raw : LINES.split(report)) line(out, oneLine(raw)); - } else { - line(out, "# 未找到本次崩溃的堆栈记录(可能为 native 崩溃、被系统杀死或写入失败)。"); - } - line(out, "# === system logcat (own uid tail) ==="); - if (logcatAvailable && logcat != null && !logcat.isEmpty()) { - for (String raw : logcat) line(out, oneLine(raw)); - } else { - line(out, "# 系统日志不可用。"); - } - return truncateSession(out); - } - - private static String truncateSession(StringBuilder source) { - String text = source.toString(); - if (text.length() <= MAX_SESSION_CHARS) return text; - String[] lines = LINES.split(text); - StringBuilder out = new StringBuilder(MAX_SESSION_CHARS + 128); - int count = 0; - for (String line : lines) { - if (count >= MAX_SESSION_LINES || out.length() + line.length() + 1 > MAX_SESSION_CHARS) { - line(out, "# [truncated crash session]"); - break; - } - line(out, line); - count++; - } - return out.toString(); - } - - /** - * Insert zero-width spaces after URL delimiters so TV text views can wrap a long address. - * Copying always uses the original URL, never this display form. - */ - public static String wrapUrlForDisplay(String url) { - if (url == null || url.isEmpty()) return ""; - StringBuilder out = new StringBuilder(url.length() + 16); - for (int i = 0; i < url.length(); i++) { - char c = url.charAt(i); - out.append(c); - if (c == ':' || c == '/' || c == '?' || c == '&' || c == '=') out.append('\u200B'); - } - return out.toString(); - } - - /** Redacted, single-line form used for every untrusted text field. */ - public static String oneLine(String value) { - if (value == null || value.isEmpty()) return ""; - String clean = DiagnosticText.clean(value).text(); - return clean.length() > MAX_FIELD_CHARS ? clean.substring(0, MAX_FIELD_CHARS) : clean; - } - - private static void line(StringBuilder out, String text) { - out.append(text).append('\n'); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/CrashReport.java b/app/src/main/java/com/fongmi/android/tv/utils/CrashReport.java deleted file mode 100644 index 3edf70c4c1..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/CrashReport.java +++ /dev/null @@ -1,219 +0,0 @@ -package com.fongmi.android.tv.utils; - -import android.content.Context; -import android.os.Build; -import android.os.Process; -import android.os.SystemClock; -import android.util.Log; - -import com.github.catvod.crawler.DebugLogStore; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.text.SimpleDateFormat; -import java.util.Arrays; -import java.util.Comparator; -import java.util.Date; -import java.util.Locale; - -/** - * Writes the crash facts from the dying process and hands them to the crash screen process. - * - *

CustomActivityOnCrash starts the error activity in a separate {@code :error_activity} - * process and kills the crashing process, so nothing in memory survives. The report is the only - * hand-off channel; it is written best-effort and must never take over process termination from - * the configured crash handler. - */ -public final class CrashReport { - - private static final String DIRECTORY = "crash"; - private static final String PREFIX = "webhtv-crash-"; - private static final String SUFFIX = ".txt"; - private static final int MAX_FILES = 3; - private static final int MAX_READ_CHARS = 256 << 10; - private static final int MAX_CMDLINE_BYTES = 256; - private static volatile boolean installed; - - private CrashReport() { - } - - /** - * Chain a crash writer in front of the configured handler (CustomActivityOnCrash). Idempotent. - * - *

Order matters: CAOC's normal crash path shows the error activity and kills the process - * without calling the handler it wrapped, so this writer must be the outermost handler. - * CAOC installs itself from {@code CaocInitProvider} with {@code initOrder="101"}, which runs - * before androidx.startup's {@code InitializationProvider}; this call from {@code Startup} - * therefore lands on top of CAOC's handler. Do not move it earlier, and do not let the provider - * order change: the report would silently stop being written while everything else kept working. - */ - public static void install(Context context) { - if (installed) return; - Context app = application(context); - if (app == null) return; - Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); - if (previous == null) return; - try { - Thread.setDefaultUncaughtExceptionHandler((thread, error) -> { - try { - write(app, thread, error); - } catch (Throwable ignored) { - // A report failure must not change crash handling. - } finally { - previous.uncaughtException(thread, error); - } - }); - installed = true; - } catch (RuntimeException ignored) { - // Very early startup may refuse handler changes; the crash screen then falls back to logcat. - } - } - - /** True when a report written by this process can be produced (mostly for tests/diagnostics). */ - static boolean write(Context context, Thread thread, Throwable error) { - File directory = directory(context); - if (directory == null) return false; - String report; - try { - report = CrashLogText.format(facts(thread, error)); - } catch (Throwable ignored) { - return false; - } - byte[] data = report.getBytes(StandardCharsets.UTF_8); - if (data.length == 0) return false; - File target = new File(directory, PREFIX + System.currentTimeMillis() + SUFFIX); - File temporary = new File(target.getPath() + ".new"); - try (FileOutputStream output = new FileOutputStream(temporary)) { - output.write(data); - output.flush(); - output.getFD().sync(); - } catch (IOException | RuntimeException ignored) { - temporary.delete(); - return false; - } - if (!temporary.renameTo(target)) { - temporary.delete(); - return false; - } - rotate(directory); - return true; - } - - /** Newest report written by the just-crashed process, or null when absent/stale/unreadable. */ - public static String readRecent(Context context) { - File[] files = reports(context); - if (files == null || files.length == 0) return null; - long now = SystemClock.elapsedRealtime(); - for (File file : files) { - String text = read(file); - if (text == null || text.isEmpty()) continue; - // Do not stop at the first stale report: names are wall-clock based, so a clock jump - // can make an older report sort first while a fresher one still exists. - if (CrashLogText.isFresh(CrashLogText.reportElapsed(text), now, CrashLogText.FRESH_WINDOW_MS)) return text; - } - return null; - } - - private static CrashLogText.Facts facts(Thread thread, Throwable error) { - String message = error == null ? "" : error.getMessage(); - return new CrashLogText.Facts( - AppVersion.fullName(), - new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US).format(new Date()), - SystemClock.elapsedRealtime(), - processName(), - Process.myPid(), - Process.myUid(), - thread == null ? "" : thread.getName(), - DebugLogStore.isEnabled(), - safe(Build.MANUFACTURER) + " " + safe(Build.MODEL), - "Android " + safe(Build.VERSION.RELEASE) + " (sdk " + Build.VERSION.SDK_INT + ")", - error == null ? "" : error.getClass().getName(), - message == null ? "" : message, - error == null ? "" : Log.getStackTraceString(error)); - } - - private static String processName() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) return safe(android.app.Application.getProcessName()); - byte[] bytes = new byte[MAX_CMDLINE_BYTES]; - int length; - try (FileInputStream input = new FileInputStream("/proc/self/cmdline")) { - length = input.read(bytes); - } - if (length <= 0) return ""; - int end = 0; - while (end < length && bytes[end] != 0) end++; - return new String(bytes, 0, end, StandardCharsets.UTF_8); - } catch (Throwable ignored) { - return ""; - } - } - - private static void rotate(File directory) { - try { - File[] files = reports(directory); - if (files == null) return; - for (int i = MAX_FILES; i < files.length; i++) files[i].delete(); - } catch (RuntimeException ignored) { - } - } - - private static File[] reports(Context context) { - File directory = directory(context); - return directory == null ? null : reports(directory); - } - - private static File[] reports(File directory) { - if (directory == null || !directory.isDirectory()) return null; - File[] files = directory.listFiles((dir, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX)); - if (files == null) return null; - Arrays.sort(files, Comparator.comparing(File::getName).reversed()); - return files; - } - - private static String read(File file) { - if (file == null || !file.isFile() || file.length() <= 0) return null; - try (FileInputStream input = new FileInputStream(file)) { - ByteArrayOutputStream bytes = new ByteArrayOutputStream((int) Math.min(file.length(), MAX_READ_CHARS)); - byte[] buffer = new byte[8192]; - int read; - while (bytes.size() < MAX_READ_CHARS && (read = input.read(buffer, 0, Math.min(buffer.length, MAX_READ_CHARS - bytes.size()))) > 0) { - bytes.write(buffer, 0, read); - } - return new String(bytes.toByteArray(), StandardCharsets.UTF_8); - } catch (IOException | RuntimeException ignored) { - return null; - } - } - - private static File directory(Context context) { - if (context == null) return null; - try { - File base = context.getFilesDir(); - if (base == null) return null; - File directory = new File(base, DIRECTORY); - if (!directory.isDirectory() && !directory.mkdirs()) return null; - return directory; - } catch (Throwable ignored) { - return null; - } - } - - private static Context application(Context context) { - if (context == null) return null; - try { - Context app = context.getApplicationContext(); - return app == null ? context : app; - } catch (RuntimeException ignored) { - return context; - } - } - - private static String safe(String value) { - return value == null ? "" : value; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/CrashRestartMode.java b/app/src/main/java/com/fongmi/android/tv/utils/CrashRestartMode.java deleted file mode 100644 index e17836626c..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/CrashRestartMode.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.fongmi.android.tv.utils; - -import com.github.catvod.utils.Prefers; - -public final class CrashRestartMode { - - private static final String KEY = "crash_restart_skip_config_once"; - - private CrashRestartMode() { - } - - public static void arm() { - // The crash library kills this process immediately after starting the restart activity. - // Persist synchronously so the new process cannot miss the one-shot marker. - Prefers.getPrefers().edit().putBoolean(KEY, true).commit(); - } - - public static boolean consume() { - if (!Prefers.getBoolean(KEY)) return false; - Prefers.getPrefers().edit().remove(KEY).commit(); - return true; - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/FileChooser.java b/app/src/main/java/com/fongmi/android/tv/utils/FileChooser.java index 460a18dd77..8ff2f2af73 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/FileChooser.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/FileChooser.java @@ -21,24 +21,14 @@ import com.fongmi.android.tv.ui.activity.FileActivity; import com.github.catvod.utils.Path; import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; import java.io.InputStream; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.Files; -import java.nio.file.StandardCopyOption; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; -import java.util.Locale; public class FileChooser { - private static final long MAX_IMPORT_BYTES = 32L * 1024L * 1024L; - private final ActivityResultLauncher launcher; public static FileChooser from(ActivityResultLauncher launcher) { @@ -96,11 +86,6 @@ public class FileChooser { return getPathFromUri(App.get(), uri); } - /** 为长期保存的配置导入项返回持久路径,避免系统 cache 清理或同名文件覆盖。 */ - public static String getPersistentPathFromUri(Uri uri) { - return getPathFromUri(App.get(), uri, true); - } - public static List getPathsFromIntent(Intent intent) { List paths = new ArrayList<>(); if (intent == null) return paths; @@ -118,36 +103,12 @@ public class FileChooser { } private static String getPathFromUri(Context context, Uri uri) { - return getPathFromUri(context, uri, false); - } - - private static String getPathFromUri(Context context, Uri uri, boolean persistent) { if (uri == null) return null; String path = null; - boolean encoded = true; if (DocumentsContract.isDocumentUri(context, uri)) path = getPathFromDocumentUri(context, uri); else if (ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) path = getDataColumn(context, uri); - else if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(uri.getScheme())) { - // FileActivity 用 Uri.fromFile 生成,getPath() 已是明文;再解码会把 `+` 变成空格。 - path = uri.getPath(); - encoded = false; - } - if (path != null) { - String decoded = encoded ? decodePath(path) : path; - // 解码可能猜错:document/content 拿到的路径其实也已是明文,而 `+` 会被解成空格。 - // 解码结果不存在、原文存在时一律用原文——非持久分支同样受影响(字幕/弹幕都走这里)。 - if (!decoded.equals(path) && !new File(decoded).exists() && new File(path).exists()) return path; - if (!persistent || new File(decoded).exists()) return decoded; - } - return createFileFromUri(context, uri, persistent); - } - - private static String decodePath(String path) { - try { - return URLDecoder.decode(path, StandardCharsets.UTF_8); - } catch (IllegalArgumentException e) { - return path; - } + else if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(uri.getScheme())) path = uri.getPath(); + return path != null ? URLDecoder.decode(path, StandardCharsets.UTF_8) : createFileFromUri(context, uri); } private static String getPathFromDocumentUri(Context context, Uri uri) { @@ -187,90 +148,21 @@ public class FileChooser { }; } - private static String createFileFromUri(Context context, Uri uri, boolean persistent) { + private static String createFileFromUri(Context context, Uri uri) { String[] projection = {MediaStore.MediaColumns.DISPLAY_NAME}; try (Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null)) { if (cursor == null || !cursor.moveToFirst()) return null; + InputStream is = context.getContentResolver().openInputStream(uri); + if (is == null) return null; int column = cursor.getColumnIndexOrThrow(projection[0]); - String name = cursor.getString(column); - try (InputStream is = context.getContentResolver().openInputStream(uri)) { - if (is == null) return null; - if (!persistent) { - File target = Path.cache(name); - Path.copy(is, target); - return target.isFile() && target.length() > 0 ? target.getAbsolutePath() : null; - } - return persistentImport(is, name, Path.files("cat_source_imports")); - } + File file = Path.cache(cursor.getString(column)); + Path.copy(is, file); + return file.getAbsolutePath(); } catch (Exception e) { return null; } } - /** - * 导入目录在内部存储里,系统「清理缓存」清不掉,所以不能每次导入都留一份新副本。 - * - *

按内容 md5 命名做天然去重:反复导入同一个包只占一份,而不同内容各自独立, - * 也不需要删除任何文件——已保存的配置仍指向自己那一份。先写临时文件再原子移动, - * 中断不会留下残缺文件。temp 在拿到流之后才建,避免 {@code openInputStream} - * 返回 null 时留下 0 字节的 {@code .import-*.tmp}。 - * - *

{@code dir} 由调用方传入,好让单测不依赖 {@code Path.files()} 背后的 Android Context。 - */ - static String persistentImport(InputStream is, String name, File dir) throws IOException { - dir.mkdirs(); - File temp = File.createTempFile(".import-", ".tmp", dir); - try { - MessageDigest digest; - try { - digest = MessageDigest.getInstance("MD5"); - } catch (NoSuchAlgorithmException e) { - throw new IOException("MD5 不可用", e); - } - long total = 0; - try (FileOutputStream out = new FileOutputStream(temp)) { - byte[] buffer = new byte[65536]; - int length; - while ((length = is.read(buffer)) != -1) { - total += length; - if (total > MAX_IMPORT_BYTES) throw new IOException("导入文件超过大小限制"); - digest.update(buffer, 0, length); - out.write(buffer, 0, length); - } - } - if (total == 0) throw new IOException("导入文件为空"); - File target = new File(dir, hex(digest.digest()) + "_" + safeName(name)); - if (target.isFile() && target.length() == total) { - temp.delete(); - return target.getAbsolutePath(); - } - moveImport(temp, target); - return target.isFile() && target.length() > 0 ? target.getAbsolutePath() : null; - } catch (Exception e) { - if (temp.exists()) temp.delete(); - throw e; - } - } - - private static String safeName(String name) { - String base = name == null ? "import" : new File(name).getName(); - return base.replaceAll("[^A-Za-z0-9._-]", "_"); - } - - private static String hex(byte[] bytes) { - StringBuilder value = new StringBuilder(bytes.length * 2); - for (byte item : bytes) value.append(String.format(Locale.ROOT, "%02x", item & 0xff)); - return value.toString(); - } - - private static void moveImport(File source, File target) throws IOException { - try { - Files.move(source.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException e) { - Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); - } - } - private static String getDataColumn(Context context, Uri uri) { String[] projection = {MediaStore.MediaColumns.DATA}; try (Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null)) { diff --git a/app/src/main/java/com/fongmi/android/tv/utils/GithubProxy.java b/app/src/main/java/com/fongmi/android/tv/utils/GithubProxy.java index b113e03256..34955ba855 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/GithubProxy.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/GithubProxy.java @@ -5,15 +5,10 @@ import com.fongmi.android.tv.setting.Setting; import java.util.ArrayList; import java.util.List; import java.util.Locale; -import java.net.URI; public final class GithubProxy { public static final String DEFAULT = "https://gh-proxy.com/"; - public static final String DIRECT = "direct"; - public static final String MODE_FULL_URL = "full_url"; - public static final String MODE_STRIP_SCHEME = "strip_scheme"; - private static final String[] BUILT_IN = { DEFAULT, "https://ghfast.top/", @@ -39,19 +34,6 @@ public final class GithubProxy { return isEmpty(proxy) ? url : normalize(proxy) + url; } - public static Config config() { - return config(Setting.getGithubProxy(), Setting.getGithubProxyMode(), Setting.isGithubProxyEnabled()); - } - - public static Config config(String configured, String mode, boolean enabled) { - if (!enabled) return new Config(DIRECT, "", MODE_FULL_URL); - return new Config("proxy", normalize(first(configured)), normalizeMode(mode)); - } - - public static String normalizeMode(String mode) { - return MODE_STRIP_SCHEME.equals(mode) ? MODE_STRIP_SCHEME : MODE_FULL_URL; - } - public static String defaultSources() { return String.join("\n", BUILT_IN); } @@ -96,15 +78,6 @@ public final class GithubProxy { return String.join("\n", list); } - public static String addSources(String configured, String value) { - if (isEmpty(value)) return configured; - List list = sources(configured); - for (String source : sources(value)) { - if (!list.contains(source)) list.add(source); - } - return String.join("\n", list); - } - public static String removeSource(String source) { if (isEmpty(source)) return Setting.getGithubProxy(); String normalized = normalize(source); @@ -118,32 +91,6 @@ public final class GithubProxy { return sources.isEmpty() ? defaultSources() : String.join("\n", sources); } - public static String probeUrl(String source) { - String proxy = normalize(isEmpty(source) ? first(Setting.getGithubProxy()) : source); - return proxy + "https://github.com/Silent1566/webhtv/releases/download/update-channel/update.json"; - } - - public static final class Config { - - public final String id; - public final String baseUrl; - public final String mode; - - private Config(String id, String baseUrl, String mode) { - this.id = id; - this.baseUrl = baseUrl; - this.mode = mode; - } - - public String rewrite(String url) { - String target = requireHttpsUrl(url); - if (DIRECT.equals(id) || baseUrl.isEmpty()) return target; - String prefix = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; - if (MODE_STRIP_SCHEME.equals(mode)) return prefix + target.substring("https://".length()); - return prefix + target; - } - } - private static String first(String configured) { List sources = sources(configured); return sources.isEmpty() ? DEFAULT : sources.get(0); @@ -178,21 +125,6 @@ public final class GithubProxy { return lower.matches("https?://[^/]+/https?://.*"); } - private static String requireHttpsUrl(String value) { - try { - String text = value == null ? "" : value.trim(); - URI uri = URI.create(text); - if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException("HTTPS required"); - if (uri.getHost() == null || uri.getHost().isEmpty()) throw new IllegalArgumentException("Host required"); - if (uri.getUserInfo() != null) throw new IllegalArgumentException("Credentials are not allowed"); - return text; - } catch (IllegalArgumentException e) { - throw e; - } catch (Exception e) { - throw new IllegalArgumentException("Invalid HTTPS URL", e); - } - } - private static boolean isEmpty(String text) { return text == null || text.length() == 0; } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockNotice.java b/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockNotice.java deleted file mode 100644 index 0fa0836d4c..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockNotice.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.fongmi.android.tv.utils; - -import java.util.LinkedHashMap; -import java.util.Locale; -import java.util.Map; - -/** Playback-session debounce for repeated live-playlist refreshes, plus the shared notice text. */ -public final class HlsAdblockNotice { - - private static final long WINDOW_MS = 30_000L; - private static final int MAX_ENTRIES = 64; - /** Display granularity of the removed-ad duration: one tenth of a second. */ - private static final long TENTHS_PER_SECOND = 10L; - private static final long TENTHS_PER_MINUTE = 60L * TENTHS_PER_SECOND; - private static final Map RECENT = new LinkedHashMap<>(); - - private HlsAdblockNotice() {} - - public static synchronized boolean shouldNotify(String playlistUrl, long nowMs) { - String key = playlistUrl == null ? "" : playlistUrl; - Long previous = RECENT.get(key); - if (previous != null && nowMs - previous < WINDOW_MS) return false; - RECENT.put(key, nowMs); - while (RECENT.size() > MAX_ENTRIES) RECENT.remove(RECENT.keySet().iterator().next()); - return true; - } - - /** - * Builds the single user-visible ad-block confirmation used by every HLS channel. - * - *

The notice always reports the number of removed ad segments and, whenever the removed - * duration is known, the total ad time as well. The duration is included for both the - * structured rule path and the legacy fallback path, because a user who only sees a segment - * count cannot tell whether one 2-second bumper or a 3-minute ad break was removed.

- */ - public static String message(int removedSegments, double removedDurationSec) { - String count = "已跳过 " + Math.max(0, removedSegments) + " 个广告片段"; - String duration = durationText(removedDurationSec); - return duration.isEmpty() ? count : count + ",总广告时长 " + duration; - } - - /** - * Formats a removed-ad duration as seconds below one minute and as minutes at or above it. - * - *

The value is rounded to the tenth-of-a-second display granularity before the unit - * is chosen, exactly like {@link AdBlockTimeFormatter#formatSeconds(double)}: a 59.96-second - * removal is reported as {@code 1 分钟}, never as the self-contradicting {@code 60.0 秒}.

- * - * @return the text to show, or an empty string when no duration is displayable (zero, negative, - * non-finite, or smaller than the display granularity) so that the caller falls back to - * the segment count instead of showing a misleading {@code 0.0 秒} - */ - public static String durationText(double seconds) { - long tenths = displayableTenths(seconds); - if (tenths == 0L) return ""; - if (tenths < TENTHS_PER_MINUTE) { - return String.format(Locale.US, "%.1f 秒", tenths / (double) TENTHS_PER_SECOND); - } - long total = Math.round(tenths / (double) TENTHS_PER_SECOND); - long minutes = total / 60L; - long remainder = total % 60L; - return remainder == 0L ? minutes + " 分钟" : minutes + " 分 " + remainder + " 秒"; - } - - private static long displayableTenths(double seconds) { - if (!Double.isFinite(seconds) || seconds <= 0d) return 0L; - return Math.round(seconds * TENTHS_PER_SECOND); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java b/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java index 939fc227d0..1de773e521 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockPipeline.java @@ -2,92 +2,46 @@ package com.fongmi.android.tv.utils; import androidx.media3.exoplayer.hls.playlist.HlsAdsParser; +import com.github.catvod.crawler.SpiderDebug; + import java.net.URI; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; public final class HlsAdblockPipeline { private HlsAdblockPipeline() {} public static Outcome apply(String url, String manifest, List rules, boolean legacyFallback) { - if (!legacyFallback) { - HlsManifestCleaner.Result clean = HlsManifestCleaner.clean(url, manifest, rules); - return new Outcome(clean.manifest(), clean.changed(), false, - clean.removedSegments(), clean.removedDurationSec(), clean.ruleCounts(), - clean.removedSegmentDetails()); + // Encrypted playlists whose IV is implied by the media sequence number must not be + // rewritten by *either* engine, so the check lives here rather than inside one of + // them. Putting it in HlsManifestCleaner was actively harmful: the cleaner's only + // way to decline is Result.fallback, and fallback is precisely what invites the + // legacy heuristic in below — measured, that turned "25 ads removed" into "200 main + // segments deleted by the heuristic, and every retained segment decrypting with the + // wrong IV". Declining here stops both. + if (HlsEncryptionGuard.hasImpliedSegmentIv(manifest)) { + SpiderDebug.log("hls-adblock", "declined reason=implied-segment-iv url=%s", url); + return new Outcome(manifest, false, false, 0, 0); } HlsManifestCleaner.Result clean = HlsManifestCleaner.clean(url, manifest, rules); if (clean.changed()) { - return new Outcome(clean.manifest(), true, false, clean.removedSegments(), clean.removedDurationSec(), - clean.ruleCounts(), clean.removedSegmentDetails()); + return new Outcome(clean.manifest(), true, false, clean.removedSegments(), clean.removedDurationSec()); } - if (!legacyFallback || clean.fallback() || manifest == null || !manifest.contains("#EXT-X-ENDLIST")) { + // 结构化引擎回退时仍要让 legacy 兜底。fallback 的语义是「这份 manifest + // 我处理不了」,恰恰最该由启发式接手;此前把它并入短路条件,等于一条 + // 过宽的规则不只无效,还会连带停掉该站原有的去广能力 —— 比不加规则更差。 + // 触发源不止用户规则,内置与接口下发的规则同样会让 cleaner 回退。 + if (!legacyFallback || manifest == null || !manifest.contains("#EXT-X-ENDLIST")) { return new Outcome(manifest, false, false, 0, 0); } try { String filtered = HlsAdsParser.process(manifest); - if (filtered.equals(manifest)) return new Outcome(manifest, false, false, 0, 0); - List removed = legacyRemovedSegments(url, manifest, filtered); - double duration = removed.stream().mapToDouble(HlsManifestCleaner.RemovedSegment::durationSec).sum(); - return new Outcome(filtered, false, true, removed.size(), duration, - Map.of(), List.copyOf(removed)); + return new Outcome(filtered, false, !filtered.equals(manifest), 0, 0); } catch (Throwable ignored) { return new Outcome(manifest, false, false, 0, 0); } } - static List legacyRemovedSegments( - String baseUrl, String original, String filtered) { - List source = parseSegments(baseUrl, original); - Map kept = new HashMap<>(); - for (SegmentInfo segment : parseSegments(baseUrl, filtered)) kept.merge(segment.uri(), 1, Integer::sum); - List removed = new ArrayList<>(); - for (SegmentInfo segment : source) { - int count = kept.getOrDefault(segment.uri(), 0); - if (count > 0) { - kept.put(segment.uri(), count - 1); - continue; - } - removed.add(new HlsManifestCleaner.RemovedSegment( - segment.host(), "hls.legacy-fallback", segment.startSeconds(), segment.durationSeconds())); - } - return removed; - } - - private static List parseSegments(String baseUrl, String manifest) { - List result = new ArrayList<>(); - if (manifest == null) return result; - double start = 0; - double duration = -1; - for (String raw : manifest.replace("\r", "").split("\n")) { - String line = raw.trim(); - if (line.startsWith("#EXTINF:")) { - try { - String value = line.substring(8); - int comma = value.indexOf(','); - duration = Double.parseDouble(comma < 0 ? value : value.substring(0, comma)); - } catch (RuntimeException e) { - duration = -1; - } - } else if (!line.isEmpty() && !line.startsWith("#") && duration >= 0) { - try { - URI resolved = URI.create(baseUrl).resolve(line); - result.add(new SegmentInfo(line, resolved.getHost() == null ? "" : resolved.getHost(), start, duration)); - } catch (RuntimeException e) { - result.add(new SegmentInfo(line, "", start, duration)); - } - start += duration; - duration = -1; - } - } - return result; - } - - private record SegmentInfo(String uri, String host, double startSeconds, double durationSeconds) {} - public static boolean isCoreM3u8Proxy(String url) { try { URI uri = URI.create(url); @@ -99,11 +53,5 @@ public final class HlsAdblockPipeline { } } - public record Outcome(String manifest, boolean structured, boolean legacy, int removedSegments, - double removedDurationSec, Map ruleCounts, - List removedSegmentDetails) { - public Outcome(String manifest, boolean structured, boolean legacy, int removedSegments, double removedDurationSec) { - this(manifest, structured, legacy, removedSegments, removedDurationSec, Map.of(), List.of()); - } - } + public record Outcome(String manifest, boolean structured, boolean legacy, int removedSegments, double removedDurationSec) {} } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/HlsEncryptionGuard.java b/app/src/main/java/com/fongmi/android/tv/utils/HlsEncryptionGuard.java new file mode 100644 index 0000000000..4cb2ae5979 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/utils/HlsEncryptionGuard.java @@ -0,0 +1,59 @@ +package com.fongmi.android.tv.utils; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Detects HLS playlists whose segment decryption IV is implied by the media sequence + * number, which makes them unsafe to rewrite by any means that removes segments. + * + *

When {@code #EXT-X-KEY} enables encryption but omits {@code IV=}, RFC 8216 §5.2 says + * the IV is the segment's media sequence number. Removing a segment renumbers every + * segment after it, so each one is then decrypted with the wrong IV and the stream turns + * to garbage — not a mis-cut, a black or corrupt picture. + * + *

The predicates below mirror {@code media3}'s forked {@code HlsPlaylistParser} + * exactly, because the only thing that matters is what that parser will conclude + * from the same bytes. Two of its properties are easy to get wrong, and both were: + * + *

    + *
  • {@code TAG_KEY} is {@code "#EXT-X-KEY"} without the colon, so + * {@code "#EXT-X-KEY :METHOD=AES-128,…"} still parses as a key line.
  • + *
  • {@code REGEX_METHOD} and {@code REGEX_IV} are case-sensitive and anchored to the + * attribute name, so a lowercase {@code iv=} inside a quoted {@code URI} neither + * pins the IV for media3 nor should count as pinned here. Matching on an + * upper-cased substring got this backwards and let corrupting playlists through.
  • + *
+ */ +final class HlsEncryptionGuard { + + /** {@code media3 HlsPlaylistParser.TAG_KEY} — deliberately without the trailing colon. */ + private static final String TAG_KEY = "#EXT-X-KEY"; + /** Mirrors {@code media3 HlsPlaylistParser.REGEX_METHOD}. */ + private static final Pattern REGEX_METHOD = Pattern.compile( + "METHOD=(NONE|AES-128|SAMPLE-AES|SAMPLE-AES-CENC|SAMPLE-AES-CTR)\\s*(?:,|$)"); + /** Mirrors {@code media3 HlsPlaylistParser.REGEX_IV}. */ + private static final Pattern REGEX_IV = Pattern.compile("IV=([^,.*]+)"); + + private HlsEncryptionGuard() { + } + + /** + * @return whether any key line enables encryption without pinning an explicit IV + */ + static boolean hasImpliedSegmentIv(String manifest) { + if (manifest == null) return false; + // Split the way BufferedReader.readLine does — a lone CR ends a line too. Splitting + // on "\n" alone collapses a CR-only playlist into one giant line, hiding its keys. + for (String line : manifest.split("\r\n|\n|\r", -1)) { + if (!line.startsWith(TAG_KEY)) continue; + Matcher method = REGEX_METHOD.matcher(line); + // No recognisable METHOD: media3's parseStringAttr throws and the whole playlist + // fails to parse, so it is not something we could have corrupted. Ignore it. + if (!method.find()) continue; + if ("NONE".equals(method.group(1))) continue; + if (!REGEX_IV.matcher(line).find()) return true; + } + return false; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/HlsManifestCleaner.java b/app/src/main/java/com/fongmi/android/tv/utils/HlsManifestCleaner.java index c0fbd6cf37..333f53cbf7 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/HlsManifestCleaner.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/HlsManifestCleaner.java @@ -4,10 +4,8 @@ import java.net.URI; import java.math.BigInteger; import java.util.ArrayList; import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.regex.Pattern; /** Pure manifest-text cleaner. Network fetching and URI proxy rewriting stay outside this class. */ @@ -34,24 +32,15 @@ public final class HlsManifestCleaner { List nodes = parse(manifest); int segmentCount = 0; int removedCount = 0; - double mediaOffsetSec = 0; double removedDurationSec = 0; - Map ruleCounts = new LinkedHashMap<>(); - List removedSegments = new ArrayList<>(); for (Node node : nodes) { if (!(node instanceof Segment segment)) continue; segmentCount++; - Rule matchedRule = matchRule(baseUrl, segment, rules); - if (matchedRule != null) { + if (matches(baseUrl, segment, rules)) { segment.removed = true; removedCount++; removedDurationSec += segment.durationSec; - ruleCounts.merge(matchedRule.id, 1L, Long::sum); - URI resolved = URI.create(baseUrl).resolve(segment.uri); - String host = resolved.getHost() == null ? "" : resolved.getHost(); - removedSegments.add(new RemovedSegment(host, matchedRule.id, mediaOffsetSec, segment.durationSec)); } - mediaOffsetSec += segment.durationSec; } if (removedCount == 0) return Result.unchanged(manifest); if (segmentCount == 0 || removedCount == segmentCount || (double) removedCount / segmentCount > MAX_REMOVAL_RATIO @@ -76,8 +65,7 @@ public final class HlsManifestCleaner { if (discontinuitySequenceIncrement > 0 && !manifest.contains("#EXT-X-DISCONTINUITY-SEQUENCE:")) return Result.fallback(manifest); } return new Result(render(nodes, manifest.endsWith("\n"), mediaSequenceIncrement, discontinuitySequenceIncrement), - true, false, removedCount, removedDurationSec, Collections.unmodifiableMap(ruleCounts), - Collections.unmodifiableList(removedSegments)); + true, false, removedCount, removedDurationSec); } catch (RuntimeException e) { return Result.fallback(manifest); } @@ -142,7 +130,7 @@ public final class HlsManifestCleaner { pending.clear(); } - private static Rule matchRule(String baseUrl, Segment segment, List rules) { + private static boolean matches(String baseUrl, Segment segment, List rules) { URI base = URI.create(baseUrl); URI resolved = base.resolve(segment.uri); String url = resolved.toString(); @@ -158,9 +146,9 @@ public final class HlsManifestCleaner { if (rule.hasDurationRange() && segment.durationSec >= rule.minDuration && segment.durationSec <= rule.maxDuration) signals++; if (rule.requireDiscontinuity && segment.discontinuityBefore) signals++; if (rule.requireCrossDomain && !host.isEmpty() && !baseHost.isEmpty() && !host.equals(baseHost)) signals++; - if (signals >= rule.minimumSignals) return rule; + if (signals >= rule.minimumSignals) return true; } - return null; + return false; } private static boolean matchesHost(String host, List suffixes) { @@ -257,22 +245,12 @@ public final class HlsManifestCleaner { } } - public record RemovedSegment(String adDomain, String ruleId, double startSeconds, double durationSec) {} - - public record Result(String manifest, boolean changed, boolean fallback, int removedSegments, - double removedDurationSec, Map ruleCounts, - List removedSegmentDetails) { - private static Result unchanged(String manifest) { - return new Result(manifest, false, false, 0, 0, Map.of(), List.of()); - } - - private static Result fallback(String manifest) { - return new Result(manifest, false, true, 0, 0, Map.of(), List.of()); - } + public record Result(String manifest, boolean changed, boolean fallback, int removedSegments, double removedDurationSec) { + private static Result unchanged(String manifest) { return new Result(manifest, false, false, 0, 0); } + private static Result fallback(String manifest) { return new Result(manifest, false, true, 0, 0); } } public static final class Rule { - private final String id; private final List hostSuffixes; private final List playlistHostSuffixes; private final List playlistHostPatterns; @@ -284,7 +262,6 @@ public final class HlsManifestCleaner { private final boolean requireCrossDomain; private Rule(Builder builder) { - this.id = builder.id; this.hostSuffixes = List.copyOf(builder.hostSuffixes); this.playlistHostSuffixes = List.copyOf(builder.playlistHostSuffixes); this.playlistHostPatterns = compilePatterns(builder.playlistHostPatterns); @@ -317,7 +294,6 @@ public final class HlsManifestCleaner { public static Builder builder() { return new Builder(); } public static final class Builder { - private String id = "unnamed"; private List hostSuffixes = List.of(); private List playlistHostSuffixes = List.of(); private List playlistHostPatterns = List.of(); @@ -328,7 +304,6 @@ public final class HlsManifestCleaner { private boolean requireDiscontinuity; private boolean requireCrossDomain; - public Builder id(String value) { id = value == null || value.isBlank() ? "unnamed" : value; return this; } public Builder hostSuffixes(List values) { hostSuffixes = values == null ? List.of() : values; return this; } public Builder playlistHostSuffixes(List values) { playlistHostSuffixes = values == null ? List.of() : values; return this; } public Builder playlistHostPatterns(List values) { playlistHostPatterns = values == null ? List.of() : values; return this; } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/ImageHeaderPolicy.java b/app/src/main/java/com/fongmi/android/tv/utils/ImageHeaderPolicy.java index 73568e8417..97ca9a7b35 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/ImageHeaderPolicy.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/ImageHeaderPolicy.java @@ -3,7 +3,6 @@ package com.fongmi.android.tv.utils; import com.google.common.net.HttpHeaders; import java.util.Locale; -import java.util.regex.Pattern; import okhttp3.HttpUrl; @@ -11,11 +10,6 @@ public class ImageHeaderPolicy { static final String DOUBAN_IMAGE_REFERER = "https://movie.douban.com"; - // 预编译:String.matches() 每调用一次都会重新编译同一个正则。详情页/播放页的图片列表 - // 会成百次地走 doubanImageReferer(),编译开销全落在主线程上;Pattern 是线程安全的, - // 复用一个实例即可,语义与旧写法 String.matches(...) 完全相同。 - private static final Pattern DOUBAN_IMAGE_HOST = Pattern.compile("img\\d+\\.doubanio\\.com"); - private ImageHeaderPolicy() { } @@ -29,7 +23,7 @@ public class ImageHeaderPolicy { } private static boolean isDoubanImageHost(String host) { - return host != null && DOUBAN_IMAGE_HOST.matcher(host).matches(); + return host != null && host.matches("img\\d+\\.doubanio\\.com"); } private static String host(String url) { diff --git a/app/src/main/java/com/fongmi/android/tv/utils/ImgUtil.java b/app/src/main/java/com/fongmi/android/tv/utils/ImgUtil.java index f4f00c0132..cbf9101e04 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/ImgUtil.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/ImgUtil.java @@ -187,17 +187,13 @@ public class ImgUtil { @Override public boolean onLoadFailed(@Nullable GlideException e, Object model, @NonNull Target target, boolean isFirstResource) { failed.add(url); - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, TmdbProxy.routeForImageUrl(url)); - String autoFallback = TmdbProxy.nextAutoImageUrl(url); - if (!TextUtils.isEmpty(autoFallback) && !failed.contains(autoFallback)) load(text, autoFallback, view, vod, width, height); - else if (!TextUtils.isEmpty(fallback) && !failed.contains(fallback)) load(text, fallback, view, vod, width, height); + if (!TextUtils.isEmpty(fallback) && !failed.contains(fallback)) load(text, fallback, view, vod, width, height); else showTextDrawable(text, view, vod, false); return true; } @Override public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { - TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.IMAGE, TmdbProxy.routeForImageUrl(url), 0); return false; } }; @@ -207,20 +203,13 @@ public class ImgUtil { return new RequestListener<>() { @Override public boolean onLoadFailed(@Nullable GlideException e, Object model, @NonNull Target target, boolean isFirstResource) { + showTextDrawable(text, view, vod, false); failed.add(url); - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, TmdbProxy.routeForImageUrl(url)); - String autoFallback = TmdbProxy.nextAutoImageUrl(url); - if (!TextUtils.isEmpty(autoFallback) && !failed.contains(autoFallback)) { - load(text, autoFallback, view, vod, 0, 0); - } else { - showTextDrawable(text, view, vod, false); - } return true; } @Override public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { - TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.IMAGE, TmdbProxy.routeForImageUrl(url), 0); return false; } }; diff --git a/app/src/main/java/com/fongmi/android/tv/utils/MpvConfigSync.java b/app/src/main/java/com/fongmi/android/tv/utils/MpvConfigSync.java deleted file mode 100644 index 55fd738fe0..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/MpvConfigSync.java +++ /dev/null @@ -1,329 +0,0 @@ -package com.fongmi.android.tv.utils; - -import com.fongmi.android.tv.player.mpv.MpvConfigStore; -import com.github.catvod.crawler.SpiderDebug; -import com.github.catvod.utils.Path; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.UUID; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; -import java.util.zip.ZipOutputStream; - -/** Archives only the files owned by MPV configuration management. */ -public final class MpvConfigSync { - - public static final String PART_NAME = "mpvConfigFiles"; - - private static final int BUFFER_SIZE = 64 * 1024; - private static final int MAX_ENTRIES = 512; - private static final long MAX_ENTRY_BYTES = 2L * 1024 * 1024; - private static final long MAX_ARCHIVE_BYTES = 32L * 1024 * 1024; - private static final String MPV_CONF = "mpv.conf"; - private static final String INPUT_CONF = "input.conf"; - private static final String SCRIPTS = "scripts/"; - private static final String PROFILES = "profiles/"; - - private MpvConfigSync() { - } - - public static Archive createArchive() throws IOException { - File root = MpvConfigStore.configDir().getCanonicalFile(); - File archive = File.createTempFile("webhtv-mpv-sync-", ".zip", Path.cache()); - int count = 0; - long size = 0; - byte[] buffer = new byte[BUFFER_SIZE]; - try (ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(archive), BUFFER_SIZE))) { - for (String name : new String[]{MPV_CONF, INPUT_CONF}) { - File file = new File(root, name); - if (file.isFile()) { - Stats stats = addFile(root, file, name, zos, buffer); - count += stats.count; - size += stats.size; - } - } - File scripts = new File(root, "scripts"); - addDirectoryEntry(scripts, SCRIPTS, zos); - Stats scriptsStats = addTree(root, scripts, SCRIPTS, zos, buffer); - count += scriptsStats.count; - size += scriptsStats.size; - checkLimits(count, size); - for (String target : new String[]{"mpv_conf", "input_conf"}) { - File profileDir = new File(root, "profiles" + File.separator + target); - String prefix = PROFILES + target + "/"; - addDirectoryEntry(profileDir, prefix, zos); - Stats profileStats = addTree(root, profileDir, prefix, zos, buffer); - count += profileStats.count; - size += profileStats.size; - checkLimits(count, size); - } - } catch (Throwable e) { - deleteRecursively(archive); - if (e instanceof IOException) throw (IOException) e; - throw new IOException(e); - } - if (archive.length() > MAX_ARCHIVE_BYTES) { - deleteRecursively(archive); - throw new IOException("MPV config archive too large"); - } - SpiderDebug.log("sync", "archive mpv config count=%d size=%d zip=%d file=%s", count, size, archive.length(), archive.getAbsolutePath()); - return new Archive(archive, count, size, archive.length()); - } - - public static int restoreArchive(File archive) throws IOException { - if (archive == null || !archive.isFile() || archive.length() <= 0) return 0; - if (archive.length() > MAX_ARCHIVE_BYTES) throw new IOException("MPV config archive too large"); - File staging = new File(Path.cache(), "webhtv-mpv-restore-" + UUID.randomUUID()); - if (!staging.mkdirs()) throw new IOException("Cannot create MPV config staging directory"); - int count = 0; - int entries = 0; - long total = 0; - byte[] buffer = new byte[BUFFER_SIZE]; - try { - try (ZipInputStream zis = new ZipInputStream(new BufferedInputStream(new FileInputStream(archive), BUFFER_SIZE))) { - ZipEntry entry; - while ((entry = zis.getNextEntry()) != null) { - if (++entries > MAX_ENTRIES) throw new IOException("Too many MPV config files"); - String path = normalize(entry.getName()); - if (!isAllowed(path)) throw new IOException("Invalid MPV config path"); - File out = new File(staging, path); - if (!inside(staging, out)) throw new IOException("Invalid MPV config path"); - if (entry.isDirectory()) { - if (!out.mkdirs() && !out.isDirectory()) throw new IOException("Cannot create MPV config directory"); - zis.closeEntry(); - continue; - } - count++; - File parent = out.getParentFile(); - if (parent != null && !parent.mkdirs() && !parent.isDirectory()) throw new IOException("Cannot create MPV config directory"); - long written = 0; - try (BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(out), BUFFER_SIZE)) { - int read; - while ((read = zis.read(buffer)) != -1) { - written += read; - total += read; - if (written > MAX_ENTRY_BYTES || total > MAX_ARCHIVE_BYTES) throw new IOException("MPV config archive too large"); - output.write(buffer, 0, read); - } - } - if (entry.getTime() > 0) out.setLastModified(entry.getTime()); - zis.closeEntry(); - } - } - apply(staging); - SpiderDebug.log("sync", "restore mpv config count=%d file=%s", count, archive.getAbsolutePath()); - return count; - } finally { - deleteRecursively(staging); - } - } - - static boolean isAllowed(String path) { - if (path == null || path.isEmpty() || path.startsWith("/") || path.contains("..")) return false; - if (MPV_CONF.equals(path) || INPUT_CONF.equals(path)) return true; - return path.startsWith(SCRIPTS) || path.startsWith(PROFILES + "mpv_conf/") || path.startsWith(PROFILES + "input_conf/") - || "scripts".equals(path) || "profiles".equals(path) - || "profiles/mpv_conf".equals(path) || "profiles/input_conf".equals(path); - } - - private static void apply(File staging) throws IOException { - File root = MpvConfigStore.configDir(); - replaceFile(new File(staging, MPV_CONF), new File(root, MPV_CONF)); - replaceFile(new File(staging, INPUT_CONF), new File(root, INPUT_CONF)); - replaceDirectory(new File(staging, "scripts"), new File(root, "scripts")); - replaceDirectory(new File(staging, PROFILES + "mpv_conf"), new File(root, "profiles/mpv_conf")); - replaceDirectory(new File(staging, PROFILES + "input_conf"), new File(root, "profiles/input_conf")); - MpvConfigStore.ensureCustomButtonScript(); - } - - private static void replaceFile(File source, File target) throws IOException { - if (target.exists() && !target.delete()) throw new IOException("Cannot replace MPV config file"); - if (!source.isFile()) return; - copy(source, target); - } - - private static void replaceDirectory(File source, File target) throws IOException { - deleteRecursively(target); - if (!source.exists()) { - if (!target.mkdirs() && !target.isDirectory()) throw new IOException("Cannot create MPV config directory"); - return; - } - if (!target.mkdirs() && !target.isDirectory()) throw new IOException("Cannot create MPV config directory"); - File[] files = source.listFiles(); - if (files == null) return; - for (File file : files) { - File output = new File(target, file.getName()); - if (file.isDirectory()) copyDirectory(file, output); - else copy(file, output); - } - } - - private static void copyDirectory(File source, File target) throws IOException { - if (!target.mkdirs() && !target.isDirectory()) throw new IOException("Cannot create MPV config directory"); - File[] files = source.listFiles(); - if (files == null) return; - for (File file : files) { - File output = new File(target, file.getName()); - if (file.isDirectory()) copyDirectory(file, output); - else copy(file, output); - } - } - - private static void copy(File source, File target) throws IOException { - File parent = target.getParentFile(); - if (parent != null && !parent.mkdirs() && !parent.isDirectory()) throw new IOException("Cannot create MPV config directory"); - byte[] buffer = new byte[BUFFER_SIZE]; - try (BufferedInputStream input = new BufferedInputStream(new FileInputStream(source), BUFFER_SIZE); - BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(target), BUFFER_SIZE)) { - int read; - while ((read = input.read(buffer)) != -1) output.write(buffer, 0, read); - } - target.setLastModified(source.lastModified()); - } - - private static Stats addTree(File root, File directory, String prefix, ZipOutputStream zos, byte[] buffer) throws IOException { - if (!directory.isDirectory() || !isRegularPath(directory) || !inside(root, directory)) return new Stats(); - Stats stats = new Stats(); - File[] files = directory.listFiles(); - if (files == null) return stats; - for (File file : files) { - if (!isRegularPath(file) || !inside(root, file)) continue; - String name = prefix + file.getName(); - if (file.isDirectory()) { - addDirectoryEntry(file, name + "/", zos); - stats.add(addTree(root, file, name + "/", zos, buffer)); - } else if (file.isFile()) { - stats.add(addFile(root, file, name, zos, buffer)); - } - } - return stats; - } - - private static Stats addFile(File root, File file, String name, ZipOutputStream zos, byte[] buffer) throws IOException { - if (!isRegularPath(file) || !inside(root, file) || file.length() > MAX_ENTRY_BYTES) throw new IOException("MPV config file too large"); - ZipEntry entry = new ZipEntry(name); - entry.setTime(file.lastModified()); - zos.putNextEntry(entry); - long size = 0; - try (BufferedInputStream input = new BufferedInputStream(new FileInputStream(file), BUFFER_SIZE)) { - int read; - while ((read = input.read(buffer)) != -1) { - size += read; - zos.write(buffer, 0, read); - } - } - zos.closeEntry(); - return new Stats(1, size); - } - - private static void checkLimits(int count, long size) throws IOException { - if (count > MAX_ENTRIES || size > MAX_ARCHIVE_BYTES) throw new IOException("MPV config archive too large"); - } - - private static void addDirectoryEntry(File directory, String name, ZipOutputStream zos) throws IOException { - if (!directory.isDirectory() || !isRegularPath(directory) || !inside(MpvConfigStore.configDir(), directory)) return; - ZipEntry entry = new ZipEntry(name.endsWith("/") ? name : name + "/"); - entry.setTime(directory.lastModified()); - zos.putNextEntry(entry); - zos.closeEntry(); - } - - private static String normalize(String value) { - if (value == null) return ""; - String path = value.replace('\\', '/'); - while (path.startsWith("./")) path = path.substring(2); - while (path.endsWith("/") && path.length() > 0) path = path.substring(0, path.length() - 1); - return path; - } - - private static boolean inside(File root, File file) { - try { - String base = root.getCanonicalPath(); - String path = file.getCanonicalPath(); - return path.equals(base) || path.startsWith(base + File.separator); - } catch (IOException e) { - return false; - } - } - - private static boolean isRegularPath(File file) { - try { - return file.getAbsoluteFile().equals(file.getCanonicalFile()); - } catch (IOException e) { - return false; - } - } - - private static void deleteRecursively(File file) { - if (file == null || !file.exists()) return; - try { - if (!file.getAbsoluteFile().equals(file.getCanonicalFile())) { - file.delete(); - return; - } - } catch (IOException e) { - file.delete(); - return; - } - File[] files = file.isDirectory() ? file.listFiles() : null; - if (files != null) for (File child : files) deleteRecursively(child); - file.delete(); - } - - public static final class Archive { - private final File file; - private final int count; - private final long rawSize; - private final long zipSize; - - private Archive(File file, int count, long rawSize, long zipSize) { - this.file = file; - this.count = count; - this.rawSize = rawSize; - this.zipSize = zipSize; - } - - public File getFile() { - return file; - } - - public int getCount() { - return count; - } - - public long getRawSize() { - return rawSize; - } - - public long getZipSize() { - return zipSize; - } - - public void delete() { - deleteRecursively(file); - } - } - - private static final class Stats { - private int count; - private long size; - - private Stats() { - } - - private Stats(int count, long size) { - this.count = count; - this.size = size; - } - - private void add(Stats other) { - count += other.count; - size += other.size; - } - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/Notify.java b/app/src/main/java/com/fongmi/android/tv/utils/Notify.java index 64fc07c265..86d88f8a27 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/Notify.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/Notify.java @@ -15,7 +15,7 @@ import androidx.core.content.ContextCompat; import com.fongmi.android.tv.App; import com.fongmi.android.tv.databinding.ViewProgressBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class Notify { @@ -76,7 +76,7 @@ public class Notify { private void create(Context context) { ViewProgressBinding binding = ViewProgressBinding.inflate(LayoutInflater.from(context)); - mDialog = new WebHtvAlertDialogBuilder(context).setView(binding.getRoot()).create(); + mDialog = new MaterialAlertDialogBuilder(context).setView(binding.getRoot()).create(); mDialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent); mDialog.show(); } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/OkGlideModule.java b/app/src/main/java/com/fongmi/android/tv/utils/OkGlideModule.java index 8993a23038..cd3ffb9d3b 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/OkGlideModule.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/OkGlideModule.java @@ -11,9 +11,7 @@ import com.bumptech.glide.Registry; import com.bumptech.glide.annotation.GlideModule; import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader; import com.bumptech.glide.load.model.GlideUrl; -import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory; import com.bumptech.glide.module.AppGlideModule; -import com.fongmi.android.tv.cache.CachePolicyStore; import com.github.catvod.net.OkHttp; import java.io.InputStream; @@ -24,13 +22,10 @@ public class OkGlideModule extends AppGlideModule { @Override public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) { builder.setLogLevel(Log.ERROR); - long limit = CachePolicyStore.getLimit(com.fongmi.android.tv.cache.CacheModuleId.GLIDE); - builder.setDiskCache(new InternalCacheDiskCacheFactory(context, "image_manager_disk_cache", - limit > 0 ? limit : 256L * 1024L * 1024L)); } @Override public void registerComponents(@NonNull Context context, @NonNull Glide glide, Registry registry) { registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory(OkHttp.client())); } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/utils/PreviousProcessExitLogger.java b/app/src/main/java/com/fongmi/android/tv/utils/PreviousProcessExitLogger.java index 929974746e..b99383ad23 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/PreviousProcessExitLogger.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/PreviousProcessExitLogger.java @@ -8,14 +8,9 @@ import android.os.Build; import androidx.annotation.RequiresApi; import com.github.catvod.crawler.SpiderDebug; -import com.github.catvod.crawler.DebugLogStore; -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; import com.github.catvod.utils.Prefers; import java.util.List; -import java.io.InputStream; -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; public final class PreviousProcessExitLogger { @@ -25,29 +20,20 @@ public final class PreviousProcessExitLogger { } public static void log(Context context) { - if (!SpiderDebug.isEnabled()) return; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - DebugLogStore.event(event().unknown("exitReason", DiagnosticEvent.Status.NOT_SUPPORTED)); - return; + if (!SpiderDebug.isEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return; + try { + logApi30(context.getApplicationContext()); + } catch (Throwable error) { + SpiderDebug.log("process-exit", "query failed error=%s", error.getClass().getSimpleName()); } - Context app = context.getApplicationContext(); - Task.execute(() -> { - if (!SpiderDebug.isEnabled()) return; - try { logApi30(app); } - catch (Throwable error) { - DebugLogStore.event(event().unknown("exitReason", DiagnosticEvent.Status.READ_ERROR) - .observed("javaClass", error.getClass().getSimpleName())); - SpiderDebug.log("process-exit", "query failed error=%s", error.getClass().getSimpleName()); - } - }); } @RequiresApi(Build.VERSION_CODES.R) private static void logApi30(Context context) { ActivityManager manager = context.getSystemService(ActivityManager.class); - if (manager == null) { DebugLogStore.event(event().unknown("exitReason", DiagnosticEvent.Status.UNAVAILABLE)); return; } + if (manager == null) return; List exits = manager.getHistoricalProcessExitReasons(context.getPackageName(), 0, 1); - if (exits.isEmpty()) { DebugLogStore.event(event().unknown("exitReason", DiagnosticEvent.Status.UNAVAILABLE)); return; } + if (exits.isEmpty()) return; ApplicationExitInfo exit = exits.get(0); long timestamp = exit.getTimestamp(); if (timestamp <= Prefers.getLong(KEY_LAST_EXIT_TIMESTAMP, 0)) return; @@ -56,43 +42,6 @@ public final class PreviousProcessExitLogger { "previous reason=%s(%d) status=%d importance=%d timestamp=%d pss=%d rss=%d description=%s", reasonName(exit.getReason()), exit.getReason(), exit.getStatus(), exit.getImportance(), timestamp, exit.getPss(), exit.getRss(), safe(exit.getDescription())); - DebugLogStore.event(event().observed("exitReason", reasonName(exit.getReason())).observed("exitStatus", exit.getStatus()) - .observed("firstSeenMs", timestamp).unknown("previousRun", DiagnosticEvent.Status.UNKNOWN) - .observed("reason", "previous-process-exit; correlate with journal, not current trace").pin("previous-process-exit")); - captureTrace(exit); - } - - private static DiagnosticEvent event() { - return new DiagnosticEvent("process.recovery", "none", "process", 0, 0) - .source("android", String.valueOf(Build.VERSION.SDK_INT), "app", "ApplicationExitInfo", "previous-process; trace-unresolved", - null, null, 0, android.os.SystemClock.elapsedRealtimeNanos()); - } - - @RequiresApi(Build.VERSION_CODES.R) - private static void captureTrace(ApplicationExitInfo exit) { - try (InputStream input = exit.getTraceInputStream()) { - if (input == null) { - DebugLogStore.event(event().unknown("traceAvailable", DiagnosticEvent.Status.UNAVAILABLE)); - return; - } - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - byte[] part = new byte[1024]; - int read; - while (bytes.size() < 8192 && (read = input.read(part, 0, Math.min(part.length, 8192 - bytes.size()))) > 0) bytes.write(part, 0, read); - boolean text = exit.getReason() == ApplicationExitInfo.REASON_ANR; - DebugLogStore.event(event().observed("traceAvailable", true).observed("bytes", bytes.size()) - .observed("format", text ? "anr-text-prefix" : "platform-trace; binary content not exported") - .observed("truncated", bytes.size() == 8192)); - if (text) { - String trace = new String(bytes.toByteArray(), StandardCharsets.UTF_8); - int chunks = (trace.length() + 999) / 1000; - for (int i = 0; i < chunks; i++) DebugLogStore.event(event().observed("chunk", i).observed("chunkCount", chunks) - .message(trace.substring(i * 1000, Math.min(trace.length(), (i + 1) * 1000)))); - } - } catch (Exception error) { - DebugLogStore.event(event().unknown("traceAvailable", DiagnosticEvent.Status.READ_ERROR) - .observed("javaClass", error.getClass().getSimpleName())); - } } @RequiresApi(Build.VERSION_CODES.R) diff --git a/app/src/main/java/com/fongmi/android/tv/utils/PushParser.java b/app/src/main/java/com/fongmi/android/tv/utils/PushParser.java index d4312e183e..540d082902 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/PushParser.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/PushParser.java @@ -8,8 +8,6 @@ public final class PushParser { private static final Pattern PUSH_URL = Pattern.compile("(https?|thunder|magnet|ed2k|video):\\S+"); private static final Pattern COMMON_HEADER = Pattern.compile("(?i)^(user-agent|referer|referrer|cookie|origin|authorization|range|accept|content-type)\\s*[:=].+"); - private static final Pattern NETWORK_LOCATION = Pattern.compile("(?i)^(https?|file|content|smb|nfs|ftp|rtsp|video)://\\S+$"); - private static final Pattern WINDOWS_PATH = Pattern.compile("(?i)^[a-z]:[\\\\/].+"); private PushParser() { } @@ -97,21 +95,5 @@ public final class PushParser { public String getName() { return title.isEmpty() ? url : title; } - - public boolean hasExplicitTitle() { - return !title.isEmpty(); - } - - public boolean shouldSkipAutoTmdbMatch() { - return !hasExplicitTitle() && isObviousMediaLocation(getName()); - } - - private static boolean isObviousMediaLocation(String value) { - String location = clean(value); - return NETWORK_LOCATION.matcher(location).matches() - || location.startsWith("/") - || location.startsWith("\\\\") - || WINDOWS_PATH.matcher(location).matches(); - } } } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/ResUtil.java b/app/src/main/java/com/fongmi/android/tv/utils/ResUtil.java index 3851c81abf..4a135a1253 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/ResUtil.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/ResUtil.java @@ -18,7 +18,6 @@ import android.view.animation.AnimationUtils; import androidx.annotation.AnimRes; import androidx.annotation.ArrayRes; import androidx.annotation.ColorRes; -import androidx.annotation.DimenRes; import androidx.annotation.DrawableRes; import androidx.annotation.StringRes; import androidx.core.content.ContextCompat; @@ -91,20 +90,6 @@ public class ResUtil { return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getDisplayMetrics()); } - /** - * The dp value a dimension resource resolves to. - * - *

TV row presenters take a dp spacing and convert it themselves - * ({@code CustomRowPresenter} calls {@link #dp2px(int)}), so a call site that must read a - * design token instead of a literal needs the dp value. Deriving it from the resource keeps - * the token authoritative on every density, instead of a second hardcoded number drifting - * away from it. - */ - public static int dpOf(@DimenRes int resId) { - float density = getDisplayMetrics().density; - return density <= 0f ? 0 : Math.round(App.get().getResources().getDimension(resId) / density); - } - public static String getString(@StringRes int resId) { return App.get().getResources().getString(resId); } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/SearchSourceVisibility.java b/app/src/main/java/com/fongmi/android/tv/utils/SearchSourceVisibility.java index 014b8c5363..d44f859d42 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/SearchSourceVisibility.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/SearchSourceVisibility.java @@ -5,7 +5,11 @@ public final class SearchSourceVisibility { private SearchSourceVisibility() { } - public static boolean shouldShow(boolean hasVisibleResults) { - return hasVisibleResults; + /** + * Keeps configured empty source rows only when filtering is disabled. Once a similarity filter + * is active, a source appears dynamically as soon as it has at least one visible match. + */ + public static boolean shouldShow(int similarity, boolean fixedOrder, boolean hasVisibleResults) { + return hasVisibleResults || (similarity <= 0 && fixedOrder); } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/fongmi/android/tv/utils/TmdbDetailCache.java b/app/src/main/java/com/fongmi/android/tv/utils/TmdbDetailCache.java index 18968e63e9..0a2d35418e 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/TmdbDetailCache.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/TmdbDetailCache.java @@ -2,17 +2,13 @@ package com.fongmi.android.tv.utils; import android.text.TextUtils; -import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.bean.TmdbPerson; -import com.github.catvod.crawler.SpiderDebug; import com.google.gson.JsonObject; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -24,41 +20,18 @@ public class TmdbDetailCache { private static final AtomicLong NEXT_ID = new AtomicLong(); private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); - public static String put(TmdbItem item, JsonObject detail, List cast, String language) { - return put(item, detail, cast, language, null); - } - public static String put(TmdbItem item, JsonObject detail, List cast) { - return put(item, detail, cast, ""); - } - - /** - * 详情页已经解析好的季集数据随详情快照一起交给播放页。 - * - *

播放页的选集卡片模式由 {@code Episode.tmdbEpisode} 决定,而该字段只有播放页 - * 自己再取一次分季数据才会被写入。详情页在跳转前已经取过同一季(否则它自己也画不出 - * 卡片),让播放页复用这份数据可以消除第二次网络请求,避免请求失败时详情页有卡片、 - * 播放页却退化成纯文本按钮。 - */ - public static String put(TmdbItem item, JsonObject detail, List cast, String language, - Map> seasonEpisodes) { if (item == null || item.getTmdbId() <= 0 || detail == null) return ""; if (CACHE.size() >= MAX_PENDING) CACHE.clear(); String key = "tmdb_detail_" + NEXT_ID.incrementAndGet(); - CACHE.put(key, new Entry(item, detail, cast, TmdbLanguagePolicy.normalize(language), seasonEpisodes)); + CACHE.put(key, new Entry(item, detail, cast)); return key; } public static Entry take(String key, TmdbItem expected) { - return take(key, expected, ""); - } - - public static Entry take(String key, TmdbItem expected, String expectedLanguage) { if (TextUtils.isEmpty(key)) return null; Entry entry = CACHE.remove(key); - boolean rejected = entry != null && !entry.matches(expected, expectedLanguage); - if (rejected) SpiderDebug.log("tmdb-language-cache", "snapshotLanguage=%s target=%s result=reject", entry.getLanguage(), expectedLanguage); - if (entry == null || rejected) return null; + if (entry == null || !entry.matches(expected)) return null; return entry; } @@ -70,38 +43,11 @@ public class TmdbDetailCache { private final TmdbItem item; private final JsonObject detail; private final List cast; - private final String language; - private final Map> seasonEpisodes; - private Entry(TmdbItem item, JsonObject detail, List cast, String language, - Map> seasonEpisodes) { + private Entry(TmdbItem item, JsonObject detail, List cast) { this.item = item; this.detail = detail; this.cast = cast == null ? new ArrayList<>() : new ArrayList<>(cast); - this.language = language == null ? "" : language; - this.seasonEpisodes = copySeasonEpisodes(seasonEpisodes); - } - - /** - * 详情页已解析的季集数据(季号 -> 该季分集),没有时为 null。 - * 与 {@link #getCast()} 一致做防御性拷贝:快照一旦交给播放页就不应再受详情页改动影响。 - */ - public Map> getSeasonEpisodes() { - return copySeasonEpisodes(seasonEpisodes); - } - - private static Map> copySeasonEpisodes(Map> source) { - if (source == null || source.isEmpty()) return null; - Map> copy = new LinkedHashMap<>(); - for (Map.Entry> entry : source.entrySet()) { - if (entry.getKey() == null || entry.getKey() < 0 || entry.getValue() == null || entry.getValue().isEmpty()) continue; - copy.put(entry.getKey(), List.copyOf(entry.getValue())); - } - return copy.isEmpty() ? null : copy; - } - - public String getLanguage() { - return language; } public TmdbItem getItem() { @@ -116,10 +62,9 @@ public class TmdbDetailCache { return new ArrayList<>(cast); } - private boolean matches(TmdbItem expected, String expectedLanguage) { + private boolean matches(TmdbItem expected) { if (expected == null || item == null) return false; - if (item.getTmdbId() != expected.getTmdbId() || !normalize(item.getMediaType()).equals(normalize(expected.getMediaType()))) return false; - return TmdbLanguagePolicy.matchesTarget(expectedLanguage, language); + return item.getTmdbId() == expected.getTmdbId() && normalize(item.getMediaType()).equals(normalize(expected.getMediaType())); } private String normalize(String value) { diff --git a/app/src/main/java/com/fongmi/android/tv/utils/TmdbEpisodeSorter.java b/app/src/main/java/com/fongmi/android/tv/utils/TmdbEpisodeSorter.java index e6dd763b71..6ccb90e944 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/TmdbEpisodeSorter.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/TmdbEpisodeSorter.java @@ -26,39 +26,33 @@ public final class TmdbEpisodeSorter { } public static void sort(Flag flag) { - if (flag == null || flag.getEpisodes() == null) return; - List episodes = flag.getEpisodes(); - synchronized (episodes) { - if (episodes.size() < 2) return; - List indexed = new ArrayList<>(); - int recognized = 0; - int recognizedWithSeason = 0; - for (int i = 0; i < episodes.size(); i++) { - Episode episode = episodes.get(i); - int season = sourceSeasonNumber(episode); - int number = number(episode); - if (number > 0) { - recognized++; - if (season > 0) recognizedWithSeason++; - } - indexed.add(new IndexedEpisode(episode, season, number, i)); + if (flag == null || flag.getEpisodes() == null || flag.getEpisodes().size() < 2) return; + List indexed = new ArrayList<>(); + int recognized = 0; + int recognizedWithSeason = 0; + for (int i = 0; i < flag.getEpisodes().size(); i++) { + Episode episode = flag.getEpisodes().get(i); + int season = sourceSeasonNumber(episode); + int number = number(episode); + if (number > 0) { + recognized++; + if (season > 0) recognizedWithSeason++; } - if (recognized < 2) return; - // Use one strategy for the whole flag so the comparator remains transitive. - boolean useSeason = recognized == recognizedWithSeason; - Comparator comparator = (left, right) -> compare(left, right, useSeason); - if (isSorted(indexed, comparator)) return; - try { - indexed.sort(comparator); - } catch (IllegalArgumentException e) { - SpiderDebug.log("tmdb-episode-sort", "keep source order after sort failure: %s", e.getMessage()); - return; - } - // Reorder in place instead of clear()+add(). The episode list is shared with the UI; - // structural replacement increments ArrayList.modCount and can invalidate a UI iterator - // while SourceEpisodeSeasonCache resolves the current season on the main thread. - for (int i = 0; i < indexed.size(); i++) episodes.set(i, indexed.get(i).episode()); + indexed.add(new IndexedEpisode(episode, season, number, i)); } + if (recognized < 2) return; + // Use one strategy for the whole flag so the comparator remains transitive. + boolean useSeason = recognized == recognizedWithSeason; + Comparator comparator = (left, right) -> compare(left, right, useSeason); + if (isSorted(indexed, comparator)) return; + try { + indexed.sort(comparator); + } catch (IllegalArgumentException e) { + SpiderDebug.log("tmdb-episode-sort", "keep source order after sort failure: %s", e.getMessage()); + return; + } + flag.getEpisodes().clear(); + for (IndexedEpisode item : indexed) flag.getEpisodes().add(item.episode()); } private static boolean isSorted(List episodes, Comparator comparator) { diff --git a/app/src/main/java/com/fongmi/android/tv/utils/TmdbImageSelector.java b/app/src/main/java/com/fongmi/android/tv/utils/TmdbImageSelector.java index 56510a58b5..8754e01322 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/TmdbImageSelector.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/TmdbImageSelector.java @@ -90,44 +90,6 @@ public class TmdbImageSelector { return url.replaceFirst("/(w\\d+|h\\d+|original)/", "/original/"); } - /** - * 把 TMDB 图片地址换成官方档位(如 {@code w500});非 TMDB {@code /t/p/} 路径原样返回。 - *

- * 只使用官方 OAS 声明过的档位:poster {@code w92/w154/w185/w342/w500/w780/original}、 - * backdrop {@code w300/w780/w1280/original}、still {@code w92/w185/w300/original}。 - * 代理包装线路(wsrv/itv666)把原始地址放在 query 里,包含 {@code /t/p/} 时同样可替换; - * 编码过或无法识别时返回原地址,宁可不优化也不破图。 - */ - public static String tierUrl(String url, String size) { - if (isEmpty(url) || isEmpty(size)) return isEmpty(url) ? "" : url; - int marker = url.indexOf("/t/p/"); - if (marker < 0) return url; - int sizeStart = marker + "/t/p/".length(); - int sizeEnd = url.indexOf('/', sizeStart); - if (sizeEnd <= sizeStart) return url; - return url.substring(0, sizeStart) + size.trim() + url.substring(sizeEnd); - } - - /** 详情页 rail 的海报缩略图档位(实测原图 1,187,694B → w500 85,680B)。 */ - public static String posterTier(String url) { - return tierUrl(url, "w500"); - } - - /** 详情页 rail 的图集/剧照缩略图档位(backdrop 官方档位 w780)。 */ - public static String photoTier(String url) { - return tierUrl(url, "w780"); - } - - /** - * 详情页 hero 海报档位(poster 官方档位 {@code w780})。 - *

- * hero 海报显示尺寸远小于原图,实测 original 1,187,694B / w780 201,063B(−83%), - * 解码后按显示尺寸缩放,画质无可见差异。 - */ - public static String heroPosterTier(String url) { - return tierUrl(url, "w780"); - } - private static void addImages(List candidates, JsonObject detail, String key, String base, Orientation fallback) { JsonArray images = array(detail, "images", key); for (JsonElement element : images) { @@ -156,7 +118,7 @@ public class TmdbImageSelector { private static String image(String base, String path) { if (isEmpty(path)) return ""; String normalizedBase = originalBase(base); - return TmdbProxy.imageUrl(normalizedBase, path); + return normalizedBase + (path.startsWith("/") ? path : "/" + path); } private static String originalBase(String base) { diff --git a/app/src/main/java/com/fongmi/android/tv/utils/TmdbLanguagePolicy.java b/app/src/main/java/com/fongmi/android/tv/utils/TmdbLanguagePolicy.java deleted file mode 100644 index d4fa980282..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/TmdbLanguagePolicy.java +++ /dev/null @@ -1,195 +0,0 @@ -package com.fongmi.android.tv.utils; - -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -/** Pure language policy for TMDB display fields. */ -public final class TmdbLanguagePolicy { - - public static final String DEFAULT_LANGUAGE = "zh-CN"; - - public record TranslatedValue(String value, String language, boolean exact) { - public static final TranslatedValue EMPTY = new TranslatedValue("", "", false); - - public boolean isEmpty() { - return TextUtils.isEmpty(value); - } - } - - private TmdbLanguagePolicy() { - } - - /** - * 只看配置的 TMDB 请求语言。 - * - *

本类是纯策略(不依赖 Android),这条重载只反映用户配置,不会跟随界面语言。 - * 任何「真正发请求」或「写读按语言分键的缓存」的调用点都必须用 - * {@link #requestLanguage(TmdbConfig, int)} 并传入 {@code Setting.getLanguage()}: - * 请求语言与缓存键一旦不同源,繁体界面取回的 zh-TW 数据会被标成 zh-CN 落盘, - * 切回简体后语言守卫 {@link #matchesTarget} 认不出这份「其实是繁体」的快照。 - */ - public static String requestLanguage(@Nullable TmdbConfig config) { - return normalize(config == null ? null : config.getLanguage()); - } - - /** - * TMDB 请求语言:用户显式配置过就用用户的,否则跟随界面语言的中文变体。 - * - *

为什么需要区分「显式」:{@code TmdbConfig.language} 经 sanitize 后总是非空(缺省 - * {@code zh-CN}),单看值无法判断用户是否表达过偏好。直接无条件跟随界面语言会**覆盖 - * 用户显式选择**(例如用户故意「繁体界面 + 简体 TMDB 数据」)。 - * - *

只在界面语言是中文时才跟随:英文/其它语言界面下 TMDB 仍按用户配置返回, - * 避免把中文界面偏好意外带进非中文场景。 - */ - public static String requestLanguage(@Nullable TmdbConfig config, int uiLanguage) { - String configured = requestLanguage(config); - if (config != null && config.isLanguageConfigured()) return configured; - String ui = languageForInterface(uiLanguage); - return TextUtils.isEmpty(ui) ? configured : ui; - } - - /** - * 界面语言 → TMDB 语言。只处理中文变体;其它情况返回空串表示「不干预」。 - * - *

{@code 1}/{@code 2} 是 app 侧 {@code Setting.LANGUAGE_SIMPLIFIED} / - * {@code LANGUAGE_TRADITIONAL} 的取值。这里刻意不直接依赖 Setting,保持本类为纯策略, - * 便于在 JVM 单测里直接验证。 - */ - @Nullable - public static String languageForInterface(int uiLanguage) { - if (uiLanguage == 1) return "zh-CN"; - if (uiLanguage == 2) return "zh-TW"; - return ""; - } - - public static String normalize(@Nullable String language) { - String value = language == null ? "" : language.trim(); - if (TextUtils.isEmpty(value)) return DEFAULT_LANGUAGE; - String lower = value.toLowerCase(Locale.ROOT); - if ("zh-hans".equals(lower) || "zh-hans-cn".equals(lower) || "zh-cn".equals(lower) || "zh-sg".equals(lower) || "zh-my".equals(lower)) return "zh-CN"; - if ("zh-hant".equals(lower) || "zh-hant-tw".equals(lower) || "zh-tw".equals(lower) || "zh-hk".equals(lower) || "zh-mo".equals(lower)) return "zh-TW"; - String[] parts = lower.split("[-_]", 2); - if (parts.length == 2) return parts[0] + "-" + parts[1].toUpperCase(Locale.ROOT); - return lower.toUpperCase(Locale.ROOT); - } - - public static String root(@Nullable String language) { - String normalized = normalize(language); - int separator = normalized.indexOf('-'); - return separator > 0 ? normalized.substring(0, separator) : normalized; - } - - public static boolean isChinese(@Nullable String language) { - return "ZH".equals(root(language)); - } - - public static boolean matchesTarget(@Nullable String target, @Nullable String candidateLanguage) { - if (TextUtils.isEmpty(target)) return TextUtils.isEmpty(candidateLanguage); - String expected = normalize(target); - String candidate = normalize(candidateLanguage); - if (TextUtils.isEmpty(candidate)) return false; - // zh and en are root-only languages and may satisfy zh-CN/en-US snapshots. - // Regional variants such as zh-TW must not satisfy zh-CN; they remain a - // lower-priority fallback in bestValue rather than a compatible identity. - return expected.equalsIgnoreCase(candidate) || candidate.equalsIgnoreCase(root(expected)); - } - - public static boolean isDisplayLanguageCompatible(@Nullable String target, @Nullable String payloadLanguage) { - return matchesTarget(target, payloadLanguage); - } - - public static boolean looksTargetLanguage(@Nullable String target, @Nullable String value) { - String text = value == null ? "" : value.trim(); - if (text.isEmpty()) return false; - if (!isChinese(target)) return true; - for (int i = 0; i < text.length(); i++) { - char ch = text.charAt(i); - if (ch >= 0x3400 && ch <= 0x9FFF || ch >= 0xF900 && ch <= 0xFAFF || ch >= 0x20000 && ch <= 0x3FFFF) return true; - } - return false; - } - - public static TranslatedValue bestValue(@Nullable String primary, @Nullable JsonArray translations, - @NonNull String valueKey, @Nullable String targetLanguage) { - String target = normalize(targetLanguage); - Map ranked = new LinkedHashMap<>(); - for (Candidate candidate : translationCandidates(translations, valueKey)) { - int score = score(target, candidate.language()); - if (score >= 0) ranked.putIfAbsent(score, new TranslatedValue(candidate.value(), candidate.language(), score <= 2)); - } - if (!TextUtils.isEmpty(primary)) { - boolean primaryMatches = !isChinese(target) || looksTargetLanguage(target, primary); - int score = primaryMatches ? 0 : 4; - ranked.putIfAbsent(score, new TranslatedValue(primary.trim(), target, primaryMatches)); - } - for (int score = 0; score <= 4; score++) { - TranslatedValue value = ranked.get(score); - if (value != null && !value.isEmpty()) return value; - } - return TranslatedValue.EMPTY; - } - - public static String bestDisplayValue(@Nullable String primary, @Nullable JsonArray translations, - @NonNull String valueKey, @Nullable String targetLanguage) { - return bestValue(primary, translations, valueKey, targetLanguage).value(); - } - - private static List translationCandidates(@Nullable JsonArray translations, String valueKey) { - List result = new ArrayList<>(); - if (translations == null) return result; - for (JsonElement element : translations) { - if (element == null || !element.isJsonObject()) continue; - JsonObject translation = element.getAsJsonObject(); - JsonObject data = translation.has("data") && translation.get("data").isJsonObject() ? translation.getAsJsonObject("data") : null; - String value = string(data, valueKey); - if (TextUtils.isEmpty(value)) continue; - String language = firstNonEmpty(string(translation, "iso_639_1"), string(translation, "language")); - if (TextUtils.isEmpty(language)) continue; - String country = string(translation, "iso_3166_1"); - String full = TextUtils.isEmpty(country) ? language : language + "-" + country; - result.add(new Candidate(value.trim(), full)); - } - return result; - } - - private static int score(String target, String candidate) { - String expected = normalize(target); - String actual = normalize(candidate); - if (expected.equalsIgnoreCase(actual)) return 0; - String expectedRoot = root(expected); - String actualRoot = root(actual); - if (expectedRoot.equalsIgnoreCase(actualRoot)) return 1; - if (isChinese(expected) && isChinese(actual)) return 2; - if ("EN".equalsIgnoreCase(actualRoot)) return 4; - if (!TextUtils.isEmpty(actualRoot)) return 3; - return -1; - } - - private static String firstNonEmpty(String... values) { - for (String value : values) if (!TextUtils.isEmpty(value)) return value; - return ""; - } - - private static String string(JsonObject object, String key) { - if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive() || !object.get(key).getAsJsonPrimitive().isString()) return ""; - String value = object.get(key).getAsString(); - return value == null ? "" : value.trim(); - } - - private record Candidate(String value, String language) { - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/TmdbProxy.java b/app/src/main/java/com/fongmi/android/tv/utils/TmdbProxy.java deleted file mode 100644 index 9d700af3eb..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/utils/TmdbProxy.java +++ /dev/null @@ -1,392 +0,0 @@ -package com.fongmi.android.tv.utils; - -import java.net.URI; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.regex.Pattern; - -/** TMDB API 与图片线路的地址归一化和内置线路定义。 */ -public final class TmdbProxy { - - public static final String OFFICIAL_API = "https://api.tmdb.org"; - public static final String OFFICIAL_IMAGE = "https://images.tmdb.org"; - public static final String AUTO = "auto"; - /** 实测 API 与 /t/p 图片路径均可用的公共镜像。 */ - public static final String ITV666 = "http://tmdb.itv666.cc"; - /** wsrv.nl 将原始图片地址放在 url 查询参数中。 */ - public static final String WSRV_IMAGE = "https://wsrv.nl/?url=https://image.tmdb.org"; - - private static final String REMOVED_WORKER_POOL = "worker-pool"; - private static final String REMOVED_NASTOOL = "https://tmdb.nastool.org"; - private static final Pattern POOL_SEPARATOR = Pattern.compile("[,,;;\\s]+"); - - private static final List

重新扫描复用 {@link #update(File)}:它是本页**唯一**的列表填充入口,既重算 - * {@link #list(File)} 又驱动状态机,所以"扫描后仍为空"会正确地停在空态而不是留白。 - */ - private void setEmptyContent() { - mBinding.progressLayout.setEmptyContent( - R.string.file_empty_title, - R.string.file_empty_description, - R.string.file_empty_action, - view -> rescan()); - } - - /** 重新扫描当前目录;尚未拿到目录时(权限回调之前)退回根目录。 */ - private void rescan() { - update(dir == null ? Path.root() : dir); - } - private void setRecyclerView() { mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setAdapter(mAdapter = new FileAdapter(this)); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java index 1afce9e86c..b247575828 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java @@ -21,11 +21,11 @@ import com.fongmi.android.tv.databinding.ActivityHistoryBinding; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.HistoryAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.SyncDialog; import com.fongmi.android.tv.utils.MobileWindow; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; @@ -53,39 +53,15 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl @Override protected void initView(Bundle savedInstanceState) { setSupportActionBar(mBinding.toolbar); - setEmptyContent(); setRecyclerView(); getHistory(); } - /** - * 空态内容:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 - * - *

历史页与收藏页一样,新用户第一次进来时必为空。改动前只有一句 - * {@code error_empty}(「这里什麽都没有 👀」),既不解释为什么空,也不给出下一步。 - */ - private void setEmptyContent() { - mBinding.progressLayout.setEmptyContent( - R.string.history_empty_title, - R.string.history_empty_description, - R.string.history_empty_action, - view -> goHome()); - } - - /** 空态主按钮:回点播首页去挑一部。与收藏页/播放页的回首页写法一致。 */ - private void goHome() { - startActivity(new Intent(this, HomeActivity.class).addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); - } - private void setRecyclerView() { int column = MobileWindow.isWide(this) ? Product.getColumn(this) : 3; mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setLayoutManager(new GridLayoutManager(this, column)); mBinding.recycler.setAdapter(mAdapter = new HistoryAdapter(this)); - // 列表数量变化后统一驱动状态机(BaseDiffAdapter 的钩子):注册一次,之后无论从哪条 - // 路径改动列表(单条删除、删除全部、刷新)状态都会跟着列表走,宿主不可能再忘。 - mAdapter.setOnCountChanged(count -> mBinding.progressLayout.showContent(true, count)); mBinding.recycler.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { @@ -100,6 +76,7 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl private void getHistory() { mAdapter.setItems(History.getForDisplay(), (hasChange) -> { + mBinding.progressLayout.showContent(true, mAdapter.getItemCount()); if (hasChange) mBinding.recycler.scrollToPosition(0); mBinding.recycler.post(this::updateMarquee); }); @@ -139,7 +116,7 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl private void onDelete() { if (mAdapter.isDelete()) { - new WebHtvAlertDialogBuilder(this).setTitle(R.string.dialog_delete_record).setMessage(Setting.isGlobalHistoryEnabled() ? R.string.dialog_delete_global_history : R.string.dialog_delete_history).setNegativeButton(R.string.dialog_negative, null).setPositiveButton(R.string.dialog_positive, (dialog, which) -> mAdapter.clear()).show(); + new MaterialAlertDialogBuilder(this).setTitle(R.string.dialog_delete_record).setMessage(Setting.isGlobalHistoryEnabled() ? R.string.dialog_delete_global_history : R.string.dialog_delete_history).setNegativeButton(R.string.dialog_negative, null).setPositiveButton(R.string.dialog_positive, (dialog, which) -> mAdapter.clear()).show(); } else if (mAdapter.getItemCount() > 0) { mAdapter.setDelete(true); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HomeActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HomeActivity.java index 102992f67c..cae76e7567 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HomeActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HomeActivity.java @@ -29,9 +29,6 @@ import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.event.ServerEvent; import com.fongmi.android.tv.event.StateEvent; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingScheduler; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.lab.LabActivity; import com.fongmi.android.tv.lab.LabConfig; @@ -53,7 +50,6 @@ import com.fongmi.android.tv.ui.fragment.SettingPersonalFragment; import com.fongmi.android.tv.ui.fragment.SettingPlayerFragment; import com.fongmi.android.tv.ui.fragment.SettingSubtitleFragment; import com.fongmi.android.tv.ui.fragment.VodFragment; -import com.fongmi.android.tv.utils.CrashRestartMode; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.MobileWindow; import com.fongmi.android.tv.utils.Notify; @@ -69,7 +65,7 @@ import com.google.gson.JsonObject; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; -public class HomeActivity extends BaseActivity implements NavigationBarView.OnItemSelectedListener, WebHomeChromeController.Host, VodFragment.ConfigRetry { +public class HomeActivity extends BaseActivity implements NavigationBarView.OnItemSelectedListener, WebHomeChromeController.Host { public static final String EXTRA_NAV_POSITION = "nav_position"; private static final String STATE_RETURN_VOD_FROM_ENHANCE = "returnVodFromEnhance"; @@ -91,7 +87,6 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); - setIntent(intent); checkAction(intent); } @@ -110,21 +105,15 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt mChrome = new WebHomeChromeController(this, mBinding, this, savedInstanceState, WebHomeChromeStartup.restore(mStartupConfig)); mBinding.getRoot().addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> checkWindowShape(right - left, bottom - top)); mBinding.navigation.setOnItemSelectedListener(this); - setNavigation(); PermissionUtil.requestFile(this, allGranted -> PermissionUtil.requestNotify(this)); - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); initFragment(savedInstanceState); - if (savedInstanceState == null && getIntent().hasExtra(EXTRA_NAV_POSITION)) checkAction(getIntent()); initConfig(); } @Override protected void onResume() { super.onResume(); - if (mBinding.navigation.getMenu().findItem(R.id.lab).isVisible() != LabConfig.get().getNavEntry() - || mBinding.navigation.getMenu().findItem(R.id.following).isVisible() != FollowingSettings.isEnabled()) setNavigation(); - updateFollowingBadge(); + if (mBinding.navigation.getMenu().findItem(R.id.lab).isVisible() != LabConfig.get().getNavEntry()) setNavigation(); } @Override @@ -187,29 +176,11 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt } private void initConfig() { - if (CrashRestartMode.consume()) { - checkAction(getIntent()); - StateEvent.empty(); - return; - } VodConfig.get().config(mStartupConfig == null ? Config.vod() : mStartupConfig).load(getCallback()); LiveConfig.get().init().load(); WallConfig.get().init(); } - /** - * 首页错误态的重试动作。 - * - *

失败的那一步是这里的**内容源配置加载**({@link #initConfig()}),所以重试必须回到它—— - * 由 VodFragment 通过 {@link VodFragment.ConfigRetry} 回调过来。重试期间先把状态浮层切回 - * 加载态,避免错误态一直停在屏幕上。 - */ - @Override - public void retryConfig() { - StateEvent.progress(); - initConfig(); - } - private Callback getCallback() { return new Callback() { @Override @@ -217,29 +188,11 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt checkAction(getIntent()); } - /** - * 还没有配置任何内容源(重置应用、重装或恢复出厂后的首次启动)。 - * - *

这不是失败:没有地址可请求、没有失败原因、重试也永远不会成功。改动前 - * {@code BaseConfig} 把它当 {@code error("")} 抛出来,首页便显示「内容源加载失败 / - * 重试」——一个不存在的故障加一个点了没用的按钮。这里改发「未配置」状态: - * 不接管首页,由 {@code VodFragment} 收起状态浮层、保留底部导航;添加接口仍走 - * 既有的「设置 → 点播」管理面板({@code HistoryDialog} 的「添加」)。 - */ - @Override - public void noConfig() { - resetVodChrome(); - checkAction(getIntent()); - StateEvent.noConfig(); - } - @Override public void error(String msg) { resetVodChrome(); checkAction(getIntent()); - // 失败要发 ERROR(带原因),不能发 EMPTY:消费方对 EMPTY 的处理只是 - // "隐藏转圈",于是加载失败后首页是一片空白,用户只看到一个会消失的吐司。 - StateEvent.error(msg); + StateEvent.empty(); Notify.show(msg); } }; @@ -259,8 +212,6 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt mBinding.navigation.getMenu().findItem(R.id.setting).setVisible(true); mBinding.navigation.getMenu().findItem(R.id.lab).setVisible(LabConfig.get().getNavEntry()); mBinding.navigation.getMenu().findItem(R.id.live).setVisible(LiveConfig.hasUrl()); - mBinding.navigation.getMenu().findItem(R.id.following).setVisible(FollowingSettings.isEnabled()); - updateFollowingBadge(); syncNavigationSelection(); } @@ -311,6 +262,10 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt } } + @Subscribe(threadMode = ThreadMode.MAIN) + public void onRefreshEvent(RefreshEvent event) { + if (event.getType() == RefreshEvent.Type.THEME) recreate(); + } @Subscribe(threadMode = ThreadMode.MAIN) public void onServerEvent(ServerEvent event) { @@ -329,35 +284,9 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt } if (item.getItemId() == R.id.vod) return changeFragment(0); if (item.getItemId() == R.id.live) return openLive(); - if (item.getItemId() == R.id.following) { - FollowingActivity.start(this, null); - return false; - } return false; } - private void updateFollowingBadge() { - if (mBinding == null || mBinding.navigation.getMenu().findItem(R.id.following) == null) return; - if (!FollowingSettings.isEnabled()) { - mBinding.navigation.removeBadge(R.id.following); - return; - } - applyFollowingBadge(FollowingPlaybackBridge.cachedUnreadCount()); - FollowingPlaybackBridge.refreshUnreadCountAsync(unread -> { - if (!isFinishing() && !isDestroyed()) applyFollowingBadge(unread); - }); - } - - private void applyFollowingBadge(int unread) { - if (mBinding == null || mBinding.navigation.getMenu().findItem(R.id.following) == null) return; - if (!FollowingSettings.isEnabled()) { - mBinding.navigation.removeBadge(R.id.following); - return; - } - if (unread > 0) mBinding.navigation.getOrCreateBadge(R.id.following).setNumber(unread); - else mBinding.navigation.removeBadge(R.id.following); - } - private void selectNavigation(int position) { int itemId = position == 0 ? R.id.vod : R.id.setting; if (mBinding.navigation.getSelectedItemId() == itemId) changeFragment(position); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/KeepActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/KeepActivity.java index 5569d89b85..d1e645c5e4 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/KeepActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/KeepActivity.java @@ -22,11 +22,11 @@ import com.fongmi.android.tv.bean.Keep; import com.fongmi.android.tv.databinding.ActivityKeepBinding; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.impl.Callback; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.KeepAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.SyncDialog; import com.fongmi.android.tv.utils.Notify; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; @@ -54,38 +54,14 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override protected void initView(Bundle savedInstanceState) { setSupportActionBar(mBinding.toolbar); - setEmptyContent(); setRecyclerView(); getKeep(); } - /** - * 空态内容:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 - * - *

收藏页是最典型的空态——用户第一次进来时列表必为空。改动前只有一句 - * {@code error_empty}(「这里什麽都没有 👀」),既不解释为什么空,也不给出下一步。 - */ - private void setEmptyContent() { - mBinding.progressLayout.setEmptyContent( - R.string.keep_empty_title, - R.string.keep_empty_description, - R.string.keep_empty_action, - view -> goHome()); - } - - /** 空态主按钮:回点播首页去挑一部。与 LiveActivity/VideoActivity 的回首页写法一致。 */ - private void goHome() { - startActivity(new Intent(this, HomeActivity.class).addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); - } - private void setRecyclerView() { mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setLayoutManager(new GridLayoutManager(this, Product.getColumn(this))); mBinding.recycler.setAdapter(mAdapter = new KeepAdapter(this)); - // 列表数量变化后统一驱动状态机(BaseDiffAdapter 的钩子):注册一次,之后无论从哪条 - // 路径改动列表(单条删除、删除全部、刷新)状态都会跟着列表走,宿主不可能再忘。 - mAdapter.setOnCountChanged(count -> mBinding.progressLayout.showContent(true, count)); mBinding.recycler.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { @@ -100,6 +76,7 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis private void getKeep() { mAdapter.setItems(Keep.getVod(), () -> { + mBinding.progressLayout.showContent(true, mAdapter.getItemCount()); mBinding.recycler.post(this::updateMarquee); }); } @@ -138,7 +115,7 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis private void onDelete() { if (mAdapter.isDelete()) { - new WebHtvAlertDialogBuilder(this).setTitle(R.string.dialog_delete_record).setMessage(R.string.dialog_delete_keep).setNegativeButton(R.string.dialog_negative, null).setPositiveButton(R.string.dialog_positive, (dialog, which) -> mAdapter.clear()).show(); + new MaterialAlertDialogBuilder(this).setTitle(R.string.dialog_delete_record).setMessage(R.string.dialog_delete_keep).setNegativeButton(R.string.dialog_negative, null).setPositiveButton(R.string.dialog_positive, (dialog, which) -> mAdapter.clear()).show(); } else if (mAdapter.getItemCount() > 0) { mAdapter.setDelete(true); } @@ -154,17 +131,6 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override public void error(String msg) { Notify.show(msg); - // 设计文档 §9.9:错误态要给出「错误色 + 原因 + 重试动作」。 - // 改动前这里只弹一个会消失的吐司,页面没有任何变化。重试复用同一条加载路径, - // 所以重试成功与首次成功的行为完全一致。 - mBinding.progressLayout.showError( - R.string.error_config_load, - msg, - R.string.error_retry, - view -> { - mBinding.progressLayout.showProgress(); - loadConfig(config, item); - }); } }); } @@ -185,8 +151,6 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override public void onItemDelete(Keep item) { mAdapter.remove(item.delete(), () -> { - // 状态由 BaseDiffAdapter 的 setOnCountChanged 钩子统一驱动,这里不再手写—— - // 「手写」正是此前漏掉删除全部那条路径的原因。 if (mAdapter.getItemCount() == 0) mAdapter.setDelete(false); mBinding.recycler.post(this::updateMarquee); }); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java index 94c84df1e6..a0d4b10284 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java @@ -63,7 +63,6 @@ import com.fongmi.android.tv.impl.LiveListener; import com.fongmi.android.tv.impl.PassListener; import com.fongmi.android.tv.model.LiveViewModel; import com.fongmi.android.tv.player.PlayerHelper; -import com.fongmi.android.tv.player.LiveSourceFallbackPolicy; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.Source; import com.fongmi.android.tv.player.VideoAspectMode; @@ -71,12 +70,8 @@ import com.fongmi.android.tv.playback.PlaybackOrientation; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.LiveEpgSetting; import com.fongmi.android.tv.setting.LiveSetting; -import com.fongmi.android.tv.setting.PlayerButtonSetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.CustomCspSetting; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.LiveListScrim; -import com.fongmi.android.tv.theme.PlaybackMenuScrim; import com.fongmi.android.tv.ui.adapter.ChannelAdapter; import com.fongmi.android.tv.ui.helper.PipExitDecision; import com.fongmi.android.tv.ui.adapter.EpgDataAdapter; @@ -85,7 +80,6 @@ import com.fongmi.android.tv.ui.custom.CustomKeyDown; import com.fongmi.android.tv.ui.custom.CustomSeekView; import com.fongmi.android.tv.ui.custom.PlayerOsdController; import com.fongmi.android.tv.ui.dialog.CastDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.ui.dialog.HistoryDialog; import com.fongmi.android.tv.ui.dialog.InfoDialog; import com.fongmi.android.tv.ui.dialog.LiveControlDialog; @@ -109,17 +103,14 @@ import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.util.ArrayList; -import java.util.HashMap; import java.util.Iterator; import java.util.List; -import java.util.Map; public class LiveActivity extends PlaybackActivity implements CustomKeyDown.Listener, TrackDialog.Listener, Biometric.Callback, PassListener, ConfigListener, LiveListener, GroupAdapter.OnClickListener, ChannelAdapter.OnClickListener, EpgDataAdapter.OnClickListener, CastDialog.Listener, InfoDialog.Listener, LiveControlDialog.Listener, LiveEpgDialog.Listener { private static final int LIVE_PIP_WIDTH = 16; private static final int LIVE_PIP_HEIGHT = 9; private static final long PLAYBACK_END_RETRY_DELAY = 500; - private static final long LIVE_BUFFERING_TIMEOUT = 15000; private static final String ORIENTATION_TAG = "LiveOrientation"; private ActivityLiveBinding mBinding; @@ -133,7 +124,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private PlayerOsdController mOsd; private Result mPendingStartResult; private List mHides; - private Map mActionButtons; private String mPlaybackKey; private String mPendingReloadUrl; private String mPendingReloadMsg; @@ -143,23 +133,8 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private Runnable mR2; private Runnable mR3; private Runnable mEndRetry; - private Runnable mBufferingTimeout; private boolean rotate; - /** - * 平板全屏内的方向覆盖:true 表示全屏时请求竖屏。 - * 直播的 {@link #rotate} 语义是「是否全屏」,与点播的「是否竖屏」不同, - * 因此用手动旋转必须用独立状态,避免破坏 {@link #isEmbeddedLiveUi()} 的判定。 - */ - private boolean livePortraitFullscreen; private int count; - private boolean mFailedThisSession; - /** - * 本轮播放失败自动换线的起点线路下标,配合 {@link #mLineFallbackExhausted} 使用。 - * -1 表示当前没有进行中的失败回退轮换;自动换线绕回该下标即本频道所有线路都已试过, - * 置位 exhausted 阻止 onError 立即开启新一轮死循环;手动换线/换台/换源或播放成功后重置。 - */ - private int mLineFallbackAnchor = -1; - private boolean mLineFallbackExhausted; private PiP mPiP; private boolean mKeepPlaybackAfterPipExit; private OneShotPreDrawListener pipEntryListener; @@ -177,7 +152,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private int groupBasePaddingBottom; private int channelBasePaddingBottom; private int epgBasePaddingBottom; - private int statusBarInset; public static void start(Context context) { context.startActivity(new Intent(context, LiveActivity.class).putExtra("empty", LiveConfig.isEmpty())); @@ -263,11 +237,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } } - @Override - protected boolean shouldAutoPlay() { - return true; - } - @Override protected void onCreate(Bundle savedInstanceState) { requestOrientation("launch", getLaunchOrient()); @@ -278,7 +247,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List @Override protected void initView(Bundle savedInstanceState) { super.initView(savedInstanceState); - applyPlaybackOverlay(); mKeyDown = CustomKeyDown.create(this, mBinding.exo); captureLiveListBasePadding(); setupWindowInsets(); @@ -289,7 +257,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mHides = new ArrayList<>(); mR1 = this::hideControl; mR2 = this::setTraffic; - mBufferingTimeout = this::onBufferingTimeout; mR3 = this::hideInfo; mEndRetry = this::checkNext; mPiP = new PiP(); @@ -304,34 +271,20 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List public String getTitle() { return mChannel == null ? "" : mChannel.getName(); } - - @Override - public boolean isFullscreen() { - // PiP 小窗不算全屏:媒体徽标只在真全屏显示。 - return !isEmbeddedLiveUi() && !isInPictureInPictureMode(); - } }, 12f); setVideoView(); setNavigation(); setViewModel(); applyPadLiveMode(); - setupActionButtons(); mBinding.exo.post(() -> applyLiveResizeMode(LiveSetting.getScale())); } - private void applyPlaybackOverlay() { - mBinding.control.getRoot().setBackgroundResource(R.color.transparent); - if (Setting.isPlaybackOverlayEnabled()) mBinding.control.bottom.setBackground(PlaybackMenuScrim.create(this)); - else mBinding.control.bottom.setBackgroundResource(R.color.transparent); - } - @Override @SuppressLint("ClickableViewAccessibility") protected void initEvent() { mBinding.control.back.setOnClickListener(view -> onBack()); mBinding.control.cast.setOnClickListener(view -> onCast()); mBinding.control.info.setOnClickListener(view -> onInfo()); - mBinding.control.osdDiagnostics.setOnClickListener(view -> onPlayParams()); mBinding.control.play.setOnClickListener(view -> checkPlay()); mBinding.control.next.setOnClickListener(view -> nextChannel()); mBinding.control.prev.setOnClickListener(view -> prevChannel()); @@ -352,7 +305,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mBinding.control.action.player.setOnClickListener(view -> onPlayerKernel()); mBinding.control.action.player.setOnLongClickListener(view -> onPlayerKernelLong()); mBinding.control.action.decode.setOnClickListener(view -> onDecode()); - mBinding.control.action.playParams.setOnClickListener(view -> onPlayParams()); mBinding.control.action.text.setOnLongClickListener(view -> onTextLong()); mBinding.control.action.speed.setOnLongClickListener(view -> onSpeedLong()); mBinding.control.action.getRoot().setOnTouchListener(this::onActionTouch); @@ -403,7 +355,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void setupWindowInsets() { ViewCompat.setOnApplyWindowInsetsListener(mBinding.getRoot(), (view, insets) -> { - updateStatusBarInset(insets); updateLiveListBottomInset(insets); return insets; }); @@ -442,42 +393,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mBinding.control.action.player.setText(player().getPlayerText()); } - /** - * 直播动作栏登记点播同名的播放器按钮,使「设置-播放-播放器按钮」的开关与排序对直播同样生效。 - * 直播专属按钮(配置、直播源、线路、反色、跨类)不登记,始终按布局显示。 - */ - private void setupActionButtons() { - mActionButtons = new HashMap<>(); - addActionButton(PlayerButtonSetting.PLAYER, mBinding.control.action.player); - addActionButton(PlayerButtonSetting.DECODE, mBinding.control.action.decode); - addActionButton(PlayerButtonSetting.PLAY_PARAMS, mBinding.control.action.playParams); - addActionButton(PlayerButtonSetting.SPEED, mBinding.control.action.speed); - addActionButton(PlayerButtonSetting.SCALE, mBinding.control.action.scale); - addActionButton(PlayerButtonSetting.CHANGE, mBinding.control.action.change); - addActionButton(PlayerButtonSetting.TEXT, mBinding.control.action.text); - addActionButton(PlayerButtonSetting.AUDIO, mBinding.control.action.audio); - addActionButton(PlayerButtonSetting.VIDEO, mBinding.control.action.video); - PlayerButtonSetting.applyOrder(mBinding.control.action.container, mActionButtons); - } - - private void addActionButton(String id, View view) { - mActionButtons.put(id, view); - } - - /** - * 先恢复无动态条件的按钮,再统一套用用户关闭项。 - * 顺序不能反过来:applyVisibility 只隐藏、不恢复,必须在条件判定之后执行。 - */ - private void applyActionButtonVisibility() { - if (mActionButtons == null) return; - mBinding.control.action.player.setVisibility(View.VISIBLE); - mBinding.control.action.decode.setVisibility(View.VISIBLE); - mBinding.control.action.playParams.setVisibility(View.VISIBLE); - mBinding.control.action.scale.setVisibility(View.VISIBLE); - mBinding.control.action.change.setVisibility(View.VISIBLE); - PlayerButtonSetting.applyVisibility(mActionButtons); - } - private void setScale(int scale) { LiveSetting.putScale(scale); applyLiveResizeMode(scale); @@ -536,7 +451,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } private void checkLive() { - if (LiveConfig.isEmpty()) { + if (isEmpty()) { LiveConfig.get().init().load(getCallback()); } else { getLive(); @@ -558,33 +473,13 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } private void getLive() { - Live live = getHome(); - setSource(live); - if (!live.getGroups().isEmpty()) renderLive(live); - mViewModel.parse(live); + renderLive(getHome()); + mViewModel.parse(getHome()); showProgress(); } - /** - * 控制栏的「直播源」按钮显示当前直播源名称:固定写「直播源」看不出此刻播的是哪一路源, - * 用户换源(含失效自动换源)都会重新走 {@link #getLive()} 刷新。名称为空时回退原文案。 - * 按钮文案被源名取代后读屏会只剩一个孤立的源名,因此同步补回「直播源」动作语义。 - */ - private void setSource(Live live) { - String name = live == null ? "" : live.getDisplayName(); - boolean named = !name.isEmpty(); - String label = named ? name : getString(R.string.live_control_source); - mBinding.control.action.source.setText(label); - mBinding.control.action.source.setContentDescription(named ? getString(R.string.live_control_source_named, name) : null); - refreshControlDialog(); - } - private void renderLive(Live live) { - if (live == null || live.getGroups().isEmpty()) { - if (LiveSetting.isSourceFallback()) startSourceFallback(); - return; - } - if (liveMenuRendered) return; + if (live == null || live.getGroups().isEmpty() || liveMenuRendered) return; liveMenuRendered = true; mViewModel.parseXml(live); setGroup(live); @@ -601,7 +496,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void setWidth(Live live) { if (isEmbeddedLiveUi()) return; int padding = ResUtil.dp2px(48); - if (live.getWidth() == 0) for (Group item : live.getGroups()) live.setWidth(Math.max(live.getWidth(), ResUtil.getTextWidth(item.getDisplayName(), 14))); + if (live.getWidth() == 0) for (Group item : live.getGroups()) live.setWidth(Math.max(live.getWidth(), ResUtil.getTextWidth(item.getName(), 14))); int width = live.getWidth() == 0 ? 0 : Math.min(live.getWidth() + padding, ResUtil.getScreenWidth() / 4); setWidth(mBinding.group, width); } @@ -696,24 +591,8 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void onRotate() { Log.i(ORIENTATION_TAG, "rotate clicked embedded=" + isEmbeddedLiveUi() + " " + orientationState()); - if (isEmbeddedLiveUi()) { - enterFullscreenLive(); - } else if (ResUtil.isPad()) { - toggleFullscreenOrientation(); - } else { - exitFullscreenLive(); - } - } - - /** - * 平板全屏内的旋转:只切换全屏方向(竖屏全屏↔横屏全屏),不退出全屏, - * 与点播 {@code VideoActivity#onRotate} 的语义保持一致。 - */ - private void toggleFullscreenOrientation() { - livePortraitFullscreen = !livePortraitFullscreen; - Log.i(ORIENTATION_TAG, "rotate fullscreen portrait=" + livePortraitFullscreen + " " + orientationState()); - setR1Callback(); - requestOrientation("rotate-fullscreen-pad", getFullscreenOrient()); + if (isEmbeddedLiveUi()) enterFullscreenLive(); + else exitFullscreenLive(); } private void onPiP() { @@ -786,7 +665,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } private void exitFullscreenLive() { - livePortraitFullscreen = false; setRotate(false); hideInfo(); hideControl(); @@ -799,8 +677,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } private int getFullscreenOrient() { - if (ResUtil.isPad()) return livePortraitFullscreen ? ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT : ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE; - return PlaybackOrientation.getEnterFullscreenOrientation(player().isPortrait()); + return ResUtil.isPad() ? ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE : PlaybackOrientation.getEnterFullscreenOrientation(player().isPortrait()); } private int getEmbeddedOrient() { @@ -884,12 +761,9 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void onSpeed() { if (!player().isVod()) return; - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || !player().isVod()) return; - mBinding.control.action.speed.setText(player().setSpeed(speed)); - PlayerSetting.putDefaultSpeed(player().getSpeed()); - setR1Callback(); - }); + mBinding.control.action.speed.setText(player().addSpeed()); + PlayerSetting.putDefaultSpeed(player().getSpeed()); + setR1Callback(); } private boolean onSpeedLong() { @@ -1055,15 +929,11 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void showControl() { if (service() == null || isInPictureInPictureMode()) return; - // 与点播一致:每次唤出控制栏都重算轨道类按钮。applyVisibility 只隐藏不恢复, - // 若直播存活期间(如 PiP)在设置里重新开启字幕/音轨/视轨,只有这里能把它们恢复。 - setTrackVisible(); - setPlayParamsState(); boolean embedded = isEmbeddedLiveUi(); if (!embedded && isVisible(mBinding.recycler)) hideUI(false); mBinding.control.info.setVisibility(player().isEmpty() ? View.GONE : View.VISIBLE); mBinding.control.cast.setVisibility(View.GONE); - mBinding.control.right.rotate.setVisibility(isLock() ? View.GONE : View.VISIBLE); + mBinding.control.right.rotate.setVisibility(isLock() || isPadLiveFullscreenMode() ? View.GONE : View.VISIBLE); mBinding.control.center.setVisibility(isLock() ? View.GONE : View.VISIBLE); mBinding.control.bottom.setVisibility(isLock() ? View.GONE : View.VISIBLE); mBinding.control.action.getRoot().setVisibility(embedded ? View.GONE : View.VISIBLE); @@ -1082,26 +952,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List App.removeCallbacks(mR1); } - private void onPlayParams() { - if (mOsd == null) return; - boolean visible = !mOsd.isDiagnosticsVisible(); - PlayerSetting.putOsdDiagnostics(visible); - mOsd.setDiagnosticsVisible(visible); - setPlayParamsState(); - hideControl(); - } - - private void setPlayParamsState() { - boolean visible = mOsd != null && mOsd.isDiagnosticsVisible(); - mBinding.control.action.playParams.setSelected(visible); - mBinding.control.osdDiagnostics.setSelected(visible); - mBinding.control.osdDiagnostics.setAlpha(visible ? 1f : 0.6f); - // 「播放参数」被播放器按钮设置关闭后,顶栏快捷图标一并隐藏;未关闭时保留一键开关。 - boolean entry = PlayerButtonSetting.isVisible(PlayerButtonSetting.PLAY_PARAMS) && service() != null && !player().isEmpty(); - mBinding.control.osdDiagnostics.setVisibility(entry ? View.VISIBLE : View.GONE); - applyActionButtonVisibility(); - } - private void showInfo() { mBinding.widget.infoPip.setVisibility(isInPictureInPictureMode() ? View.VISIBLE : View.GONE); mBinding.widget.info.setVisibility(isInPictureInPictureMode() ? View.GONE : View.VISIBLE); @@ -1206,7 +1056,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } else if (mGroup != null) { mGroup.setPosition(mChannelAdapter.setSelected(item.group(mGroup))); mChannel = item; - resetLineFallback(); setArtwork(); showInfo(); hideUI(syncPosition); @@ -1377,8 +1226,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void fetch(EpgData item) { App.removeCallbacks(mEndRetry); if (mChannel == null) return; - App.removeCallbacks(mBufferingTimeout); - App.post(mBufferingTimeout, LIVE_BUFFERING_TIMEOUT); playbackCatchup = true; mViewModel.getUrl(mChannel, item); if (service() != null) { @@ -1391,8 +1238,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void fetch() { App.removeCallbacks(mEndRetry); if (mChannel == null) return; - App.removeCallbacks(mBufferingTimeout); - App.post(mBufferingTimeout, LIVE_BUFFERING_TIMEOUT); playbackCatchup = false; LiveConfig.get().setKeep(mChannel); mViewModel.getUrl(mChannel); @@ -1432,14 +1277,11 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } private void handleSameReloadUrl(String msg) { - // 同 URL 重载失败等价于该线路播放失败:走带绕圈/耗尽保护的自动换线, - // 不重置本轮回退状态,避免 reload 路径重新开启死循环。 - // 沿用重载路径既有语义(不校验用户的自动换线开关),只补齐保护: - // 换不动(单线路或本轮已绕回起点)时必须给出可见失败反馈, - // 否则 fetch() 刚 showProgress() 的进度条会一直转、错误文案永远不出现。 - if (advanceLineForFallback()) return; - App.removeCallbacks(mBufferingTimeout); - showError(msg); + if (mChannel != null && !mChannel.isOnly()) { + nextLine(true); + } else { + onError(msg); + } } private void checkControl() { @@ -1577,37 +1419,14 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List setTrackVisible(); } - @Override - protected boolean onSourceHttpError(int statusCode, String msg) { - // HTTP 播放错误优先交给直播回退流程:开启自动换线或接口回退任一时短路播放器重试链。 - if (!LiveSetting.isChange() && !LiveSetting.isSourceFallback()) return false; - onError(msg); - return true; - } - @Override protected void onError(String msg) { - App.removeCallbacks(mBufferingTimeout); Track.delete(player().getKey()); player().resetTrack(); player().reset(); player().stop(); - if (!mFailedThisSession) { - mFailedThisSession = true; - showError(msg); - startFlow(); - } - } - - /** - * 缓冲超时(既没出画面也没报错):等价于本条线路播放失败。 - *

- * 与 onError 不同,本路径不受 mFailedThisSession 限制:上一轮换线后新线路若一直卡在 - * 解析/缓冲阶段,mFailedThisSession 仍为 true,但此时必须仍能继续换线; - * 确实无线路可换(单线路或本轮已绕回起点)时给出可见的失败提示,避免进度条一直转。 - */ - private void onBufferingTimeout() { - if (!startFlow()) showError(ResUtil.getString(R.string.error_play_url)); + showError(msg); + startFlow(); } @Override @@ -1634,15 +1453,12 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List protected void onStateChanged(int state) { switch (state) { case Player.STATE_BUFFERING: - mFailedThisSession = false; showProgress(); break; case Player.STATE_READY: hideProgress(); checkControl(); player().reset(); - mFailedThisSession = false; - resetLineFallback(); break; case Player.STATE_ENDED: checkEnded(); @@ -1668,7 +1484,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List @Override protected void onPlayingChanged(boolean isPlaying) { - if (isPlaying) App.removeCallbacks(mBufferingTimeout); if (isPlaying || isPaused()) updatePlayControl(isPlaying); } @@ -1712,11 +1527,11 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List @Override public void setLive(Live item) { + if (item.isSelected()) item.getGroups().clear(); LiveConfig.get().setHome(item); player().reset(); player().clear(); player().stop(); - resetLineFallback(); resetAdapter(); hideControl(); getLive(); @@ -1763,7 +1578,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mBinding.control.action.audio.setVisibility(player().haveTrack(C.TRACK_TYPE_AUDIO) ? View.VISIBLE : View.GONE); mBinding.control.action.video.setVisibility(player().haveTrack(C.TRACK_TYPE_VIDEO) ? View.VISIBLE : View.GONE); mBinding.control.action.speed.setVisibility(player().isVod() ? View.VISIBLE : View.GONE); - applyActionButtonVisibility(); } private MediaMetadata buildMetadata() { @@ -1778,74 +1592,9 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List player().setMetadata(buildMetadata()); } - /** - * 线路播放失败的自动回退:只在当前频道的线路间轮换(含最后一条绕回), - * 试完一圈即停,绝不因播放失败直接跳到配置里的下一个直播接口。 - * 调用点:onError、缓冲超时(mBufferingTimeout -> onBufferingTimeout)。 - *

- * 本方法只负责「还能不能再换一条线路」,换不动时返回 false, - * 失败文案由调用方负责显示。 - * - * @return true 表示已换到另一条线路并重新拉流 - */ - private boolean startFlow() { - LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decideLineFailure( - LiveSetting.isChange(), - mChannel != null, - mChannel != null && !mChannel.isOnly()); - return action == LiveSourceFallbackPolicy.Action.NEXT_LINE && advanceLineForFallback(); - } - - /** - * 直播接口节目列表拉取失败(renderLive 空结果)时的回退:只有此时才直接 - * 跳转到配置里的下一个直播接口。 - */ - private void startSourceFallback() { - Live next = LiveConfig.getNextHome(); - LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decideSourceFailure( - LiveSetting.isSourceFallback(), - next != null); - if (action == LiveSourceFallbackPolicy.Action.NEXT_SOURCE) setLive(next); - } - - /** - * 播放失败自动换线:从失败起点开始轮换,绕回起点即本轮所有线路都已试过,置位 - * exhausted 停止后续回退;否则刷新线路信息并重新拉流。 - *

- * 本方法是唯一改动 mChannel 线路下标的地方,因此 exhausted 短路必须放在这里: - * 无论从 onError、缓冲超时还是同 URL 重载进来,都不可能再开启新一轮 - * A→B→A 死循环(否则 anchor 未清时换线会重新绕回起点并重新拉流)。 - * - * @return true 表示已换到另一条线路并重新拉流;false 表示没有可换的线路 - * (单线路)或本轮线路已绕回起点(回退耗尽),调用方需要自行给出失败反馈。 - */ - private boolean advanceLineForFallback() { - if (isLineFallbackExhausted()) return false; - if (mChannel == null || mChannel.isOnly()) return false; - if (mLineFallbackAnchor < 0) mLineFallbackAnchor = mChannel.getIndex(); - mChannel.switchLine(true); - if (mChannel.getIndex() == mLineFallbackAnchor) { - mLineFallbackExhausted = true; - return false; - } - showInfo(); - fetch(); - return true; - } - - /** - * 播放失败回退已耗尽(本轮所有线路都试过且都失败)时不再自动换线,避免死循环。 - */ - private boolean isLineFallbackExhausted() { - return mLineFallbackExhausted; - } - - /** - * 手动换线、换台、换直播接口或播放成功后,重置失败回退轮换状态。 - */ - private void resetLineFallback() { - mLineFallbackAnchor = -1; - mLineFallbackExhausted = false; + private void startFlow() { + if (mChannel == null || !LiveSetting.isChange()) return; + if (!mChannel.isLast()) nextLine(true); } private boolean prevGroup() { @@ -1901,7 +1650,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void nextLine(boolean show) { if (mChannel == null || mChannel.isOnly()) return; - resetLineFallback(); mChannel.switchLine(true); if (show) showInfo(); else setInfo(); @@ -1911,7 +1659,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void setLine(int position) { if (mChannel == null || position < 0 || position >= mChannel.getUrls().size()) return; if (mChannel.getIndex() == position) return; - resetLineFallback(); mChannel.setIndex(position); setInfo(); fetch(); @@ -2129,8 +1876,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List cancelPendingLivePiP(false); } setVideoView(isInPictureInPictureMode); - // PiP 窗口里也要按当前模式重算状态栏占位,否则嵌入式高度会把 video 往下挤。 - applyStatusBarInset(); if (isInPictureInPictureMode) { dismissLiveControlDialog(); hideControl(); @@ -2224,7 +1969,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List updateLiveMenuInsets(); if (isEmbeddedLiveUi()) { Util.showSystemUI(this); - ViewCompat.requestApplyInsets(mBinding.getRoot()); getWindow().setStatusBarColor(Color.TRANSPARENT); getWindow().setNavigationBarColor(Color.TRANSPARENT); WindowInsetsControllerCompat insets = WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); @@ -2248,7 +1992,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List keepLiveMenuVisible(); } embeddedUiMode = embedded; - applyStatusBarInset(); updateControlInsets(); updateVideoHeight(videoSize); applyLiveResizeMode(LiveSetting.getScale()); @@ -2276,7 +2019,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void applyLiveListStyle() { if (mBinding == null) return; if (liveMenuOverlay) { - mBinding.recycler.setBackground(LiveListScrim.create(this)); + mBinding.recycler.setBackgroundResource(R.drawable.shape_live_list); } else { mBinding.recycler.setBackgroundResource(LiveSetting.isListStyleClassic() ? R.color.transparent : R.drawable.shape_live_embedded_list); } @@ -2303,7 +2046,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List if (mBinding.getRoot() instanceof LinearLayoutCompat root) { LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0); params.weight = 14; - root.addView(mBinding.recycler, Math.min(root.indexOfChild(mBinding.video) + 1, root.getChildCount()), params); + root.addView(mBinding.recycler, Math.min(1, root.getChildCount()), params); } else if (mBinding.getRoot() instanceof FrameLayout root) { root.addView(mBinding.recycler, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.START)); } @@ -2349,22 +2092,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List view.setLayoutParams(params); } - private void updateStatusBarInset(@Nullable WindowInsetsCompat insets) { - int top = insets == null ? 0 : insets.getInsets(WindowInsetsCompat.Type.statusBars()).top; - // 全屏期间状态栏被隐藏,派发的 inset 是 0;退出全屏后仍要用最后一次可见的高度。 - if (top > 0) statusBarInset = top; - applyStatusBarInset(); - } - - private void applyStatusBarInset() { - if (mBinding.statusBar == null) return; - ViewGroup.LayoutParams params = mBinding.statusBar.getLayoutParams(); - int height = isEmbeddedLiveUi() ? statusBarInset : 0; - if (params.height == height) return; - params.height = height; - mBinding.statusBar.setLayoutParams(params); - } - private void updateLiveMenuInsets() { if (isEmbeddedLiveUi()) noPadding(mBinding.recycler); else setPadding(mBinding.recycler, true); @@ -2428,7 +2155,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mOsd.setDiagnosticsVisible(PlayerSetting.isOsdDiagnostics()); mOsd.start(); } - setPlayParamsState(); setAudioOnly(false); mPiP.resetAudioMode(); setStop(false); @@ -2458,7 +2184,6 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List Source.get().exit(); App.removeCallbacks(mR1, mR2, mR3); App.removeCallbacks(mEndRetry); - App.removeCallbacks(mBufferingTimeout); if (mOsd != null) mOsd.release(); mViewModel.url().removeObserver(mObserveUrl); mViewModel.epg().removeObserver(mObserveEpg); 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 33c57c8f4b..142567315f 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 @@ -16,7 +16,6 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.Parcelable; -import android.os.SystemClock; import android.provider.Settings; import android.text.TextUtils; import android.text.style.ClickableSpan; @@ -28,7 +27,6 @@ import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewConfiguration; -import android.view.ViewParent; import android.view.WindowManager; import android.view.animation.DecelerateInterpolator; import android.widget.ImageView; @@ -46,11 +44,9 @@ import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.C; -import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; import androidx.media3.common.VideoSize; -import androidx.media3.mpvplayer.MpvPlayer; import androidx.media3.ui.PlayerView; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; @@ -59,6 +55,9 @@ import androidx.transition.ChangeBounds; import androidx.transition.TransitionManager; import androidx.viewbinding.ViewBinding; import com.bumptech.glide.request.transition.Transition; +import com.google.android.flexbox.FlexWrap; +import com.google.android.flexbox.FlexboxLayoutManager; +import com.google.android.flexbox.JustifyContent; import com.fongmi.android.tv.App; import com.fongmi.android.tv.Constant; import com.fongmi.android.tv.R; @@ -66,16 +65,17 @@ 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; import com.fongmi.android.tv.bean.Episode; import com.fongmi.android.tv.bean.EpisodePositionCache; import com.fongmi.android.tv.bean.Flag; -import com.fongmi.android.tv.bean.FlagPreferenceCache; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.helper.EpisodeSeasonSnapshot; import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.bean.Keep; @@ -87,30 +87,18 @@ import com.fongmi.android.tv.bean.Track; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbSourcePayload; import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.databinding.ActivityVideoBinding; import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.event.CastEvent; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingPlaybackBridge; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; -import com.fongmi.android.tv.following.FollowingSource; import com.fongmi.android.tv.impl.CustomTarget; import com.fongmi.android.tv.model.SiteViewModel; import com.fongmi.android.tv.playback.PlaybackEventCollector; import com.fongmi.android.tv.playback.HistoryResumePayload; import com.fongmi.android.tv.playback.PlaybackOrientation; -import com.fongmi.android.tv.playback.SubtitleRestoreCoordinator; -import com.fongmi.android.tv.player.IntroSkipKinds; import com.fongmi.android.tv.player.IntroSkipPlayback; -import com.fongmi.android.tv.player.PlaybackAutoContext; -import com.fongmi.android.tv.player.PlaybackExperimentPolicy; -import com.fongmi.android.tv.player.PlaybackResourceClassifier; import com.fongmi.android.tv.player.PlayerHelper; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.engine.PlayerEngine; @@ -118,25 +106,17 @@ 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; import com.fongmi.android.tv.setting.DanmakuSetting; import com.fongmi.android.tv.setting.PlayerButtonSetting; import com.fongmi.android.tv.setting.MultiThreadProxySetting; -import com.fongmi.android.tv.setting.PlaybackExperimentSetting; -import com.fongmi.android.tv.setting.PreloadSetting; import com.fongmi.android.tv.setting.PlayerSetting; -import com.fongmi.android.tv.setting.FlagSwitchPlaybackPolicy; import com.fongmi.android.tv.ui.dialog.PlayerKernelDialog; -import com.fongmi.android.tv.ui.dialog.PlaybackSpeedDialog; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.PlaybackMenuScrim; import com.fongmi.android.tv.setting.SiteHealthStore; -import com.fongmi.android.tv.setting.DetailRuntimeModePolicy; import com.fongmi.android.tv.setting.TmdbSitePolicy; -import com.fongmi.android.tv.setting.TmdbSourceState; import com.fongmi.android.tv.title.MediaTitleLearningExample; import com.fongmi.android.tv.title.MediaTitleRequest; import com.fongmi.android.tv.subtitle.SubtitlePlaybackSession; @@ -179,26 +159,18 @@ import com.fongmi.android.tv.ui.helper.EpisodeSeasonPolicy; import com.fongmi.android.tv.ui.helper.SourceEpisodeSeasonCache; import com.fongmi.android.tv.ui.helper.EpisodeRangePolicy; import com.fongmi.android.tv.ui.helper.PlayerControlFocusHelper; -import com.fongmi.android.tv.ui.helper.TmdbMatchPolicy; import com.fongmi.android.tv.ui.helper.TmdbNavigation; -import com.fongmi.android.tv.ui.helper.TmdbBundle; -import com.fongmi.android.tv.ui.helper.TmdbSourceAdapter; -import com.fongmi.android.tv.ui.helper.TmdbSourceAvailability; -import com.fongmi.android.tv.ui.helper.TmdbSourcePayloadParser; import com.fongmi.android.tv.ui.helper.VodEventGuard; import com.fongmi.android.tv.ui.player.VodPlayerChrome; import com.fongmi.android.tv.ui.player.VodPlayerUiController; import com.fongmi.android.tv.ui.player.VodPlayerUiHost; -import com.fongmi.android.tv.ui.player.ShortDramaQueueCoordinator; import com.fongmi.android.tv.utils.ActivityLaunch; import com.fongmi.android.tv.utils.AudioUtil; -import com.fongmi.android.tv.utils.BatteryUtil; import com.fongmi.android.tv.utils.Clock; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.ImgUtil; import com.fongmi.android.tv.utils.EpisodeHistoryTitleResolver; import com.fongmi.android.tv.utils.EpisodeTitleFormatter; -import com.fongmi.android.tv.utils.Formatters; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PiP; import com.fongmi.android.tv.utils.PushParser; @@ -217,7 +189,6 @@ import com.google.gson.JsonObject; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.io.File; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -252,7 +223,6 @@ import com.fongmi.android.tv.player.lyrics.LyricsLine; import com.fongmi.android.tv.player.lyrics.LyricsRepository; import com.fongmi.android.tv.player.lyrics.LyricsRequest; import com.fongmi.android.tv.player.lyrics.LyricsResult; -import com.fongmi.android.tv.player.mpv.MpvConfigStore; import com.fongmi.android.tv.setting.LyricsSetting; import com.fongmi.android.tv.ui.custom.AudioPlayerBackgroundDrawable; import com.fongmi.android.tv.ui.custom.KaraokeResultView; @@ -266,15 +236,7 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; -public class VideoActivity extends PlaybackActivity implements Clock.Callback, CustomKeyDown.Listener, TrackDialog.Listener, ControlDialog.Listener, DanmakuDialog.Host, FlagAdapter.OnClickListener, EpisodeAdapter.OnClickListener, EpisodeGroupAdapter.OnClickListener, QualityAdapter.OnClickListener, QuickAdapter.OnClickListener, ParseAdapter.OnClickListener, CastDialog.Listener, InfoDialog.Listener, SubtitlePlaybackSession.Host, com.fongmi.android.tv.ui.novel.NovelReaderHost, com.fongmi.android.tv.plugin.PluginSurface.ImmersiveAware { - /** - * 用户拖拽 seek 时加载圈的最小可见时长。 - * - *

必须盖过一个完整的网速采样间隔({@code setTraffic} 每秒一跳),因为读数是由 - * 相邻两次采样的字节差算出的:窗口短于一跳就永远只能拿到没有间隔的首跳(空白)。 - * 取 1.2s 留出余量,避免收圈与首跳采样同帧竞争。 - */ - private static final long SEEK_PROGRESS_MIN_VISIBLE_MS = 1200L; +public class VideoActivity extends PlaybackActivity implements Clock.Callback, CustomKeyDown.Listener, TrackDialog.Listener, ControlDialog.Listener, DanmakuDialog.Host, FlagAdapter.OnClickListener, EpisodeAdapter.OnClickListener, EpisodeGroupAdapter.OnClickListener, QualityAdapter.OnClickListener, QuickAdapter.OnClickListener, ParseAdapter.OnClickListener, CastDialog.Listener, InfoDialog.Listener, SubtitlePlaybackSession.Host, com.fongmi.android.tv.ui.novel.NovelReaderHost { private static final long LYRICS_OFFSET_MIN_MS = -5000L; private static final long LYRICS_OFFSET_MAX_MS = 5000L; private static final long LYRICS_OFFSET_STEP_MS = 500L; @@ -314,8 +276,6 @@ private KaraokeResult mPendingKaraokeResult; private AlertDialog mKaraokeResultDialog; private boolean mSuppressKaraokeResultAction; private boolean mRestoringConfigurationPlayback; -private boolean mProgrammaticFlagSwitch; -private boolean mDeferFlagSwitchPlayback; private boolean mSkipKaraokeTrackAutoLoad; private BottomSheetDialog mLyricsResultDialog; private BottomSheetDialog mAudioQueueDialog; @@ -340,34 +300,11 @@ private String mInlineLyrics; private long mLyricsLoopLastPlayerPosition = C.TIME_UNSET; private boolean mLyricsLoopLastPlaying; private String mPlaybackEpisodeKey; -private String mPlaybackFlagKey = ""; -private int runtimeDetailMode = Setting.getDetailOpenMode(); -private boolean runtimeSourceOnly; private String mArtworkRequestUrl; private String mArtworkRequestOwner; private Vod mPendingDetailVod; -private java.util.ArrayList episodeTitleValues; -private boolean episodeTitleValuesReady; -private java.util.Map episodeTitles; -private boolean episodeTitlesReady; private Result mPendingPlayerResult; -private int playerContentGeneration; -private int playerContentRequestId; -private String playerContentKey = ""; -private String playerContentFlag = ""; -private String playerContentEpisode = ""; - private Result mAppliedPlayerResult; - private final ShortDramaQueueCoordinator mShortDramaQueue = new ShortDramaQueueCoordinator(); - private PlaySpec mShortDramaQueuedSpec; - private Result mShortDramaQueuedResult; - private Episode mShortDramaQueuedEpisode; - private boolean mShortDramaQueueSessionActive; - private boolean mShortDramaQueueFallbackUsed; - private boolean mShortDramaEndedFallbackScheduled; - private long mShortDramaTransitionPositionMs = C.TIME_UNSET; - private long mShortDramaTransitionDurationMs = C.TIME_UNSET; - private final Runnable mShortDramaEndedFallback = this::fallbackShortDramaQueued; -private long mInitialPlaybackPosition = C.TIME_UNSET; +private Result mAppliedPlayerResult; private AudioPlaybackResolver.Resolved mImmersiveAudioResolved; private int mAudioArtworkColor = Color.rgb(55, 45, 68); private final Map mAudioQueueFlags = new HashMap<>(); @@ -400,7 +337,6 @@ private int mAudioBackgroundRandomNonce; private static final String EXTRA_RESUME_HISTORY_CID = "resume_history_cid"; private static final String EXTRA_RESUME_HISTORY_KEY = "resume_history_key"; private static final String EXTRA_TMDB_VOD_CACHE_KEY = "tmdb_vod_cache_key"; - private static final String EXTRA_DETAIL_RUNTIME_MODE = "detail_runtime_mode"; private static final String EXTRA_TMDB_DETAIL_THEME = "tmdb_detail_theme"; private static final String EXTRA_IMMERSIVE_AUDIO_CACHE_KEY = "immersive_audio_cache_key"; private static final String EXTRA_SEARCH_KEYWORD = "search_keyword"; @@ -437,13 +373,6 @@ private int mAudioBackgroundRandomNonce; private PersonalRecommendationService.RecommendationPage mNativePersonalDoubanPage; private PersonalRecommendationService.RecommendationPage mNativePersonalAiPage; private Map mActionButtons; - private final List mCustomActionViews = new ArrayList<>(); - private HorizontalScrollView mCustomLeftButtons; - private HorizontalScrollView mCustomRightButtons; - private HorizontalScrollView mCustomPortraitButtons; - private LinearLayout mCustomLeftButtonRow; - private LinearLayout mCustomRightButtonRow; - private LinearLayout mCustomPortraitButtonRow; private SiteViewModel mViewModel; private FlagAdapter mFlagAdapter; private VodPlayerUiController mPlayerUi; @@ -458,57 +387,34 @@ private int mAudioBackgroundRandomNonce; private List mBroken; private History mHistory; private boolean fullscreen; - /** - * 本页当前的画中画状态。 - * - *

两个写点,各有分工:{@code onPictureInPictureModeChanged} 用形参写(回调是唯一能保证 - * 事件顺序的地方),{@code initView} 用 {@code isInPictureInPictureMode()} 写初值——进程在 - * 画中画中被回收后恢复不会再走一次回调,字段停在 {@code false} 会把恢复中的小窗当全屏。 - * - *

为什么消费侧读字段而不是现场问平台。不是为了绕开回调时序(平台在派发前已写好 - * 该状态,回调里问也是新值),而是为了让"这一次形态变更"只有一个快照:判据在重算入口与 - * 发事件之间必须看到同一个值,现场再问一次就可能取到别的时点。 - */ - private boolean inPip; private boolean initAuto; private boolean autoMode; private boolean revealManualSearch; private boolean useParse; private boolean rotate; - private boolean manualRotate; private boolean detailHealthRecorded; private boolean playHealthRecorded; private boolean mNativePersonalTmdbLoading; private boolean mNativePersonalDoubanLoading; private boolean mEpisodeGridMode = Setting.getTmdbEpisodeGridMode(); private int playerKernelSwitchRequestId; - private int decodeSwitchRequestId; - private int mPendingPlayerKernel = PlayerSetting.NONE; private boolean decodeSwitchRefreshing; private int deferredFullscreenOrientation = Configuration.ORIENTATION_UNDEFINED; private int mEpisodeSpanCount; private int mEpisodeBottomInset; - private int mEpisodeMaxHeight = -1; + private int mEpisodeMaxHeight; private Runnable mR1; private Runnable mR2; private Runnable mR3; private Runnable mR4; private Runnable mSeekProgressFallback; - private boolean mSeekProgressPending; - private long mSeekProgressStartedAtMs; private Runnable mTmdbDetailTimeout; private Clock mClock; - private MpvPlayer mDiscMenuPlayer; - private MpvPlayer mCustomButtonPlayer; - private final Runnable mCustomButtonStateListener = this::updateCustomButtonStates; - private final Runnable mDiscMenuStateListener = this::updateDiscMenuTools; private PiP mPiP; private String mContextWallUrl; private String mContextWallLockedUrl; private String playHealthKey; private long detailStartTime; - private int followingUiGeneration; - private boolean followingActionPending; private long playerStartTime; private long pendingResumeSeekMs = C.TIME_UNSET; private boolean tmdbHistoryResumePending; @@ -539,7 +445,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; @@ -661,25 +571,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private static boolean shouldOpenLegacyTmdbDetail(String key, String id) { int mode = Setting.getDetailOpenMode(); - return canOpenLegacyTmdbDetail(key, id) && Setting.isTmdbDetailModeConfigured() && Setting.isStandaloneTmdbDetailMode(mode); - } - - public static void startDirectResolved(Activity activity, Vod vod) { - if (activity == null || vod == null) return; - Intent intent = new Intent(activity, VideoActivity.class); - intent.putExtra("detail_mode", Setting.DETAIL_OPEN_DIRECT); - intent.putExtra(EXTRA_DETAIL_RUNTIME_MODE, Setting.DETAIL_OPEN_DIRECT); - intent.putExtra("scan", false); - intent.putExtra("collect", false); - intent.putExtra("cast", false); - intent.putExtra("key", vod.getSiteKey()); - intent.putExtra("id", vod.getId()); - intent.putExtra("name", vod.getName()); - intent.putExtra("pic", vod.getPic()); - intent.putExtra("mark", vod.getRemarks()); - intent.putExtra("content", vod.getContent()); - putDetailVodCache(intent, vod); - activity.startActivity(intent); + return canOpenLegacyTmdbDetail(key, id) && Setting.isTmdbDetailPage() && Setting.isStandaloneTmdbDetailMode(mode); } public static void start(Activity activity, String url) { @@ -779,11 +671,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco start(activity, key, id, name, pic, mark, false, tmdbItem); } - /** 追更页通过 flavor 专用入口调用;普通历史记录仍使用原有模式路由。 */ - public static void startFromFollowingHistory(Activity activity, History item) { - startFromHistory(activity, item); - } - public static void startFromHistory(Activity activity, History item) { if (shouldOpenLegacyTmdbDetail(item.getSiteKey(), item.getVodId())) { TmdbDetailActivity.startFromHistory(activity, item); @@ -928,7 +815,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco public static void startDirectTmdb(Activity activity, String key, String id, String name, String pic, String mark, ArrayList episodeTitles, TmdbItem item, Vod tmdbVod, Vod detailVod, String tmdbDetailCacheKey, String playFlag, String playEpisodeName, String playEpisodeUrl, int playSeasonNumber, int playEpisodeNumber, History resumeHistory) { startDirectTmdb(activity, key, id, name, pic, mark, episodeTitles, item, tmdbVod, detailVod, tmdbDetailCacheKey, playFlag, - "", playEpisodeName, playEpisodeUrl, playSeasonNumber, playEpisodeNumber, resumeHistory); + resumeHistory == null ? "" : resumeHistory.getSourceBindingKey(), + playEpisodeName, playEpisodeUrl, playSeasonNumber, playEpisodeNumber, resumeHistory); } public static void startDirectTmdb(Activity activity, String key, String id, String name, String pic, String mark, ArrayList episodeTitles, TmdbItem item, Vod tmdbVod, Vod detailVod, String tmdbDetailCacheKey, String playFlag, String playFlagKey, String playEpisodeName, String playEpisodeUrl, int playSeasonNumber, int playEpisodeNumber, History resumeHistory) { @@ -1135,7 +1023,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco ? getString(R.string.detail_episode_season_context, season) : getString(R.string.detail_episode)); boolean selectable = isTmdbSourceEnabled() - && !runtimeSourceOnly && mTmdbUIAdapter != null && mTmdbUIAdapter.getTmdbItem() != null && mTmdbUIAdapter.getTmdbItem().isTv() @@ -1200,7 +1087,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco ? com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, index) : mTmdbUIAdapter == null ? "" : mTmdbUIAdapter.activeFlagKey(flag); mHistory.setSourceBindingKey(flagKey); - syncHistory(); } private Flag resolveHistoryPlaybackFlag(List flags) { @@ -1208,75 +1094,11 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco String flagKey = mHistory == null ? "" : mHistory.getSourceBindingKey(); String flagName = mHistory == null ? "" : mHistory.getVodFlag(); String episodeUrl = mHistory == null ? "" : mHistory.getEpisodeUrl(); - // 用户最后一次显式切换的线路优先于 History 里的线路快照:History.vodFlag 只是 - // 「上次起播时的线路」的副产物,切线路而没重新起播时它不会更新,杀进程后若还 - // 以它为准就会退回旧线路甚至第一条。偏好同样要能定位到这次要播的集, - // 否则会劫持跨源解算出的、确实含目标集的那条线路。 - Flag preferred = findPreferredFlag(flags, episodeUrl); - if (preferred != null) return preferred; Flag selected = com.fongmi.android.tv.ui.helper.TmdbUIAdapter.selectPlaybackFlag( flags, flagKey, episodeUrl, flagName); return selected == null ? flags.get(0) : selected; } - /** - * 记住用户显式切换的线路。线路选择原本只作为播放副产物写进 History,而切线路后 - * 不一定重新起播(seamless 没切集时不会走 onRefresh),mHistory 的 vodFlag/ - * sourceBindingKey 仍指向旧线路,杀进程后就退回了旧线路。这里与详情页保持一致, - * 切一下就落盘偏好,不依赖播放进度。 - */ - private void rememberFlagSelection(Flag flag) { - if (flag == null) return; - int index = mFlagAdapter == null ? -1 : mFlagAdapter.indexOf(flag); - if (index < 0 && mVod != null) index = com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagIndex(mVod.getFlags(), flag); - // 索引未知时不写稳定键:Flag.stableKey 会把 -1 夹成 0,留下一个必然命中首条线路的假键。 - String stableKey = index >= 0 ? com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, index) : ""; - FlagPreferenceCache.get().put(getKey(), getId(), stableKey, flag.getFlag()); - Task.execute(() -> FlagPreferenceCache.get().save()); - if (mHistory == null) return; - // History 里的 sourceBindingKey 与既有 setHistoryFlag 保持一致:稳定键不可用时退化到适配器当前键。 - String historyKey = TextUtils.isEmpty(stableKey) - ? (mTmdbUIAdapter == null ? "" : mTmdbUIAdapter.activeFlagKey(flag)) - : stableKey; - mHistory.setVodFlag(flag.getFlag()); - if (!TextUtils.isEmpty(historyKey)) mHistory.setSourceBindingKey(historyKey); - // 还没起播(无进度)时不写库,避免在「最近观看」里留下一条没有进度的空记录。 - if (mHistory.canSave()) syncHistory(); - } - - /** - * 读取上次显式选中的线路。稳定键(线路名#索引)优先,源站线路增删导致索引漂移时 - * 退化用线路名匹配。episodeUrl 非空时要求该线路确实含这一集。 - */ - private Flag findPreferredFlag(List flags, String episodeUrl) { - FlagPreferenceCache.FlagPreference preference = FlagPreferenceCache.get().get(getKey(), getId()); - if (preference == null) return null; - Flag candidate = null; - String stableKey = preference.getStableKey(); - if (!TextUtils.isEmpty(stableKey)) { - for (int i = 0; i < flags.size(); i++) { - Flag flag = flags.get(i); - if (flag != null && TextUtils.equals(stableKey, com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, i))) { - candidate = flag; - break; - } - } - } - if (candidate == null) { - String flagName = preference.getFlagName(); - if (TextUtils.isEmpty(flagName)) return null; - for (Flag flag : flags) { - if (flag != null && TextUtils.equals(flagName, flag.getFlag())) { - candidate = flag; - break; - } - } - } - if (candidate == null) return null; - if (TextUtils.isEmpty(episodeUrl) || candidate.containsEpisodeUrl(Episode.create("", episodeUrl))) return candidate; - return null; - } - private Episode getEpisode() { Flag flag = getFlag(); if (flag != null) { @@ -1296,49 +1118,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return getIntent().getBooleanExtra("tmdbMode", false); } - private void initializeRuntimeDetailMode() { - runtimeDetailMode = getIntent().getIntExtra(EXTRA_DETAIL_RUNTIME_MODE, Setting.getDetailOpenMode()); - runtimeSourceOnly = false; - } - - private void applyRuntimeDetailMode(int mode) { - runtimeDetailMode = Setting.isTmdbMode(mode) || mode == Setting.DETAIL_OPEN_DIRECT ? mode : Setting.DETAIL_OPEN_DIRECT; - } - - private int runtimeDetailMode() { - return runtimeDetailMode; - } - - private int policyConfiguredMode() { - int override = getIntent().getIntExtra(EXTRA_DETAIL_RUNTIME_MODE, Integer.MIN_VALUE); - return override == Setting.DETAIL_OPEN_DIRECT ? Setting.DETAIL_OPEN_DIRECT : Setting.getDetailOpenMode(); - } - - private boolean isRuntimeFusionMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_FUSION || getIntent().getBooleanExtra("fusion", false); - } - - private boolean isRuntimeOriginalEnhancedMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_ORIGINAL_ENHANCED; - } - - private boolean isRuntimeDirectMode() { - return runtimeDetailMode() == Setting.DETAIL_OPEN_DIRECT; - } - - private void applySourceOnlyActionVisibility() { - if (!runtimeSourceOnly || mTmdbHeaderView == null || mTmdbHeaderView.getHeaderRoot() == null) return; - View rematch = mTmdbHeaderView.getHeaderRoot().findViewById(R.id.tmdbRematch); - if (rematch != null) { - rematch.setVisibility(View.GONE); - rematch.setClickable(false); - rematch.setFocusable(false); - } - } - private boolean isTmdbSourceEnabled() { if (isTmdbMode()) return true; - if (!Setting.isTmdbMode(runtimeDetailMode())) return false; + if (!Setting.isTmdbMode(Setting.getDetailOpenMode())) return false; + if (!Setting.isTmdbEnabled()) return false; return TmdbSitePolicy.isEnabled(getKey(), getId()); } @@ -1347,12 +1130,11 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean hasTmdbDetailAdapter() { - return isTmdbSourceEnabled() && mTmdbHeaderView != null && mTmdbUIAdapter != null - && (mTmdbUIAdapter.isReady() || runtimeSourceOnly); + return isTmdbSourceEnabled() && mTmdbHeaderView != null && mTmdbUIAdapter != null && mTmdbUIAdapter.isReady(); } private boolean shouldLoadTmdbDetail() { - return mTmdbUIAdapter != null && (mTmdbUIAdapter.isReady() || runtimeSourceOnly); + return mTmdbUIAdapter != null && mTmdbUIAdapter.isReady(); } private boolean shouldUseTmdbDetailLayout() { @@ -1360,7 +1142,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean shouldUseTmdbBackdropSurface() { - return !isRuntimeFusionMode() && (isRuntimeOriginalEnhancedMode() || shouldUseTmdbDetailLayout() && (runtimeDetailMode() == Setting.DETAIL_OPEN_ENHANCED || Setting.isTmdbNativeStyle())); + return !Setting.isFusionDetailPage() && (Setting.isOriginalEnhancedDetailPage() || shouldUseTmdbDetailLayout() && (Setting.getDetailOpenMode() == Setting.DETAIL_OPEN_ENHANCED || Setting.isTmdbNativeStyle())); } private com.fongmi.android.tv.bean.TmdbItem getTmdbItem() { @@ -1405,9 +1187,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco tmdbHistoryResumePending = true; } if (!targetFlag.isSelected() || !targetEpisode.isSelected()) { - mProgrammaticFlagSwitch = true; onItemClick(targetFlag); - mProgrammaticFlagSwitch = false; return; } @@ -1515,20 +1295,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return mBinding.control.seek; } - @Override - protected long getDurationFallbackMs() { - return mHistory == null ? C.TIME_UNSET : mHistory.getDuration(); - } - @Override protected void onServiceConnected() { player().setDanmakuController(mBinding.exo.getDanmakuController()); - applyPendingPlayerKernel(); - // The history kernel can rebuild the service player before a PlaySpec - // exists. In that window PlaybackActivity's ownership guard correctly - // skips its rebuild callback, so refresh the direct progress source - // after the pending kernel has been applied. - getSeekView().setProgressPlayer(player().getPlayer()); syncDesktopLyricsAudioContent(); setPlayerKernel(); setDecode(); @@ -1556,7 +1325,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override protected void onNewIntent(Intent intent) { - invalidateShortDramaQueue("new-intent"); String oldKey = getKey(); String oldId = getId(); super.onNewIntent(intent); @@ -1575,20 +1343,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco getIntent().removeExtra(EXTRA_RESUME_HISTORY_CID); getIntent().removeExtra(EXTRA_RESUME_HISTORY_KEY); getIntent().putExtras(intent); - // intent 换了,缓存的集数标题必须一起失效,否则新条目会沿用上一部的标题。 - invalidateEpisodeTitlesCache(); - resetPlaybackOwnership(); - if (mViewModel != null) mViewModel.cancelPlayerContent(); - invalidatePlayerContent(); - mPendingDetailVod = null; - mPendingPlayerResult = null; - mAppliedPlayerResult = null; - if (service() != null) { - subtitlePlaybackSession.stop(this); - player().reset(); - player().stop(); - player().clear(); - } if (mTmdbUIAdapter != null) mTmdbUIAdapter.beginDetailRequest(); mSourceEpisodeSeasonCache.clear(); mSourceVodName = ""; @@ -1598,22 +1352,14 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco // 否则长视频会卡在竖屏全屏且无法旋转。仍是短剧则保持形态,避免无谓的进出全屏抖动。 if (shortDramaSession && !isShortDramaSource()) exitFullscreen(); shortDramaSession = false; - // 形态可能仍是短剧全屏(新条目也是短剧时上面不退出),所以按当前形态重算而不是一律清掉, - // 否则新条目在到达 STATE_READY 之前会用长视频那套手势,侧边竖滑又变成调亮度。 - syncShortDramaGesture(); setOrient(); checkId(); } @Override protected void initView(Bundle savedInstanceState) { - initializeRuntimeDetailMode(); mTmdbDetailTimeout = this::showTmdbDetailFallback; super.initView(savedInstanceState); - // 初始值必须来自平台真实状态:进程在画中画里被回收后重建时不会再走一次回调, - // 字段留在默认 false 就会把恢复中的 PiP 短暂当成全屏。 - inPip = isInPictureInPictureMode(); - applyPlaybackOverlay(); mRestoringConfigurationPlayback = savedInstanceState != null; ViewCompat.setOnApplyWindowInsetsListener(mBinding.getRoot(), (v, insets) -> setStatusBar(insets)); mKeyDown = CustomKeyDown.create(this, mBinding.exo); @@ -1636,11 +1382,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return getOsdTitle(); } - @Override - public boolean isFullscreen() { - return VideoActivity.this.isFullscreen(); - } - }, VodPlayerChrome.fromVideo(mBinding, null, 12f), this); mClock = mPlayerUi.clock(); mOsd = mPlayerUi.osd(); @@ -1652,6 +1393,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mR3 = this::setOrient; mR4 = this::showEmpty; mSeekProgressFallback = this::hideSeekProgressIfReady; + checkDanmakuImg(); setRecyclerView(); setVideoView(); setViewModel(); @@ -1668,30 +1410,17 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void setupIntroSkipConfirmListener() { mIntroSkipPlayback.setSkipConfirmListener((segment, action) -> { - if (mIntroSkipConfirmDialog != null && mIntroSkipConfirmDialog.isShowing()) return false; - mIntroSkipConfirmDialog = new WebHtvAlertDialogBuilder(this) + if (mIntroSkipConfirmDialog != null && mIntroSkipConfirmDialog.isShowing()) return; + int messageId = segment.isOpening() + ? (segment.getKind() == IntroSkipService.Segment.Kind.INTRO ? R.string.intro_skip_confirm_intro : R.string.intro_skip_confirm_recap) + : R.string.intro_skip_confirm_outro; + mIntroSkipConfirmDialog = new com.google.android.material.dialog.MaterialAlertDialogBuilder(this) .setTitle(R.string.intro_skip_confirm_title) - .setMessage(IntroSkipKinds.confirmMessage(segment)) + .setMessage(messageId) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> mIntroSkipPlayback.declineConfirmation(segment)) + .setNegativeButton(android.R.string.cancel, null) .show(); - mIntroSkipConfirmDialog.setOnDismissListener(dialog -> { - mIntroSkipPlayback.cancelConfirmation(segment); - if (mIntroSkipConfirmDialog == dialog) mIntroSkipConfirmDialog = null; - }); - return true; }); - mIntroSkipPlayback.setSkipNoticeListener(IntroSkipKinds::notifySkipped); - mIntroSkipPlayback.setSkipConfirmDismisser(this::dismissIntroSkipConfirm); - } - - private void dismissIntroSkipConfirm() { - if (mIntroSkipConfirmDialog == null) return; - try { - mIntroSkipConfirmDialog.dismiss(); - } catch (Throwable ignored) { - } - mIntroSkipConfirmDialog = null; } @Override @@ -1700,7 +1429,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.name.setOnClickListener(view -> onName()); mBinding.more.setOnClickListener(view -> onMore()); mBinding.shortDisplay.setOnClickListener(view -> onShortDisplay()); - if (mBinding.following != null) mBinding.following.setOnClickListener(view -> onFollowing()); mBinding.search.setOnClickListener(view -> onSearch()); mBinding.castAction.setOnClickListener(guarded(this::onCast)); mBinding.settingAction.setOnClickListener(view -> onSetting()); @@ -1728,41 +1456,30 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.right.rotate.setOnClickListener(view -> onRotate()); mBinding.control.right.pip.setOnClickListener(guarded(this::onPiP)); mBinding.control.fullscreen.setOnClickListener(guarded(this::onFullscreen)); - mBinding.control.shortDramaChangeSource.setOnClickListener(view -> onChange()); - mBinding.control.shortDramaQuality.setOnClickListener(guarded(this::onQuality)); - mBinding.control.shortDramaEpisodes.setOnClickListener(guarded(this::onEpisodes)); + mBinding.control.danmaku.setOnClickListener(view -> onDanmakuShow()); mBinding.control.action.text.setOnClickListener(guardedView(this::onTrack)); mBinding.control.action.audio.setOnClickListener(guardedView(this::onTrack)); mBinding.control.action.video.setOnClickListener(guardedView(this::onTrack)); mBinding.control.action.scale.setOnClickListener(guarded(this::onScale)); + mBinding.control.action.actionQuality.setOnClickListener(guarded(this::onQuality)); mBinding.control.action.lut.setOnClickListener(guarded(this::onLut)); - mBinding.control.action.karaoke.setOnClickListener(view -> onKaraokeMode()); mBinding.control.action.speed.setOnClickListener(guarded(this::onSpeed)); mBinding.control.action.reset.setOnClickListener(guarded(this::onReset)); - // 错误层的重试复用控制层同一条「重做本次播放」动作:onReset() 会按用户的「重置」 - // 设置决定从头重播还是重新拉流,所以不新写播放路径,也就不会与既有的自动换源 - // 产生第二套恢复逻辑。重试成功后的收尾由 showProgress() 负责隐藏错误层。 - mBinding.widget.errorRetry.setOnClickListener(guarded(this::onReset)); - mBinding.control.action.change2.setOnClickListener(view -> onChange()); mBinding.control.action.title.setOnClickListener(guarded(this::onTitle)); - mBinding.control.action.discMenu.setOnClickListener(view -> { - hideControl(); - openDiscMenu(); - }); - mBinding.control.action.discMenu.setOnLongClickListener(view -> { - hideControl(); - showDiscMenuControls(); - return true; - }); - mBinding.discTools.fullscreen.setOnClickListener(view -> onFullscreen()); mBinding.control.action.player.setOnClickListener(guarded(this::onPlayerKernel)); - mBinding.control.action.player.setOnLongClickListener(view -> onChooseLong()); + mBinding.control.action.player.setOnLongClickListener(view -> onPlayerKernelLong()); + mBinding.control.action.change2.setOnClickListener(view -> onChange()); + mBinding.control.shortDramaChangeSource.setOnClickListener(view -> onChange()); + mBinding.control.shortDramaQuality.setOnClickListener(guarded(this::onQuality)); + mBinding.control.shortDramaEpisodes.setOnClickListener(guarded(this::onEpisodes)); + mBinding.control.action.fullscreen.setOnClickListener(guarded(this::onFullscreen)); + mBinding.control.action.playParams.setOnClickListener(guarded(this::onPlayParams)); + mBinding.control.action.multiThreadProxy.setOnClickListener(guarded(this::onMultiThreadProxy)); + mBinding.control.action.codecCapability.setOnClickListener(guarded(this::onCodecCapabilityPanel)); mBinding.control.action.prev.setOnClickListener(view -> checkPrev()); mBinding.control.action.next.setOnClickListener(view -> checkNext()); mBinding.control.action.decode.setOnClickListener(guarded(this::onDecode)); mBinding.control.action.playParams.setOnClickListener(guarded(this::onPlayParams)); - mBinding.control.action.multiThreadProxy.setOnClickListener(guarded(this::onMultiThreadProxy)); - mBinding.control.action.codecCapability.setOnClickListener(guarded(this::onCodecCapabilityPanel)); mBinding.control.action.ending.setOnClickListener(guarded(this::onEnding)); mBinding.control.action.repeat.setOnClickListener(guarded(this::onRepeat)); mBinding.control.action.opening.setOnClickListener(guarded(this::onOpening)); @@ -1785,17 +1502,16 @@ 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()); mBinding.control.action.reset.setOnLongClickListener(view -> onResetToggle()); mBinding.control.action.ending.setOnLongClickListener(view -> onEndingReset()); mBinding.control.action.opening.setOnLongClickListener(view -> onOpeningReset()); + mBinding.video.setOnTouchListener((view, event) -> mKeyDown.onTouchEvent(event)); // 控制层显示时会先于 video 容器接收事件,空白区域必须直接转发给手势检测器。 mBinding.control.getRoot().setOnTouchListener(this::onPlayerControlTouch); - mBinding.video.setOnTouchListener((view, event) -> - (!isVisible(mBinding.control.getRoot()) && dispatchDiscMenuTouch(event)) - || mKeyDown.onTouchEvent(event)); mBinding.control.action.getRoot().setOnTouchListener(this::onActionTouch); mBinding.swipeLayout.setOnRefreshListener(this::onSwipeRefresh); } @@ -1820,37 +1536,20 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void updateEpisodeViewportHeight() { - updateEpisodeTouchHandling(); if (mBinding.episode.getVisibility() != View.VISIBLE) return; int limit = ResUtil.isPad() || ResUtil.isLand(this) ? ResUtil.dp2px(328) : ResUtil.dp2px(280); // The episode list lives inside a scroll container, so capping it by the // current on-screen remainder can collapse the viewport to a single row // when the section is laid out below the fold. Keep a stable cap here // and let the parent page handle the rest of the scrolling. - int height = usesOuterEpisodePageScroll() ? 0 : limit; - if (!usesOuterEpisodePageScroll() && isTmdbEpisodeCardMode()) height = Math.max(height, getEpisodeCardMinHeight()); - if (height == mEpisodeMaxHeight) return; + int height = limit; + if (isTmdbEpisodeCardMode()) height = Math.max(height, getEpisodeCardMinHeight()); + if (height <= 0 || height == mEpisodeMaxHeight) return; mEpisodeMaxHeight = height; mBinding.episode.setMaxHeight(height); mBinding.episode.requestLayout(); } - private void updateEpisodeTouchHandling() { - mBinding.episode.setOnTouchListener((view, event) -> { - if (!usesOuterEpisodePageScroll()) return false; - int action = event.getActionMasked(); - if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_MOVE) { - if (view.getParent() != null) view.getParent().requestDisallowInterceptTouchEvent(false); - } - return false; - }); - } - - private boolean usesOuterEpisodePageScroll() { - return isRuntimeOriginalEnhancedMode() - || mTmdbControlsMoved && shouldUseTmdbBackdropSurface(); - } - private boolean isTmdbEpisodeCardMode() { return mEpisodeAdapter != null && !mEpisodeAdapter.isEmpty() && mEpisodeAdapter.isUsingTmdbCard(); } @@ -2001,7 +1700,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco setupActionButtons(); mBinding.video.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { mPiP.update(this, view); - updateCustomButtonLayout(); Log.d(SIZE_TAG, "video layout new=" + (right - left) + "x" + (bottom - top) + " old=" + (oldRight - oldLeft) + "x" + (oldBottom - oldTop) + " fullscreen=" + isFullscreen() @@ -2015,7 +1713,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void setPlayer() { - mBinding.control.action.player.setText(service() == null ? ResUtil.getStringArray(R.array.select_player)[PlayerSetting.getActivePlayer()] : player().getPlayerText()); + mBinding.control.action.player.setText(service() == null ? ResUtil.getStringArray(R.array.select_player)[PlayerSetting.getPlayer()] : player().getPlayerText()); } private void setupActionButtons() { @@ -2046,9 +1744,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco addActionButton(PlayerButtonSetting.NEXT, mBinding.control.action.next); addActionButton(PlayerButtonSetting.EPISODES, mBinding.control.action.episodes); applyActionButtonSettings(); - addActionButton(PlayerButtonSetting.CHANGE, mBinding.control.action.change2); - PlayerButtonSetting.applyOrder(mBinding.control.action.container, mActionButtons); - setupCustomActionButtons(); setPlayParamsState(); } @@ -2056,149 +1751,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mActionButtons.put(id, view); } - private void setupCustomActionButtons() { - ensureCustomButtonContainers(); - for (View view : mCustomActionViews) { - ViewParent parent = view.getParent(); - if (parent instanceof ViewGroup) ((ViewGroup) parent).removeView(view); - } - mCustomActionViews.clear(); - mCustomLeftButtonRow.removeAllViews(); - mCustomRightButtonRow.removeAllViews(); - mCustomPortraitButtonRow.removeAllViews(); - List buttons = MpvConfigStore.customButtons(); - boolean landscape = isLand(); - for (int index = 0; index < buttons.size(); index++) { - MpvConfigStore.CustomButton button = buttons.get(index); - if (!button.isButtonVisible()) continue; - TextView view = new TextView(this); - view.setTextSize(13); - view.setTextColor(Color.WHITE); - view.setGravity(Gravity.CENTER); - view.setMinHeight(ResUtil.dp2px(40)); - view.setMinWidth(ResUtil.dp2px(56)); - view.setPadding(ResUtil.dp2px(10), ResUtil.dp2px(4), ResUtil.dp2px(10), ResUtil.dp2px(4)); - view.setBackgroundResource(R.drawable.selector_control_sheet_button); - view.setText(button.title); - view.setSingleLine(true); - view.setMaxWidth(ResUtil.dp2px(144)); - view.setEllipsize(TextUtils.TruncateAt.END); - view.setContentDescription(button.title); - view.setTag(button.id); - view.setOnClickListener(item -> { - player().sendMpvCustomButton(button.id, false); - setR1Callback(); - }); - view.setOnLongClickListener(item -> { - player().sendMpvCustomButton(button.id, true); - setR1Callback(); - return true; - }); - if (Util.isLeanback()) { - view.setFocusable(true); - view.setFocusableInTouchMode(true); - } - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ResUtil.dp2px(40)); - params.setMargins(ResUtil.dp2px(4), ResUtil.dp2px(2), ResUtil.dp2px(4), ResUtil.dp2px(2)); - LinearLayout target = landscape - ? (index < 4 ? mCustomLeftButtonRow : mCustomRightButtonRow) - : mCustomPortraitButtonRow; - target.addView(view, params); - mCustomActionViews.add(view); - } - updateCustomButtonLayout(); - updateCustomButtonVisibility(); - } - - private void updateCustomButtonStates() { - MpvPlayer mpv = service() != null && isOwner() - && player().getPlayer() instanceof MpvPlayer active ? active : null; - if (mCustomButtonPlayer != mpv) { - if (mCustomButtonPlayer != null) mCustomButtonPlayer.removeCustomButtonStateListener(mCustomButtonStateListener); - mCustomButtonPlayer = mpv; - if (mpv != null) mpv.addCustomButtonStateListener(mCustomButtonStateListener); - } - for (View view : mCustomActionViews) { - view.setSelected(mpv != null && mpv.isCustomButtonActive((String) view.getTag())); - } - } - - private void ensureCustomButtonContainers() { - if (mCustomLeftButtons != null) return; - mCustomLeftButtons = createCustomButtonScroll(); - mCustomRightButtons = createCustomButtonScroll(); - mCustomPortraitButtons = createCustomButtonScroll(); - mCustomLeftButtonRow = createCustomButtonRow(mCustomLeftButtons); - mCustomRightButtonRow = createCustomButtonRow(mCustomRightButtons); - mCustomPortraitButtonRow = createCustomButtonRow(mCustomPortraitButtons); - mCustomRightButtons.setFillViewport(true); - mCustomRightButtonRow.setGravity(Gravity.CENTER_VERTICAL | Gravity.END); - mCustomRightButtonRow.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; - - FrameLayout.LayoutParams leftParams = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.START | Gravity.TOP); - FrameLayout.LayoutParams rightParams = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.END | Gravity.TOP); - int margin = ResUtil.dp2px(8); - leftParams.setMargins(margin, 0, margin, 0); - rightParams.setMargins(margin, 0, margin, 0); - mBinding.video.addView(mCustomLeftButtons, leftParams); - mBinding.video.addView(mCustomRightButtons, rightParams); - - RelativeLayout.LayoutParams portraitParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - portraitParams.addRule(RelativeLayout.ABOVE, R.id.bottom); - portraitParams.setMargins(margin, 0, margin, ResUtil.dp2px(2)); - ((ViewGroup) mBinding.control.getRoot()).addView(mCustomPortraitButtons, portraitParams); - } - - private HorizontalScrollView createCustomButtonScroll() { - HorizontalScrollView scroll = new HorizontalScrollView(this); - scroll.setHorizontalScrollBarEnabled(false); - scroll.setFillViewport(false); - scroll.setClipToPadding(false); - scroll.setOverScrollMode(View.OVER_SCROLL_NEVER); - return scroll; - } - - private LinearLayout createCustomButtonRow(HorizontalScrollView scroll) { - LinearLayout row = new LinearLayout(this); - row.setGravity(Gravity.CENTER_VERTICAL); - row.setOrientation(LinearLayout.HORIZONTAL); - row.setClipChildren(false); - scroll.addView(row, new HorizontalScrollView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - return row; - } - - private void updateCustomButtonLayout() { - if (mCustomLeftButtons == null || mBinding.video.getWidth() <= 0) return; - boolean landscape = isLand(); - if (!landscape) return; - - int margin = ResUtil.dp2px(8); - int width = Math.max(ResUtil.dp2px(96), mBinding.video.getWidth() / 2 - margin * 2); - int height = Math.max(mCustomLeftButtons.getMeasuredHeight(), ResUtil.dp2px(40)); - int top = Math.max(margin, Math.round(mBinding.video.getHeight() * 0.65f - height / 2f)); - FrameLayout.LayoutParams leftParams = (FrameLayout.LayoutParams) mCustomLeftButtons.getLayoutParams(); - FrameLayout.LayoutParams rightParams = (FrameLayout.LayoutParams) mCustomRightButtons.getLayoutParams(); - leftParams.width = width; - rightParams.width = width; - leftParams.topMargin = top; - rightParams.topMargin = top; - mCustomLeftButtons.setLayoutParams(leftParams); - mCustomRightButtons.setLayoutParams(rightParams); - } - - private void updateCustomButtonVisibility() { - updateCustomButtonStates(); - boolean visible = service() != null && player().isMpv() && isVisible(mBinding.control.getRoot()); - if (mCustomLeftButtons != null) mCustomLeftButtons.setVisibility(visible && isLand() ? View.VISIBLE : View.GONE); - if (mCustomRightButtons != null) mCustomRightButtons.setVisibility(visible && isLand() ? View.VISIBLE : View.GONE); - if (mCustomPortraitButtons != null) mCustomPortraitButtons.setVisibility(visible && !isLand() ? View.VISIBLE : View.GONE); - updateCustomButtonLayout(); - } - private void applyActionButtonVisibility() { if (mActionButtons != null) PlayerButtonSetting.applyVisibility(mActionButtons); - updateDiscMenuTools(); - updateCustomButtonVisibility(); } private void applyActionButtonSettings() { @@ -2330,60 +1884,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.action.player.setText(player().getPlayerText()); } - /** - * 起播前把内核切到本剧记住的选择,并返回该内核给取址用——取播放地址要按内核区分线路。 - * 没有记录时 getPlayerOrDefault 会退回设置页的全局默认。 - * 播放服务还没连上时先只记会话内核(取址在工作线程上读它),引擎由 onServiceConnected 补齐。 - */ - private int applyHistoryPlayerKernel(boolean forcePrepare) { - int kernel = mHistory == null ? PlayerSetting.getPlayer() : mHistory.getPlayerOrDefault(); - PlayerSetting.putActivePlayer(kernel); - if (service() == null) { - mPendingPlayerKernel = kernel; - return kernel; - } - mPendingPlayerKernel = PlayerSetting.NONE; - player().preparePlayer(kernel, forcePrepare); - // preparePlayer() is intentionally allowed before playback ownership - // is established; keep the mobile seek view on the replacement player. - getSeekView().setProgressPlayer(player().getPlayer()); - setPlayerKernel(); - setDecode(); - return kernel; - } - - private int applyHistoryPlayerKernel() { - return applyHistoryPlayerKernel(false); - } - - /** - * 服务连上后补齐本页在等的内核。 - * 服务可能是上一次播放留活下来的,它建 PlayerManager 时读到的还是上一部剧的内核, - * 所以本页在服务就绪前定下的选择要在这里落到引擎上;没有待办时不动引擎。 - */ - private void applyPendingPlayerKernel() { - int kernel = mPendingPlayerKernel; - mPendingPlayerKernel = PlayerSetting.NONE; - if (!PlayerSetting.isPlayer(kernel)) return; - player().preparePlayer(kernel); - // See applyHistoryPlayerKernel(): this rebuild happens before the - // first media spec and therefore may not reach onPlayerRebuilt(). - getSeekView().setProgressPlayer(player().getPlayer()); - } - - /** - * 把用户刚选定的内核写回本剧历史并落盘。 - * 只在用户显式换内核时调用,且用用户选的值而不是会话/引擎状态: - * 播放页可能重叠存在(上一部剧的 Activity 还没销毁),若挂在每次存历史上, - * 上一部剧的收尾存档会用别人的会话内核覆盖本剧记住的选择; - * 而引擎类型还会被播放失败后的自动回退改掉,也不代表用户改了选择。 - */ - private void rememberPlayerKernel(int type) { - if (mHistory == null || !PlayerSetting.isPlayer(type)) return; - mHistory.setPlayer(type); - syncHistory(); - } - private void setScale(int scale) { if (mHistory != null) mHistory.setScale(scale); if (SiteApi.PUSH.equals(getKey())) PlayerSetting.putScale(scale); @@ -2415,7 +1915,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco VodEventGuard.alignCachedIdentity(cached, getKey(), getId()); detailStartTime = System.currentTimeMillis(); detailHealthRecorded = true; - if (!shouldRevealShellWhileLoading()) mBinding.progressLayout.showProgress(); + mBinding.progressLayout.showProgress(); SpiderDebug.log("video-flow", "detail cache hit queued key=%s id=%s name=%s", getKey(), getId(), cached.getName()); mBinding.getRoot().postDelayed(() -> { if (isFinishing() || isDestroyed()) return; @@ -2439,9 +1939,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco detailHealthRecorded = false; cancelTmdbDetailFallback(); SpiderDebug.log("video-flow", "detail start key=%s id=%s name=%s refresh=%s", getKey(), getId(), getName(), refresh); - // 骨架已经揭开时不能再整页转圈:那会把刚露出的视频窗口与选集重新压成 INVISIBLE, - // 变回「先整页转一次、再在播放器窗口里转一次」的两层加载。 - if (!shouldRevealShellWhileLoading()) mBinding.progressLayout.showProgress(); + mBinding.progressLayout.showProgress(); prefetchDirectTmdbDetail(); mViewModel.detailContent(getKey(), getId(), refresh); } @@ -2457,8 +1955,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco revealManualSearch = false; if (!isAutoMode()) mViewModel.stopSearch(); saveHistory(); - if (mViewModel != null) mViewModel.cancelPlayerContent(); - invalidatePlayerContent(); getIntent().putExtra("key", item.getSiteKey()); getIntent().putExtra("pic", item.getPic()); getIntent().putExtra("id", item.getId()); @@ -2528,19 +2024,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco item.checkContent(getTmdbVodContent()); item.checkContent(getContent()); applyIntentTmdbVodRemark(item); - TmdbConfig tmdbConfig = TmdbConfig.effectiveCurrent(); - TmdbSourcePayload sourcePayload = TmdbSourcePayloadParser.parse(item.getTmdb()); - TmdbBundle sourceBundle = TmdbSourceAdapter.toBundle(sourcePayload, item, tmdbConfig); - TmdbSourceState sourceState = TmdbSourceAvailability.classify(item, sourcePayload, sourceBundle); - DetailRuntimeModePolicy.Decision decision = DetailRuntimeModePolicy.resolve(new DetailRuntimeModePolicy.Input( - policyConfiguredMode(), - tmdbConfig.isReady(), - TmdbSitePolicy.isEnabled(tmdbConfig, getKey(), getId()), - sourceState - )); - applyRuntimeDetailMode(decision.runtimeMode()); - runtimeSourceOnly = decision.sourceOnly(); - applySourceOnlyActionVisibility(); boolean tmdbMode = shouldLoadTmdbDetail(); mTmdbFallbackToNative = false; mTmdbContentLoaded = false; @@ -2594,27 +2077,20 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco // TMDB 增强:全局开关启用或 Intent 传入 TmdbItem 时触发 if (shouldLoadTmdbDetail()) { mTmdbUIAdapter.setActiveFlag(getFlag()); - if (sourceState == TmdbSourceState.RENDERABLE && sourceBundle != null) { - SpiderDebug.log("tmdb-mobile", "source-first load vodTitle=%s tmdbId=%d media=%s sourceOnly=%s", item.getName(), sourceBundle.item().getTmdbId(), sourceBundle.item().getMediaType(), runtimeSourceOnly); - mTmdbUIAdapter.loadSource(sourceBundle, item, sourcePayload); - } else { - com.fongmi.android.tv.bean.TmdbItem tmdbItem = getTmdbItem(); - if (tmdbItem != null) { + com.fongmi.android.tv.bean.TmdbItem tmdbItem = getTmdbItem(); + if (tmdbItem != null) { // 直接使用传入的 TmdbItem SpiderDebug.log("tmdb-mobile", "direct load vodTitle=%s tmdbTitle=%s tmdbId=%d media=%s", item.getName(), tmdbItem.getTitle(), tmdbItem.getTmdbId(), tmdbItem.getMediaType()); mTmdbUIAdapter.load(tmdbItem, item); - } else { - mTmdbUIAdapter.autoMatch(item.getName(), item, getSearchKeyword()); - } + } else { + mTmdbUIAdapter.autoMatch(item.getName(), item, getSearchKeyword()); } } } private void setText(Vod item) { setDetailLyrics(item.getContent()); - // 富集仍在进行时不填充会被 TMDB 覆盖的站源文本:骨架可以先揭开, - // 但文本要等 TMDB 落定再写,避免揭开后再跳一次。 - if (isTmdbDetailEnrichmentPending()) { + if (shouldWaitForTmdbDetailReveal()) { applyFusionNativeTextColors(); return; } @@ -2644,7 +2120,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean canUseTmdbTabletWideLayout() { - return isLand() && ResUtil.isPad() && shouldUseTmdbDetailLayout() && !isRuntimeFusionMode() && mDefaultFrameParams != null; + return isLand() && ResUtil.isPad() && shouldUseTmdbDetailLayout() && !Setting.isFusionDetailPage() && mDefaultFrameParams != null; } private void applyTmdbTabletVideoLayoutIfNeeded() { @@ -2740,7 +2216,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco view.setText(Sniffer.buildClickable(resId > 0 ? getString(resId, text) : text, this::clickableSpan), TextView.BufferType.SPANNABLE); view.setVisibility(text.isEmpty() ? View.GONE : View.VISIBLE); if (view == mBinding.content) setContentVisible(); - view.setLinkTextColor(isRuntimeFusionMode() && isFusionLightTheme() ? 0xFF1D8F5A : Color.WHITE); + view.setLinkTextColor(Setting.isFusionDetailPage() && isFusionLightTheme() ? 0xFF1D8F5A : Color.WHITE); CustomMovement.bind(view); } @@ -2776,70 +2252,45 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void applyTmdbEpisodeTitles(Vod vod) { java.util.Map titles = getEpisodeTitles(); + android.util.Log.d("VideoActivity", "applyTmdbEpisodeTitles - 集数标题数量: " + titles.size()); if (vod == null || titles.isEmpty() || vod.getFlags() == null) return; - int applied = 0; - boolean includeFileSize = Setting.isTmdbEpisodeFileSize(); for (Flag flag : vod.getFlags()) { for (Episode episode : flag.getEpisodes()) { String title = titles.get(episode.getNumber()); if (android.text.TextUtils.isEmpty(title)) continue; - String displayName = EpisodeTitleFormatter.withSourceFileSize(episode.getName(), EpisodeTitleFormatter.formatTmdbTitle(episode.getNumber(), title), includeFileSize); + String displayName = EpisodeTitleFormatter.withSourceFileSize(episode.getName(), EpisodeTitleFormatter.formatTmdbTitle(episode.getNumber(), title), Setting.isTmdbEpisodeFileSize()); if (android.text.TextUtils.equals(episode.getDisplayName(), displayName)) continue; episode.setDisplayName(displayName); - applied++; + android.util.Log.d("VideoActivity", "应用标题: " + episode.getNumber() + " -> " + title); } } - // 原来每集打一行日志,一部几十集的剧就是几十行主线程 logcat;合并成一行。 - if (applied > 0) Log.d("VideoActivity", "applyTmdbEpisodeTitles applied=" + applied + "/" + titles.size()); } - /** - * intent 里的集数标题在 Activity 生命周期内是不变量:只有 onNewIntent 的 putExtras 会换掉它。 - * 但一次进入播放页这个方法会被 setDetail / 身份解析 / 历史标题解析各调一次(设备实测 3 次), - * 每次 getStringArrayListExtra 都是一次 Parcelable 反序列化,原来还各打一行日志。 - * 这里缓存结果,由 onNewIntent 在 putExtras 之后失效。 - */ private java.util.Map getEpisodeTitles() { - if (episodeTitlesReady) return episodeTitles; java.util.Map titles = new java.util.HashMap<>(); - java.util.ArrayList values = episodeTitleValues(); - if (values != null) { - for (String value : values) { - String[] parts = value.split("\t", 2); - if (parts.length != 2 || android.text.TextUtils.isEmpty(parts[1])) continue; - try { - titles.put(Integer.parseInt(parts[0]), parts[1]); - } catch (NumberFormatException ignored) { - } + java.util.ArrayList values = getIntent().getStringArrayListExtra("tmdb_episode_titles"); + android.util.Log.d("VideoActivity", "getEpisodeTitles - values: " + (values != null ? values.size() : "null")); + if (values == null) return titles; + for (String value : values) { + String[] parts = value.split("\t", 2); + if (parts.length != 2 || android.text.TextUtils.isEmpty(parts[1])) continue; + try { + titles.put(Integer.parseInt(parts[0]), parts[1]); + } catch (NumberFormatException ignored) { } } - episodeTitles = titles; - episodeTitlesReady = true; return titles; } - private java.util.ArrayList episodeTitleValues() { - if (!episodeTitleValuesReady) { - episodeTitleValues = getIntent().getStringArrayListExtra("tmdb_episode_titles"); - episodeTitleValuesReady = true; - } - return episodeTitleValues; - } - - private void invalidateEpisodeTitlesCache() { - episodeTitleValuesReady = false; - episodeTitleValues = null; - episodeTitlesReady = false; - episodeTitles = null; - } - private boolean isIntentTmdbPlayback() { - java.util.ArrayList values = episodeTitleValues(); - return values != null && !values.isEmpty(); + java.util.ArrayList values = getIntent().getStringArrayListExtra("tmdb_episode_titles"); + boolean result = values != null && !values.isEmpty(); + android.util.Log.d("VideoActivity", "isIntentTmdbPlayback: " + result); + return result; } private void getPlayer(Flag flag, Episode episode) { - publishPluginEpisodeContext(episode); + resetAdFeedback(); mBinding.control.title.setText(getPlaybackControlTitle(episode)); playerStartTime = System.currentTimeMillis(); beginPlayHealth(); @@ -2853,411 +2304,14 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco clearLyrics(); clearKaraokeState(); if (shouldUseImmersiveAudio()) setAudioStageVisible(true); - beginPlayerContentRequest(getKey(), playFlag, episode.getUrl()); - mPlaybackFlagKey = playbackFlagKey(flag); - mViewModel.playerContent(getKey(), playFlag, episode.getUrl(), applyHistoryPlayerKernel()); + mViewModel.playerContent(getKey(), playFlag, episode.getUrl()); mBinding.control.title.setSelected(true); updateHistory(episode); showProgress(); } - /** - * 把当前集的剧集身份交给插件通道。 - * - *

挂在起播的唯一收口而不是各个点击处:初次进入、点集、自动连播、切线路与阅读器 - * 换章最后都汇到这一个方法,只有在这里注入才不会有某条路径漏发。上下文是可选增强, - * 任何异常都不许影响播放,所以整段吞掉。 - */ - private void publishPluginEpisodeContext(Episode episode) { - try { - com.fongmi.android.tv.bean.TmdbItem item = mTmdbUIAdapter == null ? null : mTmdbUIAdapter.getTmdbItem(); - if (item == null) item = getTmdbItem(); - com.google.gson.JsonObject context = com.fongmi.android.tv.plugin.PluginEpisodePayload.of(item, mVod, episode); - com.fongmi.android.tv.plugin.PluginEpisodeContext.publish(getClass().getSimpleName(), context); - // episode.changed 与上下文槽共用同一个 of() 结果:插件若从事件读到 A、点击时从槽里读到 B, - // 表现就是"评论串台",而这类错在日志里没有任何痕迹。这里传的是 JsonObject, - // 序列化发生在 helper 内部的 hasSubscribers 短路之后,因此无订阅者时这一行确实零行为。 - com.fongmi.android.tv.plugin.PluginHostEvents.episodeChanged(this, context); - } catch (Throwable ignored) { - // 插件上下文是可选增强:拿不到就当没有,绝不影响播放。 - } - } - - /** - * 回到本页时重新认领剧集上下文。 - * - *

{@code PluginEpisodeContext} 是全局单槽:播放页发布后本页的快照就被覆盖了,而 - * 返回本页不会触发任何起播路径。不在这里补发,用户走过一次「本页 → 别的页 → 返回」 - * 之后,插件 chip 读到的永远是 {@code null}(名字不匹配即 null)——这是永久缺口, - * 不是一次竞态。 - * - *

拿不到当前集时直接返回,不发布:{@code publish(name, null)} 的语义是「清除」, - * 一次没有数据的回归发布会把原本正确的快照擦掉,比不发布更糟。 - */ - private void republishPluginEpisodeContext() { - try { - Episode episode = getEpisode(); - if (episode == null) return; - publishPluginEpisodeContext(episode); - } catch (Throwable ignored) { - // 插件上下文是可选增强:任何异常都不许影响播放页回归。 - } - } - - private void beginPlayerContentRequest(String key, String flag, String episode) { - if (mViewModel != null) mViewModel.cancelPlayerContent(); - mPendingPlayerResult = null; - invalidatePlayerContent(); - playerContentKey = key; - playerContentFlag = flag; - playerContentEpisode = episode; - } - - private boolean isShortDramaQueueEligible(Result result) { - if (!PlaybackExperimentSetting.isAllowed(PlaybackExperimentPolicy.Action.EXO_SHORT_DRAMA_QUEUE)) return false; - if (!isShortDramaSource() || service() == null || player() == null || !player().isExo() - || !player().supportsPlaylistQueue() || !PreloadSetting.isPreload(PlayerSetting.EXO)) return false; - if (MultiThreadProxySetting.get().enabled() || player().isRepeatOne() || mHistory == null - || mHistory.getOpening() > 0 || mHistory.getEnding() > 0 - || pendingResumeSeekMs != C.TIME_UNSET || tmdbHistoryResumePending - || isAudioOnly() || shouldUseImmersiveAudio()) return false; - Flag flag = getFlag(); - if (flag == null || flag.getEpisodes().size() < 2 || result == null || result.hasMsg() - || result.getRealUrl().isEmpty() || result.getDrm() != null - || result.needParse() || result.shouldUseParse()) return false; - String url = result.getRealUrl(); - if (com.fongmi.android.tv.player.exo.MediaSourceFactory.isConcatenatingUrl(url)) return false; - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest( - url, null, result.getFormat()); - if (classification.streamKind() == PlaybackAutoContext.StreamKind.LIVE - || classification.streamKind() == PlaybackAutoContext.StreamKind.LOW_LATENCY_LIVE) return false; - return classification.protocol() == PlaybackAutoContext.Protocol.HLS - || classification.protocol() == PlaybackAutoContext.Protocol.PROGRESSIVE_HTTP; - } - - private ShortDramaQueueCoordinator.Episode shortDramaEpisode(Episode episode, int index) { - return new ShortDramaQueueCoordinator.Episode("", episode == null ? "" : episode.getName(), index); - } - - private ShortDramaQueueCoordinator.MediaKind shortDramaMediaKind(String url, String format) { - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest(url, null, format); - return classification.protocol() == PlaybackAutoContext.Protocol.HLS - ? ShortDramaQueueCoordinator.MediaKind.HLS - : ShortDramaQueueCoordinator.MediaKind.PROGRESSIVE_MP4; - } - - private boolean hasShortDramaStoredPosition(Episode episode) { - if (episode == null || skipEpisodePositionCache()) return false; - EpisodePositionCache.EpisodePosition cached = EpisodePositionCache.get().get( - getKey(), getId(), getFlag().getFlag(), episodePositionCacheName(episode, currentSourceSeasonNumber())); - return cached != null && cached.position > 0; - } - - private MediaMetadata buildShortDramaMetadata(Episode episode) { - String title = mHistory == null ? getName() : mHistory.getVodName(); - String name = episode == null ? "" : episode.getName(); - return PlayerManager.buildMetadata(title, name.isEmpty() || title.equals(name) ? "" : name, - mHistory == null ? "" : mHistory.getVodPic()); - } - - private void beginShortDramaQueue() { - Flag flag = getFlag(); - List items = flag == null ? List.of() : flag.getEpisodes(); - Episode current = getEpisode(); - if (flag == null || current == null || items.size() < 2 || !isShortDramaQueueEligible(mAppliedPlayerResult)) return; - int currentIndex = getSelectedEpisodePosition(items); - List descriptors = new ArrayList<>(items.size()); - for (int i = 0; i < items.size(); i++) descriptors.add(shortDramaEpisode(items.get(i), i)); - ShortDramaQueueCoordinator.Session session = new ShortDramaQueueCoordinator.Session( - getId(), getKey(), flag.getFlag(), getEpisodePlayFlag(flag, current), descriptors, - currentIndex, mHistory.isRevPlay()); - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.begin(session); - mShortDramaQueueFallbackUsed = false; - mShortDramaQueueSessionActive = true; - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - resolveShortDramaNext(snapshot); - } - - private void resolveShortDramaNext(ShortDramaQueueCoordinator.Snapshot snapshot) { - if (snapshot == null || snapshot.session() == null || snapshot.queued() != null || mViewModel == null) return; - ShortDramaQueueCoordinator.ResolveRequest request = snapshot.nextRequest(); - if (request == null) return; - int index = request.episode().index(); - List items = getFlag() == null ? List.of() : getFlag().getEpisodes(); - if (index < 0 || index >= items.size()) return; - Episode target = items.get(index); - if (hasShortDramaStoredPosition(target)) return; - String key = snapshot.session().siteKey(); - String playFlag = snapshot.session().playFlag(); - long generation = request.generation(); - mViewModel.playerContentIsolated(key, playFlag, target.getUrl(), player().getPlayerType(), - result -> enqueueShortDramaResult(generation, request.episode(), target, result), - error -> { - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "resolve failed generation=%d type=%s", generation, error == null ? "unknown" : error.getClass().getSimpleName()); - }); - } - - private void enqueueShortDramaResult(long generation, - ShortDramaQueueCoordinator.Episode descriptor, - Episode target, - Result result) { - if (isFinishing() || isDestroyed() || target == null || !isShortDramaQueueEligible(mAppliedPlayerResult)) return; - if (result == null || result.hasMsg() || result.getRealUrl().isEmpty() || result.getDrm() != null - || result.needParse() || result.shouldUseParse()) return; - String url = result.getRealUrl(); - boolean concatenated = com.fongmi.android.tv.player.exo.MediaSourceFactory.isConcatenatingUrl(url); - PlaybackResourceClassifier.Classification classification = PlaybackResourceClassifier.classifyRequest( - url, null, result.getFormat()); - boolean live = classification.streamKind() == PlaybackAutoContext.StreamKind.LIVE - || classification.streamKind() == PlaybackAutoContext.StreamKind.LOW_LATENCY_LIVE; - boolean hls = classification.protocol() == PlaybackAutoContext.Protocol.HLS; - boolean progressive = classification.protocol() == PlaybackAutoContext.Protocol.PROGRESSIVE_HTTP; - ShortDramaQueueCoordinator.ResolvedItem resolved = new ShortDramaQueueCoordinator.ResolvedItem( - url, new HashMap<>(result.getHeader()), result.getFormat(), - hls ? ShortDramaQueueCoordinator.MediaKind.HLS : progressive ? ShortDramaQueueCoordinator.MediaKind.PROGRESSIVE_MP4 : null, - false, result.needParse() || result.shouldUseParse(), live, false, concatenated, false); - ShortDramaQueueCoordinator.QueueItem item = mShortDramaQueue.acceptResolved(generation, descriptor, resolved); - if (item == null || !resolved.isEligible()) return; - PlaySpec spec = PlaySpec.from(result, activePlaybackKey(), buildShortDramaMetadata(target)).checkUa(); - spec.setHeaders(new HashMap<>(result.getHeader())); - if (!player().appendPlaylistItem(spec, item.mediaId())) { - invalidateShortDramaQueue("append-failed"); - return; - } - mShortDramaQueuedSpec = spec; - mShortDramaQueuedResult = result; - mShortDramaQueuedEpisode = target; - player().setPlaylistPreloadDurationMs(5_000L); - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "append generation=%d index=%d kind=%s", generation, target.getIndex(), resolved.kind()); - } - - private void saveShortDramaEpisodeProgress(Episode episode) { - saveShortDramaEpisodeProgress(episode, - mHistory == null ? C.TIME_UNSET : mHistory.getPosition(), - mHistory == null ? C.TIME_UNSET : mHistory.getDuration()); - } - - private void saveShortDramaEpisodeProgress(Episode episode, long position, long duration) { - if (episode == null || mHistory == null || service() == null || !isOwner() || tmdbHistoryResumePending) return; - String cacheName = episodePositionCacheName(episode, currentSourceSeasonNumber()); - if (!TextUtils.isEmpty(cacheName) && !skipEpisodePositionCache()) { - long savedPosition = position > 0 ? position : mHistory.getPosition(); - long savedDuration = duration > 0 ? duration : mHistory.getDuration(); - EpisodePositionCache.get().put(getKey(), getId(), getFlag().getFlag(), cacheName, - savedPosition, savedDuration); - } - } - - private void updateHistoryAfterShortDramaTransition(Episode item) { - if (mHistory == null || item == null || getFlag() == null) return; - Episode historyEpisode = withSourceSeasonEpisodeIdentity(item); - EpisodePositionCache.EpisodePosition cached = skipEpisodePositionCache() ? null : EpisodePositionCache.get().get( - getKey(), getId(), getFlag().getFlag(), episodePositionCacheName(item, currentSourceSeasonNumber())); - if (cached != null) { - mHistory.setPosition(cached.position); - mHistory.setDuration(cached.duration); - } else { - mHistory.setPosition(C.TIME_UNSET); - mHistory.setDuration(C.TIME_UNSET); - } - setHistoryFlag(getFlag()); - mHistory.setVodRemarks(getHistoryEpisodeName(item)); - mHistory.setEpisodeUrl(item.getUrl()); - if (historyEpisode.getTmdbEpisode() != null) mHistory.setTmdbEpisodePosition(historyEpisode); - PlaybackEventCollector.get().updateHistory(mHistory); - } - - private void commitShortDramaTransition(ShortDramaQueueCoordinator.Transition transition, - Episode current, - Result result, - PlaySpec spec) { - Episode previous = transition.previous() == null ? null : getEpisodeByIndex(transition.previous().index()); - if (previous != null) saveShortDramaEpisodeProgress(previous, - mShortDramaTransitionPositionMs, mShortDramaTransitionDurationMs); - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - Flag flag = getFlag(); - if (flag == null || current == null || result == null || spec == null) { - fallbackShortDramaQueued(); - return; - } - mFlagAdapter.toggle(current); - setEpisodeAdapter(flag.getEpisodes()); - applyAudioQueueMetadata(current); - mBinding.control.title.setText(getPlaybackControlTitle(current)); - mBinding.control.title.setSelected(true); - mInlineLyrics = getEpisodeInlineLyrics(current); - applyPlaybackArtwork(current); - clearLyrics(); - clearKaraokeState(); - if (result.hasDesc()) { - setText(mBinding.content, 0, result.getDesc()); - setPlaybackLyrics(result.getDesc()); - } - mAppliedPlayerResult = result; - mQualityAdapter.addAll(result); - mQualityAdapter.setPosition(mQualityAdapter.getPosition()); - setUseParse(false); - setQualityVisible(result.getUrl().isMulti()); - updateHistoryAfterShortDramaTransition(current); - player().setMetadata(buildShortDramaMetadata(current)); - List siteDanmakus = result.getDanmaku(); - player().setDanmaku(siteDanmakus.isEmpty() ? Danmaku.empty() : siteDanmakus.get(0)); - subtitlePlaybackSession.onPlaybackStarted(this, result); - DanmakuApi.cancel(); - if (DanmakuApi.canAutoSearch(siteDanmakus)) searchShortDramaDanmaku(current, siteDanmakus); - loadTmdbRelatedVideosForCurrentEpisode(); - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaEndedFallbackScheduled = false; - App.removeCallbacks(mShortDramaEndedFallback); - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.snapshot(); - resolveShortDramaNext(snapshot); - } - - private void searchShortDramaDanmaku(Episode episode, List siteDanmakus) { - String episodeUrl = episode == null ? "" : episode.getUrl(); - DanmakuApi.search(MediaTitleRequest.builder() - .siteKey(getKey()).vodId(getId()).rawTitle(mHistory.getVodName()) - .rawRemarks(mHistory.getVodRemarks()).episodeName(episode == null ? "" : episode.getName()) - .tmdbId(danmakuTmdbId()).tmdbSeasonNumber(danmakuTmdbSeasonNumber()) - .source(MediaTitleLearningExample.SOURCE_DANMAKU_AUTO).allowAi(true).build(), danmaku -> { - Episode active = getEpisode(); - if (player() == null || active == null || !TextUtils.equals(active.getUrl(), episodeUrl)) return; - if (DanmakuSetting.isSpiderFirst() && !siteDanmakus.isEmpty()) player().addDanmaku(danmaku); - else player().setDanmaku(danmaku); - refreshDanmakuControls(); - }); - } - - private Episode getEpisodeByIndex(int index) { - Flag flag = getFlag(); - if (flag == null || index < 0 || index >= flag.getEpisodes().size()) return null; - return flag.getEpisodes().get(index); - } - - private void onShortDramaMediaItemTransition(androidx.media3.common.MediaItem mediaItem, int reason) { - if (mediaItem == null || reason != Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) return; - ShortDramaQueueCoordinator.Snapshot snapshot = mShortDramaQueue.snapshot(); - ShortDramaQueueCoordinator.Transition transition = mShortDramaQueue.onMediaItemTransition( - snapshot.generation(), mediaItem.mediaId, ShortDramaQueueCoordinator.TransitionReason.AUTO); - if (transition == null) return; - PlaySpec spec = mShortDramaQueuedSpec; - Result result = mShortDramaQueuedResult; - Episode current = mShortDramaQueuedEpisode; - // Commit changes the manager's current PlaySpec, so close the previous - // episode's playback event before switching to the queued item. - if (service() != null) PlaybackEventCollector.get().onStop(player()); - if (spec == null || result == null || current == null || !player().commitPlaylistTransition(spec)) { - fallbackShortDramaQueued(); - return; - } - commitShortDramaTransition(transition, current, result, spec); - } - - private void invalidateShortDramaQueue(String reason) { - App.removeCallbacks(mShortDramaEndedFallback); - mShortDramaEndedFallbackScheduled = false; - if (mViewModel != null) mViewModel.cancelPlayerContentIsolated(); - if (player() != null && player().isExo()) player().removePlaylistItemsAfterCurrent(); - mShortDramaQueue.invalidate(); - mShortDramaQueueSessionActive = false; - mShortDramaQueuedSpec = null; - mShortDramaQueuedResult = null; - mShortDramaQueuedEpisode = null; - mShortDramaTransitionPositionMs = C.TIME_UNSET; - mShortDramaTransitionDurationMs = C.TIME_UNSET; - if (SpiderDebug.isEnabled()) SpiderDebug.log("short-drama-queue", "invalidate reason=%s", reason); - } - - private void fallbackShortDramaQueued() { - if (mShortDramaQueueFallbackUsed || mShortDramaQueuedEpisode == null) return; - mShortDramaQueueFallbackUsed = true; - Episode target = mShortDramaQueuedEpisode; - saveShortDramaEpisodeProgress(getEpisode()); - invalidateShortDramaQueue("queue-fallback"); - if (!isFinishing() && !isDestroyed() && target != null) onItemClick(target); - } - - private void scheduleShortDramaEndedFallback() { - if (mShortDramaEndedFallbackScheduled) return; - mShortDramaEndedFallbackScheduled = true; - App.post(mShortDramaEndedFallback, 1_500L); - } - - private void invalidatePlayerContent() { - playerContentGeneration++; - playerContentRequestId++; - playerKernelSwitchRequestId++; - decodeSwitchRequestId++; - playerContentKey = ""; - playerContentFlag = ""; - playerContentEpisode = ""; - mPlaybackFlagKey = ""; - decodeSwitchRefreshing = false; - } - - private boolean isCurrentPlayerContentContext(String key, String flag, String episode) { - Flag currentFlag = getFlag(); - Episode currentEpisode = getEpisode(); - return TextUtils.equals(key, getKey()) - && currentFlag != null - && TextUtils.equals(flag, currentFlag.getFlag()) - && currentEpisode != null - && TextUtils.equals(episode, currentEpisode.getUrl()); - } - - private String playbackFlagKey(Flag flag) { - if (flag == null) return ""; - int index = mVod == null ? -1 : com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagIndex(mVod.getFlags(), flag); - if (index < 0 && mFlagAdapter != null) index = mFlagAdapter.indexOf(flag); - if (index < 0) return ""; - return com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, index); - } - - private boolean isPlaybackFlag(Flag flag) { - String key = playbackFlagKey(flag); - return !TextUtils.isEmpty(key) && TextUtils.equals(mPlaybackFlagKey, key); - } - - private boolean isPlayingContent(Episode episode) { - return episode != null && episode.isSelected() && isPlaybackFlag(getFlag()); - } - - private int beginPlayerContentSwitch(int requestId, String key, String flag, String episode) { - playerContentGeneration++; - playerContentRequestId = requestId; - playerContentKey = key; - playerContentFlag = flag; - playerContentEpisode = episode; - return playerContentGeneration; - } - - private boolean isCurrentPlayerContentRequest(int requestId, int generation, - String key, String flag, String episode) { - return !isFinishing() && !isDestroyed() - && requestId == playerContentRequestId - && generation == playerContentGeneration - && isCurrentPlayerContentContext(key, flag, episode); - } - - private boolean canApplyPlayerContentRequest(int requestId, int generation, - String key, String flag, String episode) { - return isCurrentPlayerContentRequest(requestId, generation, key, flag, episode) - && service() != null - && player() != null - && !player().isReleased() - && !player().isEmpty() - && isOwner(); - } - private void setPlayer(Result result) { - if (result == null || isFinishing() || isDestroyed()) return; + if (isFinishing() || isDestroyed()) return; if (service() == null) { mPendingPlayerResult = result; return; @@ -3281,10 +2335,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.parse.setVisibility(isFullscreen() && isUseParse() && PlayerButtonSetting.isVisible(PlayerButtonSetting.PARSE) ? View.VISIBLE : View.GONE); if (redirectToAudioIfNeeded(result)) return; List siteDanmakus = result.getDanmaku(); - mInitialPlaybackPosition = resolveInitialPlaybackPosition(); - SpiderDebug.log("video-flow", "startPlayer dispatch initialPosition=%d music=%s ijk=%s", mInitialPlaybackPosition, isMusicLike(), service() != null && player().isIjk()); - if (SubtitleRestoreCoordinator.restore(mHistory, player(), result)) syncHistory(); - startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata(), mInitialPlaybackPosition); + startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata()); subtitlePlaybackSession.onPlaybackStarted(this, result); if (DanmakuApi.canAutoSearch(siteDanmakus)) DanmakuApi.search(MediaTitleRequest.builder() .siteKey(getKey()) @@ -3292,8 +2343,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco .rawTitle(mHistory.getVodName()) .rawRemarks(mHistory.getVodRemarks()) .episodeName(getEpisode().getName()) - .tmdbId(danmakuTmdbId()) - .tmdbSeasonNumber(danmakuTmdbSeasonNumber()) .source(MediaTitleLearningExample.SOURCE_DANMAKU_AUTO) .allowAi(true) .build(), danmaku -> { @@ -3314,21 +2363,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return handled; } - private int danmakuTmdbId() { - TmdbItem item = mTmdbUIAdapter == null ? null : mTmdbUIAdapter.getTmdbItem(); - if (item == null) item = getTmdbItem(); - return item == null ? 0 : item.getTmdbId(); - } - - private int danmakuTmdbSeasonNumber() { - TmdbItem item = mTmdbUIAdapter == null ? null : mTmdbUIAdapter.getTmdbItem(); - if (item == null) item = getTmdbItem(); - if (item == null || !item.isTv()) return 0; - Episode episode = getEpisode(); - TmdbEpisode tmdbEpisode = episode == null ? null : episode.getTmdbEpisode(); - return tmdbEpisode == null ? 0 : tmdbEpisode.getSeasonNumber(); - } - private void recordDetailHealth(Result result, long cost) { if (detailHealthRecorded) return; detailHealthRecorded = true; @@ -3339,7 +2373,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void beginPlayHealth() { playHealthKey = getKey(); - SiteHealthStore.recordPlayAttempt(playHealthKey); playHealthRecorded = false; } @@ -3352,33 +2385,26 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onItemClick(Flag item) { if (item == null || mFlagAdapter.isEmpty()) return; - invalidateShortDramaQueue("line-switch"); int position = mFlagAdapter.indexOf(item); Flag resolved = mFlagAdapter.get(position < 0 ? 0 : position); boolean initialBinding = mEpisodeAdapter == null || mEpisodeAdapter.isEmpty(); - if (resolved.isSelected() && isPlaybackFlag(resolved) && !initialBinding) return; + if (resolved.isSelected() && !initialBinding) return; Flag previous = getFlag(); SpiderDebug.log("playback-action", "flag switch ui=mobile site=%s from=%s to=%s fullscreen=%s", getKey(), previous == null ? "" : previous.getFlag(), resolved.getFlag(), isFullscreen()); mFlagAdapter.setSelected(resolved); if (mTmdbUIAdapter != null) mTmdbUIAdapter.setActiveFlag(resolved); - rememberFlagSelection(resolved); scrollToPosition(mBinding.flag, mFlagAdapter.getPosition()); - boolean deferFlagSwitch = FlagSwitchPlaybackPolicy.shouldDeferPlayback(PlayerSetting.isDeferFlagSwitchPlayback(), mProgrammaticFlagSwitch); - mDeferFlagSwitchPlayback = deferFlagSwitch && !initialBinding; boolean episodeChanged = seamless(resolved); if (!episodeChanged) setEpisodeAdapter(resolved.getEpisodes()); scrollEpisodeToSelected(); setQualityVisible(false); if (initialBinding && !episodeChanged) onRefresh(); - else if (deferFlagSwitch && !episodeChanged) onRefresh(); loadTmdbRelatedVideosForCurrentEpisode(); - mDeferFlagSwitchPlayback = false; } @Override public void onItemClick(Episode item) { if (shouldEnterFullscreen(item)) return; - invalidateShortDramaQueue("manual-episode"); syncCurrentAudioPlaylistMetadata(); Flag flag = getFlag(); if (mFlagAdapter != null) mFlagAdapter.toggle(item); @@ -3394,24 +2420,16 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco * 含 parse=1 二次解析)。解析结果经 NovelRouter.routeReaderEngine 回传给前台阅读页。 */ @Override - public boolean labPlayEpisode(String chapterUrl) { - if (chapterUrl == null || chapterUrl.isEmpty()) return false; - // 宿主已在销毁中:解析结果不会再回到阅读器,直接报「没发出」让它立刻收尾。 - // 另外 SiteViewModel 被清理后 playerContent 会抛 RejectedExecutionException, - // 那个异常会顺着阅读器的 runOnUiThread 冒出去导致崩溃。 - if (isFinishing() || isDestroyed()) return false; - invalidateShortDramaQueue("reader-episode"); + public void labPlayEpisode(String chapterUrl) { + if (chapterUrl == null || chapterUrl.isEmpty()) return; Flag flag = getFlag(); - if (flag == null || flag.getEpisodes() == null) return false; + if (flag == null || flag.getEpisodes() == null) return; for (Episode ep : flag.getEpisodes()) { if (chapterUrl.equals(ep.getUrl())) { getPlayer(flag, ep); - return true; + return; } } - // 章节不在当前线路:阅读器的章节表跨线路合并,这种情况静默返回, - // 告知调用方没有发出请求,让它立刻收尾在途标记。 - return false; } @Override @@ -3428,11 +2446,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onItemClick(Result result) { - invalidateShortDramaQueue("quality"); + // 画质列表直点也会换 URL,且不经 changeQuality + resetAdFeedback(); updateActionQuality(result); beginPlayHealth(); - // 切清晰度也会重建 spec,字幕列表跟着重置,所以这里同样要恢复一次。 - if (SubtitleRestoreCoordinator.restore(mHistory, player(), result)) syncHistory(); startPlayer(getHistoryKey(), result, isUseParse(), getSite().getTimeout(), buildMetadata()); subtitlePlaybackSession.onPlaybackStarted(this, result); } @@ -3446,7 +2463,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onItemClick(Parse item) { - invalidateShortDramaQueue("parse"); setParse(item); onRefresh(); } @@ -3496,7 +2512,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean shouldUseUpstreamNativeEpisodeModule() { - return isRuntimeDirectMode() && !isTmdbMode(); + return Setting.isDirectDetailPage() && !isTmdbMode(); } private void setUpstreamNativeEpisodeItems(List items) { @@ -3598,8 +2614,11 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void updateEpisodeLayout(List items, boolean useTmdbCard) { if (!mEpisodeGridMode) { RecyclerView.LayoutManager manager = mBinding.episode.getLayoutManager(); - if (!(manager instanceof LinearLayoutManager) || manager instanceof GridLayoutManager || ((LinearLayoutManager) manager).getOrientation() != LinearLayoutManager.HORIZONTAL) { - mBinding.episode.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)); + if (!(manager instanceof FlexboxLayoutManager)) { + FlexboxLayoutManager layoutManager = new FlexboxLayoutManager(this); + layoutManager.setFlexWrap(FlexWrap.WRAP); + layoutManager.setJustifyContent(JustifyContent.CENTER); + mBinding.episode.setLayoutManager(layoutManager); } updateEpisodeDecoration(new SpaceItemDecoration(8)); return; @@ -3644,7 +2663,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean isOriginalEnhancedEpisodeFallback() { - return isRuntimeOriginalEnhancedMode() && isTmdbSourceEnabled(); + return Setting.isOriginalEnhancedDetailPage() && isTmdbSourceEnabled(); } private List getEpisodeDisplayItems(List items) { @@ -3670,19 +2689,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private int getSelectedEpisodePosition(List items) { for (int i = 0; i < items.size(); i++) if (items.get(i).isSelected()) return i; - if (mHistory == null) return 0; - Episode historyEpisode = mHistory.getEpisode(); - // 与 leanback 对齐:历史集 URL 能定位到当前列表条目时先按 URL 锁定版本(同集多版本并存), - // 换线路/换源/源站刷新后 URL 必然失配,交回下面的集号容错。 - for (int i = 0; i < items.size(); i++) { - if (TextUtils.isEmpty(historyEpisode.getUrl())) break; - if (!TextUtils.equals(historyEpisode.getUrl(), items.get(i).getUrl())) continue; - if (items.get(i).getTmdbEpisode() != null && items.get(i).getTmdbEpisode().getNumber() > 0 - && historyEpisode.getTmdbEpisode() != null && historyEpisode.getTmdbEpisode().getNumber() > 0 - && !items.get(i).matchesNumber(historyEpisode)) continue; - return i; - } - for (int i = 0; i < items.size(); i++) if (items.get(i).matchesPlayback(historyEpisode)) return i; return 0; } @@ -3697,20 +2703,11 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean seamless(Flag flag) { - // 与 leanback 对齐:先按历史集身份定位(URL 优先、集号容错),刮削标题只作兜底。 - Episode episode = mHistory == null ? null : flag.find(mHistory.getEpisode(), true); - if (episode == null && !getMark().isEmpty()) episode = flag.find(getMark(), false); + Episode episode = getMark().isEmpty() ? flag.find(mHistory.getEpisode(), true) : flag.find(mHistory.getVodRemarks(), false); setQualityVisible(episode != null && episode.isSelected() && mQualityAdapter.getItemCount() > 1); - if (episode == null || (isPlaybackFlag(flag) && episode.isSelected())) return false; - // 防御性守卫:mHistory 在历史创建/恢复之后恒非空(:1379 定位结果、:6010 resumeHistory、:6013 createHistory 兜底), - // 此处仅防止早期回调路径在历史尚未建立时解引用;返回 false 表示未处理。 - if (mHistory == null) return false; - // 只有定位到的集与历史身份一致(或历史尚无 URL)时才回写,避免把错版本固化成下次续播目标。 - boolean sameHistoryIdentity = TextUtils.isEmpty(mHistory.getEpisodeUrl()) || episode.matchesPlayback(mHistory.getEpisode(), flag.containsEpisodeUrl(mHistory.getEpisode())); - // 集名与 URL 同为身份键(Flag.find 按 remarks 打分、History.matchesEpisode 严格比对),必须同进同退, - // 否则会落库 (remarks=兜底集标题, url=旧URL) 的矛盾身份。 - if (sameHistoryIdentity) mHistory.setVodRemarks(getHistoryEpisodeName(episode)); - if (sameHistoryIdentity) mHistory.setEpisodeUrl(episode.getUrl()); + if (episode == null || episode.isSelected()) return false; + mHistory.setVodRemarks(getHistoryEpisodeName(episode)); + mHistory.setEpisodeUrl(episode.getUrl()); onItemClick(episode); return true; } @@ -3749,7 +2746,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void onQuality() { Result result = mViewModel.getPlayer().getValue(); if (result == null || !result.getUrl().isMulti()) return; - new WebHtvAlertDialogBuilder(this) + new com.google.android.material.dialog.MaterialAlertDialogBuilder(this) .setTitle(R.string.detail_quality) .setSingleChoiceItems(getQualityItems(result), result.getUrl().getPosition(), (dialog, which) -> { dialog.dismiss(); @@ -3764,6 +2761,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco updateActionQuality(result); return; } + // 换画质同集换 URL:切片结构与在途归因都属于上一画质 + resetAdFeedback(); mQualityAdapter.setPosition(position); updateActionQuality(result); onItemClick(result); @@ -3843,7 +2842,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onReverse() { - invalidateShortDramaQueue("reverse-sort"); mHistory.setRevSort(!mHistory.isRevSort()); reverseEpisode(false); } @@ -3993,36 +2991,21 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void checkNext(boolean notify) { - advanceEpisode(notify); - } - - /** @return 是否真的切走了。末集切不动,调用方据此决定要不要提示「进入下一集」。 */ - private boolean advanceEpisode(boolean notify) { setR1Callback(); - int offset = mHistory != null && mHistory.isRevPlay() ? -1 : 1; - Episode item = getAdjacentEpisode(offset); - if (!item.isSelected()) { - onItemClick(item); - return true; - } - if (notify) Notify.show(offset > 0 ? R.string.error_play_next : R.string.error_play_prev); - return false; + Episode item = getAdjacentEpisode(1); + if (!item.isSelected()) onItemClick(item); + else if (notify) Notify.show(R.string.error_play_next); } private void checkPrev() { - checkPrev(true); - } - - private void checkPrev(boolean notify) { setR1Callback(); - int offset = mHistory != null && mHistory.isRevPlay() ? 1 : -1; - Episode item = getAdjacentEpisode(offset); + Episode item = getAdjacentEpisode(-1); if (!item.isSelected()) onItemClick(item); - else if (notify) Notify.show(offset > 0 ? R.string.error_play_next : R.string.error_play_prev); + else Notify.show(R.string.error_play_prev); } private Episode getAdjacentEpisode(int offset) { - List items = mFlagAdapter == null || mFlagAdapter.isEmpty() ? getCurrentEpisodeItems() : getFlag().getEpisodes(); + List items = mFlagAdapter == null || mFlagAdapter.isEmpty() ? mEpisodeAdapter.getItems() : getFlag().getEpisodes(); if (items.isEmpty()) return new Episode(); int position = getSelectedEpisodePosition(items) + offset; position = Math.max(0, Math.min(position, items.size() - 1)); @@ -4030,7 +3013,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onSetting() { - setTrackVisible(); ControlDialog.create().parent(mBinding).history(mHistory).parse(isUseParse()).player(player()).show(this); } @@ -4045,9 +3027,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void onRotate() { setR1Callback(); setRotate(!isRotate()); - // 用户显式选择的方向要压过 checkOrientation 的自动纠正:切下一集时新视频的尺寸回调 - // 会再次进来,否则「横屏视频手动转竖屏」会被当成过时状态立刻改回横屏。 - manualRotate = true; setRequestedOrientation(PlaybackOrientation.getRotateOrientation(isRotate())); } @@ -4091,7 +3070,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onDanmaku() { - DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).tmdb(danmakuTmdbId(), danmakuTmdbSeasonNumber()).show(this); + DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).show(this); hideControl(); } @@ -4101,7 +3080,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(); @@ -4110,7 +3089,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onDanmakuPanel() { - DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).tmdb(danmakuTmdbId(), danmakuTmdbSeasonNumber()).show(this); + DanmakuDialog.create().player(player()).identity(getKey(), getId(), mHistory == null ? "" : mHistory.getVodName(), getDanmakuEpisodeName()).show(this); } @Override @@ -5051,7 +4030,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco if (result == null || isFinishing() || isDestroyed()) return; if (mKaraokeResultDialog != null && mKaraokeResultDialog.isShowing()) return; KaraokeResultView view = new KaraokeResultView(this).setResult(result); - AlertDialog dialog = new WebHtvAlertDialogBuilder(this, R.style.ThemeOverlay_WebHTV_Dialog).setView(view).create(); + AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_WebHTV_LightDialog).setView(view).create(); view.setAction(() -> { dialog.dismiss(); completeKaraokeResult(action); @@ -5152,8 +4131,13 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return isFullscreen(); } + private void onDanmakuShow() { + DanmakuSetting.putShow(!DanmakuSetting.isShow()); + checkDanmakuImg(); + showDanmaku(); + } + private void onRepeat() { - invalidateShortDramaQueue("repeat"); player().setRepeatOne(!player().isRepeatOne()); mBinding.control.action.repeat.setSelected(player().isRepeatOne()); } @@ -5236,12 +4220,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onSpeed() { - PlaybackSpeedDialog.show(this, player().getSpeed(), speed -> { - if (!isServiceReady() || !isOwner() || player().isEmpty()) return; - mBinding.control.action.speed.setText(player().setSpeed(speed)); - saveUserSpeed(); - setR1Callback(); - }); + mBinding.control.action.speed.setText(player().addSpeed()); + saveUserSpeed(); + setR1Callback(); } private boolean onSpeedLong() { @@ -5268,18 +4249,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void onRefresh() { saveHistory(); - // 「切换线路不自动播放」:线路点击只换线路/选集列表,旧流继续播。 - // 抑制必须先于拆流,否则用户还没点具体集数,正在看的视频就先被停掉了; - // 但仍在途的旧线路取流结果必须就地作废,否则它稍后返回会替用户自动起播旧线路。 - if (mDeferFlagSwitchPlayback) { - mDeferFlagSwitchPlayback = false; - if (mViewModel != null) mViewModel.cancelPlayerContent(); - invalidatePlayerContent(); - return; - } - invalidateShortDramaQueue("refresh"); - if (mViewModel != null) mViewModel.cancelPlayerContent(); - invalidatePlayerContent(); subtitlePlaybackSession.stop(this); player().stop(); player().clear(); @@ -5296,7 +4265,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onDecode() { - invalidateShortDramaQueue("decode"); if (refreshAndSwitchDecode()) return; mClock.setCallback(null); player().toggleDecode(); @@ -5314,8 +4282,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco String flag = getFlag().getFlag(); String episode = getEpisode().getUrl(); MediaMetadata metadata = buildMetadata(); - int requestId = ++decodeSwitchRequestId; - int generation = beginPlayerContentSwitch(requestId, key, flag, episode); decodeSwitchRefreshing = true; setNextDecodeText(); setDecodeSwitchPending(true); @@ -5324,10 +4290,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco Task.execute(() -> { try { Result result = SiteApi.playerContent(key, flag, episode); - App.post(() -> switchDecodeWithResult(requestId, generation, key, flag, episode, result, position, speed, repeat, metadata)); + App.post(() -> switchDecodeWithResult(result, position, speed, repeat, metadata)); } catch (Throwable e) { App.post(() -> { - if (!isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return; decodeSwitchRefreshing = false; setDecodeSwitchPending(false); setDecode(); @@ -5338,19 +4303,17 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return true; } - private void switchDecodeWithResult(int requestId, int generation, String key, String flag, String episode, - Result result, long position, float speed, boolean repeat, MediaMetadata metadata) { - if (requestId != decodeSwitchRequestId - || !isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return; + private void switchDecodeWithResult(Result result, long position, float speed, boolean repeat, MediaMetadata metadata) { decodeSwitchRefreshing = false; - setDecodeSwitchPending(false); - if (!canApplyPlayerContentRequest(requestId, generation, key, flag, episode)) return; + // 换解码可能重新解析出不同 URL,与换画质同理 + resetAdFeedback(); if (result == null || result.hasMsg() || result.getRealUrl().isEmpty()) { player().toggleDecode(); } else { - player().switchDecode(result, activePlaybackKey(), metadata, isUseParse(), position, speed, repeat); + player().switchDecode(result, getHistoryKey(), metadata, isUseParse(), position, speed, repeat); } setR1Callback(); + setDecodeSwitchPending(false); setDecode(); } @@ -5363,43 +4326,13 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private boolean onEndingReset() { setR1Callback(); - mIntroSkipPlayback.suppressDetected(false); setEnding(0); return true; } private void setEnding(long ending) { mHistory.setEnding(ending); - setOpeningEndingText(); - } - - /** - * 刷新片头/片尾按钮文案。手设值优先,其次显示自动探测值(带 ~ 前缀区分,不落库)。 - */ - private void setOpeningEndingText() { - if (mBinding == null || mHistory == null) return; - mBinding.control.action.opening.setText(openingLabel()); - mBinding.control.action.ending.setText(endingLabel()); - } - - private String openingLabel() { - if (mHistory.getOpening() > 0) return Util.timeMs(mHistory.getOpening()); - long detected = detectedIntroSkipValue(true); - return detected > 0 ? getString(R.string.intro_skip_detected_value, Util.timeMs(detected)) : getString(R.string.play_op); - } - - private String endingLabel() { - if (mHistory.getEnding() > 0) return Util.timeMs(mHistory.getEnding()); - long detected = detectedIntroSkipValue(false); - return detected > 0 ? getString(R.string.intro_skip_detected_value, Util.timeMs(detected)) : getString(R.string.play_ed); - } - - /** 关掉自动跳过时不显示探测值——那种情况下这个数字不会导致任何动作,显示出来是误导。 */ - private long detectedIntroSkipValue(boolean opening) { - // isReleased 必查:服务已 release 但 Activity 还握着 mService 的窗口里, - // PlayerManager.getDuration() 会直接对空的 player 取值抛 NPE - if (!Setting.isIntroSkipEnabled() || player() == null || player().isReleased()) return -1; - return opening ? mIntroSkipPlayback.getDetectedOpeningMs() : mIntroSkipPlayback.getDetectedEndingMs(player().getDuration()); + mBinding.control.action.ending.setText(ending <= 0 ? getString(R.string.play_ed) : Util.timeMs(mHistory.getEnding())); } private void onOpening() { @@ -5411,15 +4344,13 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private boolean onOpeningReset() { setR1Callback(); - // 长按清空是「这里不要有值」,别紧接着又把探测值渲染上去,看起来像没清掉 - mIntroSkipPlayback.suppressDetected(true); setOpening(0); return true; } private void setOpening(long opening) { mHistory.setOpening(opening); - setOpeningEndingText(); + mBinding.control.action.opening.setText(opening <= 0 ? getString(R.string.play_op) : Util.timeMs(mHistory.getOpening())); } private void onEpisodes() { @@ -5447,7 +4378,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco String[] items = new String[kernel.length + 1]; System.arraycopy(kernel, 0, items, 0, kernel.length); items[kernel.length] = getString(R.string.player_kernel_external); - new WebHtvAlertDialogBuilder(this).setItems(items, (dialog, index) -> { + new com.google.android.material.dialog.MaterialAlertDialogBuilder(this).setItems(items, (dialog, index) -> { if (index < kernel.length) { int which = PlayerSetting.kernelAt(index); if (!refreshAndSwitchPlayerKernel(which)) { @@ -5455,7 +4386,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco player().switchPlayerManually(which); setPlayer(); setDecode(); - rememberPlayerKernel(which); } } else { playerKernelSwitchRequestId++; @@ -5466,7 +4396,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onPlayerKernel() { - invalidateShortDramaQueue("kernel"); mClock.setCallback(null); onChoose(); setR1Callback(); @@ -5490,17 +4419,15 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco String flag = currentFlag.getFlag(); String episode = currentEpisode.getUrl(); MediaMetadata metadata = buildMetadata(); - int generation = beginPlayerContentSwitch(requestId, key, flag, episode); mClock.setCallback(null); SpiderDebug.log("video-flow", "switch player refresh start type=%d key=%s flag=%s episode=%s", nextType, key, flag, episode); Task.execute(() -> { try { Result result = SiteApi.playerContent(key, flag, episode, nextType); - App.post(() -> switchPlayerKernelWithResult(requestId, generation, key, flag, episode, nextType, result, position, speed, repeat, metadata)); + App.post(() -> switchPlayerKernelWithResult(requestId, nextType, result, position, speed, repeat, metadata)); } catch (Throwable e) { App.post(() -> { - if (requestId != playerKernelSwitchRequestId - || !isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return; + if (requestId != playerKernelSwitchRequestId) return; setPlayerKernel(); setDecode(); setR1Callback(); @@ -5511,16 +4438,14 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return true; } - private void switchPlayerKernelWithResult(int requestId, int generation, String key, String flag, String episode, - int type, Result result, long position, float speed, boolean repeat, MediaMetadata metadata) { - if (requestId != playerKernelSwitchRequestId - || !isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return; - if (!canApplyPlayerContentRequest(requestId, generation, key, flag, episode)) return; + private void switchPlayerKernelWithResult(int requestId, int type, Result result, long position, float speed, boolean repeat, MediaMetadata metadata) { + if (requestId != playerKernelSwitchRequestId) return; + // 换内核会重新解析出可能不同的 URL,与换画质/换解码同理 + resetAdFeedback(); if (result == null || result.hasMsg() || result.getRealUrl().isEmpty()) { Notify.show(result != null && result.hasMsg() ? result.getMsg() : getString(R.string.error_play_url)); } else { - player().switchPlayer(type, result, activePlaybackKey(), metadata, isUseParse(), position, speed, repeat); - rememberPlayerKernel(type); + player().switchPlayer(type, result, getHistoryKey(), metadata, isUseParse(), position, speed, repeat); } setPlayerKernel(); setDecode(); @@ -5549,7 +4474,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private boolean shouldEnterFullscreen(Episode item) { - boolean enter = !isFullscreen() && isPlayingContent(item); + boolean enter = !isFullscreen() && item.isSelected(); if (enter) enterFullscreen(); return enter; } @@ -5571,12 +4496,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.title.setVisibility(View.VISIBLE); setSizeText(); setRotate(current.isPortrait()); - // 重新进全屏就按当前视频形态重建自动基线,手动覆盖到此为止,避免 rotate 与标记互相矛盾。 - manualRotate = false; mKeyDown.resetScale(); - // 短剧会话退出全屏后再双击/点全屏按钮进来不走 enterShortDramaFullscreen, - // 这里也要按新形态重算,否则回到全屏时手势还是长视频那套轴向。 - syncShortDramaGesture(); App.post(mR3, 2000); hideControl(); logVideoFrame("enterFullscreen after"); @@ -5611,12 +4531,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.video.setLayoutParams(mFrameParams); restoreEmbeddedVideoLayoutAfterFullscreen(); mKeyDown.resetScale(); - // 退出全屏就交还短剧手势:内嵌小窗上竖滑要留给详情页滚动,不能再切集。 - syncShortDramaGesture(); App.post(mR3, 2000); setRotate(false); - // 退出全屏即结束本次手动覆盖,下次进全屏按视频形态重新建立自动基线。 - manualRotate = false; hideControl(); logVideoFrame("exitFullscreen after"); } @@ -5681,20 +4597,15 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return; } - // seek 窗口开着时不能摘掉它的最小可见计时器:那次 showProgress 属于 seek 自身 - // (BUFFERING 分支),计时器是窗口唯一的收尾路径。 - if (!mSeekProgressPending && mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); + if (mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); mBinding.progress.getRoot().setVisibility(View.VISIBLE); - if (mVod == null && shouldLoadTmdbDetail() && !mTmdbContentLoaded && !shouldRevealShellWhileLoading()) mBinding.progressLayout.showProgress(); + if (mVod == null && shouldLoadTmdbDetail() && !mTmdbContentLoaded) mBinding.progressLayout.showProgress(); else if (mVod != null && !mBinding.progressLayout.isContent()) mBinding.progressLayout.showContent(); App.post(mR2, 0); hideError(); } private void hideProgress() { - // 任何显式收圈都同时关闭 seek 窗口,避免窗口比圈活得更久。 - mSeekProgressPending = false; - mSeekProgressStartedAtMs = 0; if (mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); mBinding.progress.getRoot().setVisibility(View.GONE); App.removeCallbacks(mR2); @@ -5730,13 +4641,13 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void showError(String text) { mBinding.widget.error.setVisibility(View.VISIBLE); - mBinding.widget.errorText.setText(text); + mBinding.widget.error.setText(text); hideProgress(); } private void hideError() { mBinding.widget.error.setVisibility(View.GONE); - mBinding.widget.errorText.setText(""); + mBinding.widget.error.setText(""); } private void showDanmaku() { @@ -5751,20 +4662,18 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.action.danmaku.setVisibility(DanmakuSetting.isLoad() ? View.VISIBLE : View.GONE); mBinding.control.action.adFeedback.setVisibility(isAdFeedbackEnabled() ? View.VISIBLE : View.GONE); applyActionButtonVisibility(); + // 顶部弹幕图标只根据锁定状态和弹幕可用性显示。 + if (mBinding.control.getRoot().getVisibility() == View.VISIBLE) mBinding.control.danmaku.setVisibility(isLock() || !player().haveDanmaku() ? View.GONE : View.VISIBLE); } private void showControl() { if (service() == null || isInPictureInPictureMode()) return; - if (mAudioStageVisible && !isFullscreen()) { - hideWidgetOverlay(); - hideControl(); - return; - } - setTrackVisible(); setOsdSuppressed(true); boolean shortDrama = isShortDramaSession(); boolean showPiP = canShowPiP(shortDrama); hideWidgetOverlay(); + // 顶部弹幕图标只根据锁定状态和弹幕可用性显示。 + mBinding.control.danmaku.setVisibility(isLock() || !player().haveDanmaku() ? View.GONE : View.VISIBLE); mBinding.control.setting.setVisibility(mHistory == null || (isFullscreen() && !shortDrama) ? View.GONE : View.VISIBLE); mBinding.control.right.getRoot().setVisibility(isFullscreen() || showPiP ? View.VISIBLE : View.GONE); mBinding.control.right.rotate.setVisibility(isFullscreen() && !isLock() ? View.VISIBLE : View.GONE); @@ -5773,9 +4682,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.fullscreen.setVisibility(isLock() || shortDrama ? View.GONE : View.VISIBLE); mBinding.control.keep.setVisibility(mHistory == null ? View.GONE : View.VISIBLE); mBinding.control.nightMode.setVisibility(mHistory == null ? View.GONE : View.VISIBLE); - // 电池+时间与沉浸融合模式内联播放器同规则:全屏、未锁定且已开始播放才显示。 - mBinding.control.batteryInfo.setVisibility(isFullscreen() && !isLock() && mHistory != null && !player().isEmpty() ? View.VISIBLE : View.GONE); - updateBatteryInfo(); boolean showPlayParams = PlayerButtonSetting.isVisible(PlayerButtonSetting.PLAY_PARAMS); mBinding.control.action.playParams.setVisibility(showPlayParams ? View.VISIBLE : View.GONE); mBinding.control.action.playParams.setSelected(mOsd != null && mOsd.isDiagnosticsVisible()); @@ -5798,8 +4704,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.top.setVisibility(isLock() ? View.GONE : View.VISIBLE); syncShortDramaControlLayout(shortDrama); mBinding.control.getRoot().setVisibility(View.VISIBLE); - updateDiscMenuTools(); - updateCustomButtonVisibility(); if (mOsd != null) mOsd.setControlsVisible(true); checkFullscreenImg(); mBinding.control.getRoot().post(() -> PlayerControlFocusHelper.ensureFocus(mBinding.control.getRoot(), mBinding.control.play)); @@ -5812,8 +4716,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void hideControl() { mBinding.control.getRoot().setVisibility(View.GONE); - updateDiscMenuTools(); - updateCustomButtonVisibility(); if (mOsd != null) mOsd.setControlsVisible(false); App.removeCallbacks(mR1); setOsdSuppressed(false); @@ -5821,7 +4723,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public boolean dispatchKeyEvent(KeyEvent event) { - if (dispatchDiscMenuKey(event)) return true; if (isVisible(mBinding.control.getRoot()) && PlayerControlFocusHelper.handleKey(mBinding.control.getRoot(), mBinding.control.play, event)) return true; return super.dispatchKeyEvent(event); } @@ -5841,7 +4742,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onMultiThreadProxySaved(boolean applyNow) { - invalidateShortDramaQueue("multi-thread-proxy"); setPlayParamsState(); if (applyNow && player() != null && !player().isEmpty()) player().reloadCurrentMediaItem(); } @@ -5875,26 +4775,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void setTraffic() { Traffic.setSpeed(mBinding.progress.traffic, service() == null ? null : player()); - hidePlaybackProgressIfStale(); App.post(mR2, 1000); } - /** - * 刷新控制栏的电池+时间块(与沉浸融合模式 TmdbDetailActivity.updateMobileInlineControlStatus - * 同源:都走 BatteryUtil 与 Formatters.TIME)。容器隐藏时直接返回,避免无谓地注册 - * ACTION_BATTERY_CHANGED 粘性广播;电量读不到(level < 0)时整条隐藏,不显示错误档位。 - */ - private void updateBatteryInfo() { - if (!isVisible(mBinding.control.batteryInfo)) return; - mBinding.control.batteryTime.setText(LocalDateTime.now().format(Formatters.TIME)); - int level = BatteryUtil.getLevel(this); - if (level < 0) { - mBinding.control.batteryInfo.setVisibility(View.GONE); - return; - } - mBinding.control.battery.setImageResource(BatteryUtil.getIcon(level)); - } - private void setOrient() { if (isPort() && isAutoRotate()) setRequestedOrientation(PlaybackOrientation.getPortAutoRotateOrientation()); if (isLand() && isAutoRotate()) setRequestedOrientation(PlaybackOrientation.getLandAutoRotateOrientation()); @@ -5973,7 +4856,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void setContextWall(String url, boolean skipLock) { - if (!Setting.isPlaybackArtworkWall() && !isRuntimeFusionMode() && !shouldUseTmdbBackdropSurface()) { + if (!Setting.isPlaybackArtworkWall() && !Setting.isFusionDetailPage() && !shouldUseTmdbBackdropSurface()) { mContextWallUrl = ""; hideContextWall(); return; @@ -6023,7 +4906,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void restoreContextWall() { - if (!Setting.isPlaybackArtworkWall() && !isRuntimeFusionMode() && !shouldUseTmdbBackdropSurface()) return; + if (!Setting.isPlaybackArtworkWall() && !Setting.isFusionDetailPage() && !shouldUseTmdbBackdropSurface()) return; String wall = getContextWall(); if (TextUtils.isEmpty(wall)) { hideContextWall(); @@ -6056,9 +4939,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco applyImmersiveAudioSelection(mImmersiveAudioResolved); if (mHistory.isRevSort()) reverseEpisode(true); } else { - mProgrammaticFlagSwitch = true; onItemClick(resolveHistoryPlaybackFlag(item.getFlags())); - mProgrammaticFlagSwitch = false; if (mHistory.isRevSort()) reverseEpisode(true); } if (preservePlayback) SpiderDebug.log("karaoke-result", "configuration restore preserved playback key=%s episode=%s", player().getKey(), mHistory.getVodRemarks()); @@ -6081,11 +4962,12 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco if (!TextUtils.isEmpty(getMark())) mHistory.setVodRemarks(getMark()); applyIntentPlaybackSelection(item); if (resumeHistory == null && Setting.isIncognito() && mHistory.getKey().equals(getHistoryKey())) mHistory.delete(); - setOpeningEndingText(); + mBinding.control.action.opening.setText(mHistory.getOpening() <= 0 ? getString(R.string.play_op) : Util.timeMs(mHistory.getOpening())); + mBinding.control.action.ending.setText(mHistory.getEnding() <= 0 ? getString(R.string.play_ed) : Util.timeMs(mHistory.getEnding())); // 如果历史记录中已有有效倍速,使用历史倍速;否则使用默认播放倍速 float speed = getPlaybackSpeed(); mBinding.control.action.speed.setText(player().setSpeed(speed)); - mHistory.setVodName(historyVodName(item)); + mHistory.setVodName(item.getName()); mHistory.setVodPic(getInitialArtwork(item)); enrichHistoryMeta(item); PlaybackEventCollector.get().updateHistory(mHistory); @@ -6169,22 +5051,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return !getName().isEmpty() || !getPic().isEmpty() || !getWallPic().isEmpty(); } - /** TMDB 富集是否仍在进行:决定要不要先填充会被 TMDB 覆盖的站源文本。 */ - private boolean isTmdbDetailEnrichmentPending() { - return shouldLoadTmdbDetail() && !mTmdbContentLoaded && !mTmdbFallbackToNative; - } - private boolean shouldWaitForTmdbDetailReveal() { - return isTmdbDetailEnrichmentPending() && !shouldRevealShellWhileLoading(); - } - - /** - * 原生增强以及从独立 TMDB 详情页直达播放时都立即揭开页面骨架, - * 加载态只由播放器窗口内那一层表达,避免整页先显示主题色遮盖层。 - */ - private boolean shouldRevealShellWhileLoading() { - // 影视原生与原生增强一样由播放器窗口表达加载态,避免进场后整页再转一次。 - return isRuntimeOriginalEnhancedMode() || isRuntimeDirectMode() || getIntent().hasExtra(EXTRA_TMDB_DETAIL_THEME); + return shouldLoadTmdbDetail() && !mTmdbContentLoaded && !mTmdbFallbackToNative; } private boolean canRevealPlaybackContent() { @@ -6192,33 +5060,15 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void showInitialPreview() { - // 原生增强把详情与播放放在同一页:进入即揭开骨架并填上 intent 已知的标题, - // 否则 ProgressLayout 停在初始态、随后被 getDetail 的整页转圈盖掉。 - if (shouldRevealShellWhileLoading()) { - if (!mBinding.progressLayout.isContent()) mBinding.progressLayout.showContent(); - if (!getName().isEmpty()) mBinding.name.setText(getName()); - } if (!getPic().isEmpty()) setArtwork(getPic()); else if (!getWallPic().isEmpty()) setContextWall(getWallPic()); } - /** - * 推送片名:真实片名在 Intent 里,源站 Vod 常常只有裸地址,直接用它弹幕必然搜不到。 - */ - private String historyVodName(Vod item) { - String name = item == null ? "" : item.getName(); - if (!SiteApi.PUSH.equals(getKey())) return name; - PushParser.Parsed push = PushParser.fromId(getId()); - if (push.hasExplicitTitle()) return push.getTitle(); - String intentName = getName(); - return TmdbMatchPolicy.shouldAutoMatchPushTitle(intentName) ? intentName : name; - } - private History createHistory(Vod item) { History history = new History(); history.setKey(getHistoryKey()); history.setCid(VodConfig.getCid()); - history.setVodName(historyVodName(item)); + history.setVodName(item.getName()); history.setVodPic(getInitialArtwork(item)); history.setWallPic(getWallPic()); history.findEpisode(item.getFlags()); @@ -6240,10 +5090,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco boolean crossSource = mHistory.isCrossSourcePlayback(); boolean shareEpisodeProgress = crossSource || isResumeFromHistory() || Setting.isHistoryAggregationEffective(); boolean compatibleFlag = shareEpisodeProgress || TextUtils.equals(mHistory.getVodFlag(), flag.getFlag()); - // 历史集 URL 能定位到当前线路条目(同集多版本并存)时才启用版本消歧;换线路/刷新保留集号容错。 - boolean versionAware = flag.containsEpisodeUrl(mHistory.getEpisode()); boolean sameEpisode = episode != null && (shareEpisodeProgress - ? historyEpisode.matchesPlayback(mHistory.getEpisode(), versionAware) + ? historyEpisode.matchesPlayback(mHistory.getEpisode()) : episode.matches(mHistory.getEpisode())); if (!compatibleFlag || (episode != null && !sameEpisode)) { mHistory.setPosition(C.TIME_UNSET); @@ -6314,10 +5162,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void updateHistory(Episode item) { // 换线路或源站刷新时同一集的 URL、集名格式可能变化,统一按播放恢复规则识别。 Episode historyEpisode = withSourceSeasonEpisodeIdentity(item); - // 历史里那集的 URL 仍能定位到当前线路条目时(同集多版本并存)才启用版本消歧; - // 换线路/换源/源站刷新后 URL 必然失配,必须保留集号容错,否则跨线路续播会丢失进度。 - boolean versionAware = getFlag().containsEpisodeUrl(mHistory.getEpisode()); - boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode(), versionAware); + boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode()); boolean sameFlag = TextUtils.equals(mHistory.getVodFlag(), getFlag().getFlag()); if (!sameEpisode || !sameFlag) mIntroSkipPlayback.reset(); if ((!sameEpisode || !sameFlag) && service() != null) { @@ -6340,8 +5185,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } if (!sameEpisode && !tmdbHistoryResumePending) { - // updatePlaybackHistoryPosition() 刚把旧集进度写回 History;切换新集前必须先覆盖, - // 否则新集没有独立缓存时会继承旧集进度并从错误位置开始播放。 + // 从缓存中恢复新集的播放位置 EpisodePositionCache.EpisodePosition cached = skipEpisodePositionCache() ? null : EpisodePositionCache.get().get( getKey(), getId(), @@ -6382,6 +5226,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.fullscreen.setImageResource(isFullscreen() ? R.drawable.ic_control_fullscreen_exit : R.drawable.ic_control_fullscreen); } + private void checkDanmakuImg() { + mBinding.control.danmaku.setImageResource(DanmakuSetting.isShow() ? R.drawable.ic_control_danmaku_on : R.drawable.ic_control_danmaku_off); + } + private void createKeep() { Keep keep = new Keep(); keep.setKey(getHistoryKey()); @@ -6400,85 +5248,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco keep.setVodPic(mHistory.getVodPic()); keep.save(); } - updateFollowingState(); - } - - private void onFollowing() { - if (!FollowingSettings.isEnabled()) { - Notify.show(R.string.following_enabled_hint); - return; - } - if (mHistory == null || followingActionPending || !FollowingPlaybackBridge.isEligible(mHistory)) return; - String identityKey = FollowingPlaybackBridge.identityKey(mHistory, currentSourceSeasonNumber()); - if (TextUtils.isEmpty(identityKey)) return; - followingActionPending = true; - mBinding.following.setEnabled(false); - FollowingPlaybackBridge.findAsync(identityKey, existing -> { - if (isFinishing() || isDestroyed()) return; - if (existing != null) { - // 播放页保持原地:已追更时再次点击即刻取消,绝不跳转追更页打断播放。 - cancelFollowing(identityKey); - return; - } - Following item = FollowingPlaybackBridge.build(mHistory, currentSourceSeasonNumber()); - FollowingSource source = FollowingPlaybackBridge.source(item, mHistory); - FollowingPlaybackBridge.addAsync(item, source, (saved, error) -> { - followingActionPending = false; - if (isFinishing() || isDestroyed()) return; - if (error != null) { - mBinding.following.setEnabled(true); - Notify.show(error.getMessage()); - return; - } - FollowingScheduler.ensurePeriodic(this); - FollowingScheduler.enqueueDueNow(this); - updateFollowingState(); - Notify.show(R.string.following_added); - }); - }); - } - - /** 播放页取消追更:写墓碑后立即刷新按钮状态,不离开当前播放页。 */ - private void cancelFollowing(String identityKey) { - FollowingScheduler.cancelNext(this, identityKey); - FollowingPlaybackBridge.deleteAsync(identityKey, error -> { - followingActionPending = false; - if (isFinishing() || isDestroyed()) return; - if (error != null) { - mBinding.following.setEnabled(true); - Notify.show(error.getMessage()); - return; - } - updateFollowingState(); - FollowingPlaybackBridge.refreshUnreadCountAsync(null); - Notify.show(R.string.following_canceled); - }); - } - - private void updateFollowingState() { - boolean eligible = FollowingSettings.isEnabled() && FollowingPlaybackBridge.isEligible(mHistory); - int generation = ++followingUiGeneration; - if (!eligible) { - applyFollowingButtonState(false, false); - return; - } - String identityKey = FollowingPlaybackBridge.identityKey(mHistory, currentSourceSeasonNumber()); - applyFollowingButtonState(true, false); - FollowingPlaybackBridge.findAsync(identityKey, item -> { - if (generation != followingUiGeneration || isFinishing() || isDestroyed()) return; - applyFollowingButtonState(true, item != null); - }); - } - - private void applyFollowingButtonState(boolean eligible, boolean followed) { - mBinding.following.setVisibility(eligible ? View.VISIBLE : View.GONE); - mBinding.following.setEnabled(true); - mBinding.following.setSelected(followed); - mBinding.following.setText(followed ? R.string.following_added : R.string.following_add); - if (mTmdbHeaderView != null) { - mTmdbHeaderView.setFollowingVisible(eligible); - mTmdbHeaderView.setFollowingSelected(followed); - } } private void updateVod(Vod item) { @@ -6497,7 +5266,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } } boolean historyReloaded = reloadHistoryAfterTmdbMatch(); - if (name) mHistory.setVodName(historyVodName(item)); + if (name) mHistory.setVodName(item.getName()); if (name) mBinding.name.setText(item.getName()); // 原生增强:TMDB 富集完成后回写题材/地区/演员/主创到 History(enrichVod 已填充 item,仅补空字段) if (mHistory != null) { @@ -6746,16 +5515,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return getHistoryKey(); } - // C101: see the leanback flavor note; deferring keeps the loading spinner animating - // while PlaybackService builds PlayerManager and the ExoPlayer instance. - @Override - protected boolean deferPlaybackServiceBinding() { - return true; - } - @Override protected void onControllerReadyReconciled() { - if (canHideSeekProgress()) showPlaybackContent(); + showPlaybackContent(); } @Override @@ -6781,11 +5543,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco refreshControlDialog(); } - @Override - protected void onSubtitleSelected(Sub sub) { - if (SubtitleRestoreCoordinator.remember(mHistory, sub)) syncHistory(); - } - private void updateAudioOnlyState() { if (service() == null) return; setAudioOnly(LyricsController.isAudioOnly(player())); @@ -7924,7 +6681,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override protected void onError(String msg) { - invalidateShortDramaQueue("error"); recordPlayHealth(false, msg); subtitlePlaybackSession.stop(this); mBinding.swipeLayout.setEnabled(true); @@ -7935,17 +6691,12 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco player().resetTrack(); player().reset(); player().stop(); - // Automatic line fallback continues in the same failed playback session. - // Keep the remembered kernel, but recreate its engine so the next line cannot - // inherit a decoder/Surface failure that audio-only playback can survive. - applyHistoryPlayerKernel(true); showError(msg); startFlow(); } @Override protected void onReload(String msg) { - recordPlayHealth(false, msg); if (PlayerManager.RELOAD_LUT_WARMUP.equals(msg)) { if (SpiderDebug.isEnabled()) SpiderDebug.log("lut-ui", "auto refresh after lut warmup playback failure key=%s episode=%s", getKey(), getEpisode() == null ? null : getEpisode().getName()); onRefresh(); @@ -7960,11 +6711,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco if (result != null) setPlayer(result); } - @Override - protected void onFirstFrameRendered() { - recordPlayHealth(true, ""); - } - @Override protected void onStateChanged(int state) { switch (state) { @@ -7973,11 +6719,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco showProgress(); break; case Player.STATE_READY: - // seek 窗口未关时不能因为一次 READY 读数就收圈:引擎在 seek 真正生效前 - // 仍可能宣称 READY,那正是「拖拽后先卡画面」的那一帧。圈已由 onSeekStarted - // 亮起,这里不收它就仍在屏上,由最小可见计时器与网速 ticker 收尾。 - if (canHideSeekProgress()) showPlaybackContent(); if (mPendingKaraokeResult == null) mKaraokeResultShown = false; + recordPlayHealth(true, ""); + showPlaybackContent(); boolean pendingResumeSeekApplied = applyPendingResumeSeek(); checkControl(); refreshLyrics(); @@ -7986,25 +6730,15 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco requestIntroSkipPlan(); if (!pendingResumeSeekApplied) applyAutoIntroSkip(); setAdFeedbackVisible(); // 播放地址确定后按格式刷新"有广告"按钮 - if (!mShortDramaQueueSessionActive) beginShortDramaQueue(); + recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线 break; case Player.STATE_ENDED: - if (mShortDramaQueue.shouldSuppressLegacyAutoAdvance()) { - scheduleShortDramaEndedFallback(); - break; - } checkEnded(true); updatePlayControl(false, syncPiPForPlaybackMode()); break; } } - @Override - public void onMediaItemTransition(@Nullable MediaItem mediaItem, int reason) { - super.onMediaItemTransition(mediaItem, reason); - onShortDramaMediaItemTransition(mediaItem, reason); - } - @Override protected void onPlayingChanged(boolean isPlaying) { syncLyricsPlaybackState(isPlaying); @@ -8037,63 +6771,16 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco logVideoFrame("onSurfaceAttached after"); } - /** - * 判断当前能否收掉 seek 期间挂起的加载圈。 - * - *

拖拽进度时 {@code onSeekStarted()} 先亮圈,随后才把 seek 交给控制器。这中间引擎仍 - * 报告 READY,于是挂在网速 ticker 上的兜底收口会在 seek 真正生效前把圈收掉——现象就是 - * 「拖拽后大概率看不到加载中/网速」。窗口打开期间必须先满过最小可见时长,且播放器确实已 - * READY 且不在真实加载阻塞中,才允许收口。窗口没关时圈就一直留着,由每秒一跳的网速 - * ticker 继续重判,不会自己冒出重试循环。 - */ - private boolean canHideSeekProgress() { - if (!mSeekProgressPending) return true; - if (SystemClock.elapsedRealtime() - mSeekProgressStartedAtMs < SEEK_PROGRESS_MIN_VISIBLE_MS) return false; - return service() != null - && player() != null - && !player().isReleased() - && !player().isEmpty() - && player().getPlaybackState() == Player.STATE_READY - && (!player().isLoading() || player().isPlaying()); - } - private void hideSeekProgressIfReady() { - if (!mSeekProgressPending) return; - if (service() == null || player() == null || player().isReleased() || player().isEmpty()) return; - if (!isOwner()) return; - if (!canHideSeekProgress()) return; - mSeekProgressPending = false; - showPlaybackContent(); - } - - /** - * 加载圈的兜底收口。 - * - *

圈只在 {@code STATE_READY} 分支被收(onStateChanged),而那条回调受 isOwner() 把关。 - * 归属判定一旦因任何原因失配,圈就永久留在屏上——画面在动、圈不走。这里不依赖归属, - * 直接读播放器状态:已在播且已 READY 就收圈。要求 {@code !isEmpty()},避免详情尚未加载完 - * (播放器还空着)时把详情页自己的加载态误收。 - * - *

挂在 mR2(网速刷新,圈可见时每秒一跳)上,圈不可见时该循环本就已停,无额外开销。 - */ - private void hidePlaybackProgressIfStale() { - if (mBinding.progress.getRoot().getVisibility() != View.VISIBLE) return; - if (service() == null || player() == null || player().isReleased() || player().isEmpty()) return; - if (!isOwner()) return; - if (!canHideSeekProgress()) return; - if (player().getPlaybackState() != Player.STATE_READY) return; + if (service() == null || player() == null || player().getPlaybackState() != Player.STATE_READY) return; showPlaybackContent(); } @Override protected void onSeekStarted() { - App.removeCallbacks(mSeekProgressFallback); - // 窗口必须在 showProgress() 之前打开:它内部会立刻投递网速 ticker, - // 那个 ticker 的兜底收口会读到尚未开始 seek 的 READY。 - mSeekProgressPending = true; - mSeekProgressStartedAtMs = SystemClock.elapsedRealtime(); showProgress(); - App.post(mSeekProgressFallback, SEEK_PROGRESS_MIN_VISIBLE_MS); + App.removeCallbacks(mSeekProgressFallback); + App.post(mSeekProgressFallback, 500); } @Override @@ -8109,15 +6796,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onTimeChanged(long time) { android.util.Log.d("VideoActivity", "onTimeChanged: isOwner=" + isOwner() + " mHistory=" + (mHistory != null)); - // 控制栏可见时每秒刷新一次电量与时间(与沉浸融合模式刷新内联时间的节奏一致)。 - if (isVisible(mBinding.control.getRoot())) updateBatteryInfo(); if (!isOwner() || mHistory == null) return; long position, duration; mHistory.setCreateTime(time); - if (hasDiscNavigationTimeline()) { - if (canSavePlaybackHistory(mHistory) && mHistory.canSync()) syncHistory(); - return; - } updatePlaybackHistoryPosition(); syncCurrentAudioPlaylistMetadata(); syncKaraokePosition(); @@ -8136,7 +6817,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void updatePlaybackHistoryPosition() { if (mHistory == null || tmdbHistoryResumePending) return; - if (hasDiscNavigationTimeline()) return; long position = player().getPosition(); long duration = player().getDuration(); if (position > 0) mHistory.setPosition(position); @@ -8196,31 +6876,16 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void requestIntroSkipPlan() { if (!Setting.isIntroSkipEnabled() || player() == null) { mIntroSkipPlayback.reset(); - setOpeningEndingText(); return; } IntroSkipService.Query query = buildIntroSkipQuery(); - // 切集后 plan 已被 reset,这里先刷一次,避免 query 拿不到时残留上一集的探测值 - setOpeningEndingText(); if (query == null) return; - mIntroSkipPlayback.request(query, this::onIntroSkipPlanLoaded); - } - - private void onIntroSkipPlanLoaded() { - if (isFinishing() || isDestroyed() || player() == null || player().isReleased() || !isOwner()) return; - // 查询可能在 EXO 首次 prepare/恢复定位期间提前返回。此时 seek 会与起播定位、 - // 去广告或播放器内部准备竞争,造成重复加载,严重时停在黑屏。统一等 READY 后再应用。 - if (player().getPlaybackState() != Player.STATE_READY) return; - setOpeningEndingText(); - applyAutoIntroSkip(); - preloadAdjacentIntroSkipPlans(); + mIntroSkipPlayback.request(query, this::applyAutoIntroSkip); } private boolean applyAutoIntroSkip() { - if (!Setting.isIntroSkipEnabled() || isFinishing() || isDestroyed() - || player() == null || player().isReleased() || !isOwner()) return false; - // notify=true:片尾无处可跳(末集/电影)时至少要有提示,不能静默无反应 - return mIntroSkipPlayback.apply(player(), () -> advanceEpisode(true)); + if (!Setting.isIntroSkipEnabled() || player() == null) return false; + return mIntroSkipPlayback.apply(player(), () -> checkEnded(false)); } private IntroSkipService.Query buildIntroSkipQuery() { @@ -8239,30 +6904,6 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return new IntroSkipService.Query(item.getTmdbId(), getIntroSkipImdbId(), item.getMediaType(), season, number, duration); } - /** - * 预热前后各一集。查询不需要时长(IntroDB 不收,TheIntroDB 可选),所以这里传 0 即可; - * 缓存只按剧集身份存原始段,等那一集真开播时按其实际时长折算,不会再走网络。 - */ - private void preloadAdjacentIntroSkipPlans() { - if (!Setting.isIntroSkipEnabled()) return; - TmdbItem item = getIntroSkipTmdbItem(); - if (item == null || item.getTmdbId() <= 0 || !item.isTv()) return; - String imdbId = getIntroSkipImdbId(); - Episode current = getEpisode(); - for (int offset : new int[]{1, -1}) { - Episode neighbour = getAdjacentEpisode(offset); - // getAdjacentEpisode 越界时会夹回当前集,那样预热就是给本集重发一次请求 - if (neighbour == null || neighbour == current) continue; - TmdbEpisode tmdbEpisode = neighbour.getTmdbEpisode(); - if (tmdbEpisode == null) continue; - int season = tmdbEpisode.getSeasonNumber(); - int number = tmdbEpisode.getNumber(); - if (season < 0 || number <= 0) continue; - IntroSkipService.Query query = new IntroSkipService.Query(item.getTmdbId(), imdbId, item.getMediaType(), season, number, 0); - mIntroSkipPlayback.preload(query); - } - } - private TmdbItem getIntroSkipTmdbItem() { TmdbItem item = mTmdbUIAdapter == null ? null : mTmdbUIAdapter.getTmdbItem(); return item == null ? getTmdbItem() : item; @@ -8278,10 +6919,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Subscribe(threadMode = ThreadMode.MAIN) public void onConfigEvent(ConfigEvent event) { - if (isRedirect() || !event.isVod()) return; - if (mTmdbUIAdapter != null) mTmdbUIAdapter.invalidateSubscription(); - if (mParseAdapter != null) mParseAdapter.reload(); - if (player() != null) player().dropStaleBundleDanmaku(); + if (isRedirect() || !event.isVod() || mParseAdapter == null) return; + mParseAdapter.reload(); } /** @@ -8311,28 +6950,19 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco pendingResumeSeekMs = C.TIME_UNSET; if (mHistory == null) { tmdbHistoryResumePending = false; - mInitialPlaybackPosition = C.TIME_UNSET; return; } - if (hasDiscMenu()) { - tmdbHistoryResumePending = false; - mInitialPlaybackPosition = C.TIME_UNSET; - return; + if (mHistory.isNearEnding()) { + SpiderDebug.log("video-flow", "reset near-end history position=%d duration=%d key=%s", mHistory.getPosition(), mHistory.getDuration(), getHistoryKey()); + mHistory.resetPlaybackPosition(); + syncHistory(); } - long position = resolveInitialPlaybackPosition(); - if (position == C.TIME_UNSET || position <= 0) { + long position = Math.max(mHistory.getOpening(), mHistory.getPosition()); + if (position <= 0) { tmdbHistoryResumePending = false; - mInitialPlaybackPosition = C.TIME_UNSET; return; } mIntroSkipPlayback.setResumePosition(position); - if (mInitialPlaybackPosition == position) { - SpiderDebug.log("video-flow", "skip duplicate restore seek position=%d key=%s", position, getHistoryKey()); - mInitialPlaybackPosition = C.TIME_UNSET; - tmdbHistoryResumePending = false; - return; - } - mInitialPlaybackPosition = C.TIME_UNSET; if (player().isIjk()) pendingResumeSeekMs = position; else { player().seekTo(position); @@ -8349,21 +6979,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return mHistory == null ? PlayerSetting.getDefaultSpeed() : mHistory.getPlaybackSpeed(PlayerSetting.getDefaultSpeed()); } - private long resolveInitialPlaybackPosition() { - if (mHistory == null) return C.TIME_UNSET; - if (mHistory.isNearEnding()) { - SpiderDebug.log("video-flow", "reset near-end history position=%d duration=%d key=%s", mHistory.getPosition(), mHistory.getDuration(), getHistoryKey()); - mHistory.resetPlaybackPosition(); - syncHistory(); - } - long position = Math.max(mHistory.getOpening(), mHistory.getPosition()); - return position > 0 ? position : C.TIME_UNSET; - } - -private void checkOrientation() { - // 用户点过旋转按钮之后方向由他决定:这里只负责在用户没表态时按视频宽高自动纠正。 - // 否则全屏下把横屏视频手动转成竖屏,切下一集的尺寸回调会立刻把方向改回去。 - if (manualRotate) return; + private void checkOrientation() { if (isFullscreen() && !isRotate() && player().isPortrait()) { setRequestedOrientation(PlaybackOrientation.getPortraitVideoSizeOrientation()); setRotate(true); @@ -8404,32 +7020,12 @@ private void checkOrientation() { } private void setTrackVisible() { - mBinding.control.action.discMenu.setVisibility(hasDiscMenu() ? View.VISIBLE : View.GONE); - updateDiscMenuTools(); mBinding.control.action.text.setVisibility(player().haveTrack(C.TRACK_TYPE_TEXT) || player().isVod() ? View.VISIBLE : View.GONE); mBinding.control.action.audio.setVisibility(player().haveTrack(C.TRACK_TYPE_AUDIO) ? View.VISIBLE : View.GONE); mBinding.control.action.video.setVisibility(player().haveTrack(C.TRACK_TYPE_VIDEO) ? View.VISIBLE : View.GONE); applyActionButtonVisibility(); } - private void updateDiscMenuTools() { - updateCustomButtonStates(); - MpvPlayer mpv = service() != null && isOwner() - && player().getPlayer() instanceof MpvPlayer active ? active : null; - if (mDiscMenuPlayer != mpv) { - if (mDiscMenuPlayer != null) mDiscMenuPlayer.removeDiscMenuStateListener(mDiscMenuStateListener); - mDiscMenuPlayer = mpv; - if (mpv != null) mpv.addDiscMenuStateListener(mDiscMenuStateListener); - } - boolean visible = mpv != null && mpv.isDiscMenuActive() && !isLock() && !isInPictureInPictureMode() - && !mAudioStageVisible && !isVisible(mBinding.control.getRoot()); - mBinding.discTools.getRoot().setVisibility(visible ? View.VISIBLE : View.GONE); - mBinding.discTools.fullscreen.setImageResource(isFullscreen() - ? R.drawable.ic_control_fullscreen_exit : R.drawable.ic_control_fullscreen); - mBinding.discTools.fullscreen.setContentDescription(getString(isFullscreen() - ? R.string.play_exit_fullscreen : R.string.play_fullscreen)); - } - private void setTitleVisible() { mBinding.control.action.title.setVisibility(player().haveTitle() ? View.VISIBLE : View.GONE); applyActionButtonVisibility(); @@ -8550,9 +7146,7 @@ private void checkOrientation() { private void nextFlag(int position) { Flag flag = mFlagAdapter.get(position + 1); Notify.show(getString(R.string.play_switch_flag, flag.getFlag())); - mProgrammaticFlagSwitch = true; onItemClick(flag); - mProgrammaticFlagSwitch = false; } private void nextSite() { @@ -8584,40 +7178,6 @@ private void checkOrientation() { private void setFullscreen(boolean fullscreen) { Util.toggleFullscreen(this, this.fullscreen = fullscreen); updateFusionThemeButtonVisibility(); - notifyPluginFullscreen(); - } - - /** - * 页面自报沉浸态:本页是否处于全屏播放。 - * - *

画中画不算:PiP 下用户仍在与本页交互,入口该留着。判断读 {@link #inPip} 这个快照, - * 让"重算入口"与"广播事件"看到同一份形态,而不是各自去问一次平台。 - */ - @Override - public boolean isImmersive() { - return fullscreen && !inPip; - } - - /** - * 全屏形态变化后通知插件层:先重算入口可见性,再把新形态广播给订阅者。 - * - *

挂在这里而不是 {@code enterFullscreen} / {@code exitFullscreen} 上:那两条只是入口, - * 短剧、旋转、PiP 恢复各自还有自己的路径,只有 {@code setFullscreen} 与 PiP 回调是所有变化的收口。 - * - *

广播值取 {@link #isImmersive()} 而不是裸 {@code fullscreen}:入口的隐藏判据就是它。 - * 两处取不同值会让画中画期间事件报「全屏」而入口仍然可见,声明与行为脱钩。 - */ - private void notifyPluginFullscreen() { - try { - com.fongmi.android.tv.plugin.PluginController.get().refreshRender(this); - } catch (Throwable ignored) { - // 插件入口是可选增强:重算失败不该影响播放页。 - } - try { - com.fongmi.android.tv.plugin.PluginHostEvents.playbackFullscreen(this, isImmersive()); - } catch (Throwable ignored) { - // 事件是可选增强:广播失败同样不该影响播放页。 - } } private boolean isShortDramaSource() { @@ -8648,7 +7208,8 @@ private void checkOrientation() { mTmdbUIAdapter = new com.fongmi.android.tv.ui.helper.TmdbUIAdapter(this); if (!mTmdbUIAdapter.isReady()) { - SpiderDebug.log("TMDB 增强等待源内嵌数据或 API Key"); + SpiderDebug.log("TMDB 增强已启用,但配置未就绪(需要 API Key)"); + return; } mTmdbUIAdapter.setPersonalAiUpdateListener(() -> { if (mTmdbHeaderView != null && mTmdbUIAdapter != null && mTmdbUIAdapter.isLoaded() && !mTmdbFallbackToNative) { @@ -8686,11 +7247,6 @@ private void checkOrientation() { public void onKeep() { VideoActivity.this.onKeep(); } - - @Override - public void onFollowing() { - VideoActivity.this.onFollowing(); - } }); // 设置图片加载完成监听器 @@ -8702,7 +7258,7 @@ private void checkOrientation() { }); // 原生增强、原生样式和 Fusion 模式:设置 Backdrop 变化监听器,同步轮播到 contextWall - if (isRuntimeFusionMode() || shouldUseTmdbBackdropSurface()) { + if (Setting.isFusionDetailPage() || shouldUseTmdbBackdropSurface()) { mTmdbHeaderView.setOnBackdropChangeListener(new com.fongmi.android.tv.ui.custom.TmdbHeaderView.OnBackdropChangeListener() { @Override public void onBackdropChanged(String imageUrl) { @@ -8719,12 +7275,12 @@ private void checkOrientation() { mBinding.search.setVisibility(View.GONE); if (mBinding.videoShadow != null) mBinding.videoShadow.setVisibility(View.GONE); // 隐藏播放器下方的阴影 - if (isRuntimeFusionMode()) { + if (Setting.isFusionDetailPage()) { applyFusionDetailChrome(); } else if (shouldUseTmdbBackdropSurface()) { // 原生增强模式:启用全屏背景 applyOriginalEnhancedBackdropLayout(); - mBinding.getRoot().setBackgroundColor(enhancedBackdropBaseColor()); + mBinding.getRoot().setBackgroundColor(Color.TRANSPARENT); mBinding.scroll.setBackgroundColor(Color.TRANSPARENT); mBinding.swipeLayout.setBackgroundColor(Color.TRANSPARENT); mBinding.progressLayout.setBackgroundColor(Color.TRANSPARENT); @@ -8786,16 +7342,6 @@ private void checkOrientation() { } } - /** - * 原生增强靠全屏 backdrop 当背景,但 contextWall 初始是 gone、图也要等网络。 - * root 若留 TRANSPARENT,这段空窗期会直接露出 Material3 动态取色的窗口底色(设备上可能是紫色)。 - * 给 root 垫一层不透明底色即可:contextWall 是首个子视图、绘制在内容之下, - * 图到达后它自己就盖住这层底色,不需要再改回 TRANSPARENT。 - */ - private int enhancedBackdropBaseColor() { - return isTmdbPlaybackLightTheme() ? 0xFFF3F6F9 : 0xFF0F141A; - } - private void applyOriginalEnhancedBackdropLayout() { // 设置全屏背景布局(类似 Fusion) RelativeLayout.LayoutParams wallParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); @@ -8871,7 +7417,7 @@ private void checkOrientation() { } private void applyFusionPlayerBelowSpacing() { - if (!isRuntimeFusionMode() || mTmdbHeaderView == null || mTmdbHeaderView.getHeaderRoot() == null) return; + if (!Setting.isFusionDetailPage() || mTmdbHeaderView == null || mTmdbHeaderView.getHeaderRoot() == null) return; View actions = mTmdbHeaderView.getHeaderRoot().findViewById(R.id.tmdbActionsScroll); if (actions == null || !(actions.getLayoutParams() instanceof ViewGroup.MarginLayoutParams params)) return; params.topMargin = 0; @@ -8880,13 +7426,7 @@ private void checkOrientation() { private void applyFusionThemeSurface() { boolean light = isFusionLightTheme(); - // 顺序有意义:TMDB 未匹配回退要透出 App 壁纸,必须优先于 backdrop surface 判定。 - // 其次是原生增强(backdrop surface):垫不透明底色而非 TRANSPARENT,否则 backdrop 图到达前 - // 会露出窗口动态取色。contextWall 是首个子视图、绘制在 root 底色之上,图到达后自然盖住。 - int base = mTmdbFallbackToNative ? Color.TRANSPARENT - : shouldUseTmdbBackdropSurface() ? enhancedBackdropBaseColor() - : light ? 0xFFF3F6F9 : 0xFF0F141A; - mBinding.getRoot().setBackgroundColor(base); + mBinding.getRoot().setBackgroundColor(mTmdbFallbackToNative || shouldUseTmdbBackdropSurface() ? Color.TRANSPARENT : light ? 0xFFF3F6F9 : 0xFF0F141A); mBinding.scroll.setBackgroundColor(Color.TRANSPARENT); mBinding.swipeLayout.setBackgroundColor(Color.TRANSPARENT); mBinding.progressLayout.setBackgroundColor(Color.TRANSPARENT); @@ -8906,12 +7446,12 @@ private void checkOrientation() { mBinding.videoContextScrim.setVisibility(View.VISIBLE); return; } - boolean light = isRuntimeFusionMode() && isFusionLightTheme(); + boolean light = Setting.isFusionDetailPage() && isFusionLightTheme(); mBinding.videoContextScrim.setBackgroundResource(light ? R.drawable.shape_video_context_scrim_light : R.drawable.shape_video_context_scrim); } private void applyFusionNativeTextColors() { - if ((!isRuntimeFusionMode() && !mTmdbFallbackToNative) || mBinding.nativeContentContainer == null) return; + if ((!Setting.isFusionDetailPage() && !mTmdbFallbackToNative) || mBinding.nativeContentContainer == null) return; tintFusionNativeTextTree(mBinding.nativeContentContainer, !mTmdbFallbackToNative && isFusionLightTheme()); } @@ -8979,7 +7519,7 @@ private void checkOrientation() { private void updateFusionThemeButtonVisibility() { if (mFusionThemeButton == null) return; - boolean show = DetailThemeVisibility.showFusionThemeButton(isRuntimeFusionMode(), isFullscreen(), isInPictureInPictureMode()); + boolean show = DetailThemeVisibility.showFusionThemeButton(Setting.isFusionDetailPage(), isFullscreen(), isInPictureInPictureMode()); mFusionThemeButton.setVisibility(show ? View.VISIBLE : View.GONE); } @@ -9250,7 +7790,6 @@ private void checkOrientation() { } private void showManualTmdbSeasonDialog() { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isLoaded() || mTmdbUIAdapter.getTmdbItem() == null || !mTmdbUIAdapter.getTmdbItem().isTv()) { Notify.show(R.string.detail_tmdb_empty); return; @@ -9406,7 +7945,6 @@ private void checkOrientation() { } } private void showManualTmdbMatchDialog() { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isReady()) { Notify.show(R.string.detail_tmdb_need_key); return; @@ -9449,7 +7987,6 @@ private void checkOrientation() { } private void searchTmdb(String keyword, TmdbSearchDialog dialog) { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isReady()) return; dialog.loading(); int generation = ++mTmdbDialogGeneration; @@ -9471,7 +8008,6 @@ private void checkOrientation() { } private void applyManualTmdb(TmdbItem item) { - if (runtimeSourceOnly) return; if (mTmdbUIAdapter == null || mVod == null || item == null) return; mTmdbDialogGeneration++; mTmdbFallbackToNative = false; @@ -9497,7 +8033,6 @@ private void checkOrientation() { private void updateTmdbKeepState() { if (mTmdbHeaderView != null) mTmdbHeaderView.setKeepSelected(Keep.find(getHistoryKey()) != null); - updateFollowingState(); } private void moveFlagAndEpisodeToTmdb() { @@ -9524,8 +8059,6 @@ private void checkOrientation() { } moveFusionPlayerActionsToTmdb(playbackControls); mTmdbControlsMoved = true; - updateTmdbPlaybackScrollContentHeight(); - mBinding.episode.post(this::updateEpisodeViewportHeight); updateEpisodeGroupVisibility(); mTmdbHeaderView.refreshTheme(); if (shouldUseTmdbBackdropSurface()) mTmdbHeaderView.hideNativeHeroBackdrop(); @@ -9541,25 +8074,9 @@ private void checkOrientation() { item.parent.addView(item.view, Math.min(item.index, item.parent.getChildCount()), item.layoutParams); } mTmdbControlsMoved = false; - updateTmdbPlaybackScrollContentHeight(); - mBinding.episode.post(this::updateEpisodeViewportHeight); updateEpisodeGroupVisibility(); } - private void updateTmdbPlaybackScrollContentHeight() { - if (mBinding == null || mBinding.scroll.getChildCount() == 0) return; - View child = mBinding.scroll.getChildAt(0); - if (!(child instanceof ViewGroup content)) return; - int height = mTmdbControlsMoved && usesOuterEpisodePageScroll() - ? ViewGroup.LayoutParams.WRAP_CONTENT : ViewGroup.LayoutParams.MATCH_PARENT; - ViewGroup.LayoutParams params = content.getLayoutParams(); - if (params == null || params.height == height) return; - params.height = height; - content.setLayoutParams(params); - content.requestLayout(); - mBinding.scroll.requestLayout(); - } - private void moveTmdbSourceToFlagTitle(View tmdbRoot) { View source = tmdbRoot.findViewById(R.id.tmdbFusionSource); if (source == null) source = mBinding.flagTitleBar.findViewById(R.id.tmdbFusionSource); @@ -9610,7 +8127,7 @@ private void checkOrientation() { } private void moveFusionPlayerActionsToTmdb(ViewGroup playbackControls) { - if (!isRuntimeFusionMode()) { + if (!Setting.isFusionDetailPage()) { return; } View actions = mBinding.control.action.getRoot(); @@ -9623,7 +8140,7 @@ private void checkOrientation() { } private boolean isFusionPlayerActionsDocked() { - return isRuntimeFusionMode() && mBinding.control.action.getRoot().getParent() != mBinding.control.bottom; + return Setting.isFusionDetailPage() && mBinding.control.action.getRoot().getParent() != mBinding.control.bottom; } private View[] getTmdbMovableViews() { @@ -9653,23 +8170,11 @@ private void checkOrientation() { setRequestedOrientation(isPort() ? ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT : ActivityInfo.SCREEN_ORIENTATION_FULL_USER); mKeyDown.resetScale(); } - syncShortDramaGesture(); setShortDramaScale(); hideControl(); ViewCompat.requestApplyInsets(mBinding.getRoot()); } - /** - * 手势轴向跟着呈现形态走:短剧竖屏全屏时整屏上下滑切集、长按后上下滑调亮度/音量。 - *

- * 必须由「当前是否处于短剧全屏」推导,不能在进入时置一次了事:横屏短剧允许自由旋转, - * 转回竖屏会走 exitFullscreen 退回内嵌小窗,此时若手势仍是短剧那套,在详情页上 - * 竖滑就会误切集。同理换到另一部短剧时形态保持不变,标记也不该被清掉。 - */ - private void syncShortDramaGesture() { - if (mKeyDown != null) mKeyDown.setShortDrama(isFullscreen() && isShortDramaSession()); - } - private void setShortDramaScale() { int scale = (mHistory != null && mHistory.getScale() != -1) ? getScale() : SHORT_DRAMA_SCALE; applyResizeMode(scale); @@ -9773,6 +8278,7 @@ private void checkOrientation() { */ private View[] getShortDramaControlViews() { return new View[]{ + mBinding.control.danmaku, mBinding.control.cast, mBinding.control.keep, mBinding.control.shortDramaChangeSource, @@ -10025,9 +8531,6 @@ private void checkOrientation() { @Override public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, @NonNull Configuration newConfig) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); - inPip = isInPictureInPictureMode; - // 判据已随形参翻转:本页只依赖 fullscreen 与 inPip,这里一次重算就够。 - notifyPluginFullscreen(); updateFusionThemeButtonVisibility(); if (!isFullscreen()) setVideoView(isInPictureInPictureMode); if (isInPictureInPictureMode) { @@ -10039,13 +8542,11 @@ private void checkOrientation() { restoreContextWall(); if (isStop()) finish(); } - updateDiscMenuTools(); } @Override protected void onResume() { super.onResume(); - republishPluginEpisodeContext(); restoreContextWall(); if (mAudioStageVisible) restorePlaybackArtwork(); if (mAudioStageVisible) applyAudioBackground(); @@ -10063,7 +8564,6 @@ private void checkOrientation() { return; } syncFullscreenForOrientation(newConfig.orientation); - setupCustomActionButtons(); if (!isFullscreen()) { applyTmdbTabletVideoLayoutIfNeeded(); if (mVod != null) bindTmdbTabletTopSummary(mVod); @@ -10074,7 +8574,6 @@ private void checkOrientation() { Util.hideSystemUI(this); if (isVisible(mBinding.control.getRoot())) showControl(); } - updateDiscMenuTools(); } private void syncFullscreenForOrientation(int orientation) { @@ -10110,7 +8609,6 @@ private void checkOrientation() { public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); if (isFullscreen() && hasFocus) Util.hideSystemUI(this); - updateDiscMenuTools(); } @Override @@ -10124,7 +8622,6 @@ private void checkOrientation() { if (service() != null) refreshLyrics(); syncLyricsPlaybackState(); syncKaraokePosition(); - updateDiscMenuTools(); } @Override @@ -10135,7 +8632,6 @@ private void checkOrientation() { stopAudioCoverRotation(); if (PlayerSetting.isBackgroundOff()) mClock.stop(); if (!isAudioOnly()) setStop(true); - updateDiscMenuTools(); } @Override @@ -10162,7 +8658,6 @@ private void checkOrientation() { protected void markPlaybackExiting() { if (isPlaybackExiting()) return; super.markPlaybackExiting(); - mIntroSkipPlayback.reset(); cancelAiSeasonAnalysis(false); mPersonalRecommendationGeneration++; mPersonalRecommendationTasks.close(); @@ -10171,16 +8666,7 @@ private void checkOrientation() { } @Override protected void onDestroy() { - if (mCustomButtonPlayer != null) { - mCustomButtonPlayer.removeCustomButtonStateListener(mCustomButtonStateListener); - mCustomButtonPlayer = null; - } - if (mDiscMenuPlayer != null) { - mDiscMenuPlayer.removeDiscMenuStateListener(mDiscMenuStateListener); - mDiscMenuPlayer = null; - } - invalidateShortDramaQueue("destroy"); - mIntroSkipPlayback.reset(); + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); cancelAiSeasonAnalysis(false); dismissKaraokeResultDialogForRecreation(); mLyricsSearchSeq++; @@ -10254,15 +8740,10 @@ private void checkOrientation() { } 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 PlaybackResourceClassifier.isHlsUrl(url); + // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。 + // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。 + if (player() == null || TextUtils.isEmpty(player().getUrl())) return false; + return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0; } private void setAdFeedbackVisible() { @@ -10270,78 +8751,173 @@ private void checkOrientation() { applyActionButtonVisibility(); } + private AdFeedbackController adFeedback() { + if (mAdFeedback == null) { + mAdFeedbackHost = new AdFeedbackHostAdapter( + new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi()); + mAdFeedback = new AdFeedbackController(mAdFeedbackHost); + } + return mAdFeedback; + } + + /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */ + private void resetAdFeedback() { + mAdMarkStartMs = -1; + if (mAdFeedback != null) mAdFeedback.invalidate(); + if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence(); + // 作废后归因回调不再刷新界面,对话框会永久停在「分析中」 + if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); + mAdFeedbackDialog = null; + } + + /** + * 起播后记录本站域名,供域名信誉通道建立基线。 + * + *

必须强制初始化适配器:它原本只在用户首次按下「有广告」时懒创建, + * 若这里跳过未初始化的情况,首次反馈时基线仍为空,域名通道会因 + * 「无基线不断言」而弃权 —— 整条通道等于没接。 + */ + private void recordAdFeedbackHost() { + // 关掉去广告或播直播时不建基线:这些域名会挤掉 LRU 里真正有用的条目 + if (!isAdFeedbackEnabled()) return; + adFeedback(); + mAdFeedbackHost.recordPlaybackHost(); + } + + /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */ private void submitAdFeedback() { - AdDetectionRequest request = buildAdDetectionRequest(); - if (request == null) { - Notify.show(R.string.ad_feedback_no_url); + AdFeedbackController controller = adFeedback(); + 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, end); + } else { + session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence()); + } + if (session == null) { + Notify.show(R.string.ad_interval_invalid); return; } - // 记录 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); - }); - }); + // 统计放在成交之后,未成立的反馈不计入 + AdBlockStatsStore.recordFeedback(getKey()); } - 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; @@ -10699,7 +9275,6 @@ private void prepareImmersiveAudioPlayback(AudioPlaybackResolver.Resolved resolv playerStartTime = detailStartTime; beginPlayHealth(); mPlaybackEpisodeKey = audioQueueEpisodeKey(episode); - mPlaybackFlagKey = playbackFlagKey(resolved.getFlag()); clearLyrics(); clearKaraokeState(); setAudioOnly(true); @@ -10710,7 +9285,6 @@ private void applyImmersiveAudioSelection(AudioPlaybackResolver.Resolved resolve Episode episode = resolved.getEpisode(); mFlagAdapter.setSelected(flag); mFlagAdapter.toggle(episode); - mPlaybackFlagKey = playbackFlagKey(flag); scrollToPosition(mBinding.flag, mFlagAdapter.getPosition()); setEpisodeAdapter(flag.getEpisodes()); scrollEpisodeToSelected(); @@ -11556,13 +10130,6 @@ private void restoreFlagSelectionWithoutPlayback() { @Override protected void onPlayerPositionDiscontinuity(Player.PositionInfo oldPosition, Player.PositionInfo newPosition, int reason) { - if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION - && mShortDramaQueue.shouldSuppressLegacyAutoAdvance()) { - mShortDramaTransitionPositionMs = oldPosition == null - ? C.TIME_UNSET : Math.max(0L, oldPosition.positionMs); - mShortDramaTransitionDurationMs = player() == null - ? C.TIME_UNSET : player().getDuration(); - } debugPlaybackControl("positionDiscontinuity=" + reason + " old=" + oldPosition.positionMs + " new=" + newPosition.positionMs); debugLyricsLoop("positionDiscontinuity=" + reason, true); syncLyricsPlaybackState(player().isPlaying()); @@ -11666,10 +10233,4 @@ private void dismissKaraokeResultDialogForRecreation() { SpiderDebug.log("karaoke-result", "dismiss old window for configuration change"); } - private void applyPlaybackOverlay() { - mBinding.control.getRoot().setBackgroundResource(R.color.transparent); - if (Setting.isPlaybackOverlayEnabled()) mBinding.control.bottom.setBackground(PlaybackMenuScrim.create(this)); - else mBinding.control.bottom.setBackgroundResource(R.color.transparent); - } - } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/WebHomeChromeController.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/WebHomeChromeController.java index 14008cef9a..afd22caf92 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/WebHomeChromeController.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/WebHomeChromeController.java @@ -1,5 +1,6 @@ package com.fongmi.android.tv.ui.activity; +import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.view.View; @@ -16,7 +17,6 @@ import androidx.core.view.WindowInsetsControllerCompat; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.databinding.ActivityHomeBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.Util; import com.fongmi.android.tv.web.WebHomeChrome; import com.fongmi.android.tv.web.WebHomeChromeOptions; @@ -222,6 +222,7 @@ final class WebHomeChromeController { private boolean useDarkIcons(String style) { if (WebHomeChromeOptions.STYLE_DARK.equals(style)) return true; if (WebHomeChromeOptions.STYLE_LIGHT.equals(style)) return false; - return !ThemeController.isNight(activity); + int mask = activity.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + return mask != Configuration.UI_MODE_NIGHT_YES; } } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java index 110792e994..63083660ab 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/AdRuleAdapter.java @@ -29,7 +29,6 @@ public class AdRuleAdapter extends RecyclerView.Adapter onToggleClick(holder, item)); } - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List payloads) { - if (!payloads.isEmpty() && payloads.get(payloads.size() - 1) instanceof Boolean enabled) { - RuleItem item = mItems.get(position); - holder.binding.toggle.setChecked(enabled); - holder.binding.toggle.setContentDescription((enabled ? "关闭" : "启用") + item.getName()); - if (item.getType() == RuleType.HLS_RULE) { - holder.binding.summary.setText(item.getSummary(holder.itemView.getContext())); - } - return; - } - super.onBindViewHolder(holder, position, payloads); - } - private void onToggleClick(ViewHolder holder, RuleItem item) { boolean enabled = holder.binding.toggle.isChecked(); // MaterialSwitch 会先改变自身状态;确认完成前保持列表展示持久化状态。 diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java index 3f09d209d6..ea203d6e87 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/ConfigAdapter.java @@ -9,7 +9,6 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.setting.InterfaceOrderStore; import com.fongmi.android.tv.databinding.AdapterConfigBinding; import java.util.List; @@ -19,8 +18,6 @@ public class ConfigAdapter extends RecyclerView.Adapter mItems; private boolean readOnly; - private boolean protectCurrent; - private Config current; public ConfigAdapter(OnClickListener listener) { this.listener = listener; @@ -30,10 +27,6 @@ public class ConfigAdapter extends RecyclerView.Adapter TextUtils.equals(item.getUrl(), currentUrl)); + if (!readOnly && !TextUtils.isEmpty(currentUrl)) mItems.removeIf(item -> TextUtils.equals(item.getUrl(), currentUrl)); return this; } - private boolean isCurrent(Config item) { - if (!protectCurrent || current == null) return false; - if (current.getId() > 0 && item.getId() == current.getId()) return true; - return current.getType() == item.getType() - && !TextUtils.isEmpty(current.getInterfaceKey()) - && TextUtils.equals(current.getInterfaceKey(), item.getInterfaceKey()); - } - - public boolean isProtectedCurrent(Config item) { - return isCurrent(item); - } - public int remove(Config item) { int position = mItems.indexOf(item); if (position == -1) return -1; item.delete(); mItems.remove(position); - if (item.getType() == 0) InterfaceOrderStore.saveVodConfigs(mItems); notifyItemRemoved(position); return getItemCount(); } - public boolean drag(int from, int to) { - if (from < 0 || to < 0 || from >= mItems.size() || to >= mItems.size() || from == to) return false; - Config item = mItems.remove(from); - mItems.add(to, item); - notifyItemMoved(from, to); - if (mItems.size() > 0) InterfaceOrderStore.saveVodConfigs(mItems); - return true; - } - @Override public int getItemCount() { return mItems.size(); @@ -104,18 +69,9 @@ public class ConfigAdapter extends RecyclerView.Adapter { - if (!current) listener.onTextClick(item); - }); - holder.binding.text.setOnLongClickListener(v -> !current && listener.onTextLongClick(holder)); - holder.binding.edit.setVisibility(readOnly ? View.GONE : View.VISIBLE); - holder.binding.edit.setOnClickListener(v -> listener.onEditClick(item)); + holder.binding.text.setOnClickListener(v -> listener.onTextClick(item)); holder.binding.delete.setVisibility(readOnly ? View.GONE : View.VISIBLE); - holder.binding.delete.setAlpha(current ? 0.38f : 1f); holder.binding.delete.setOnClickListener(v -> listener.onDeleteClick(item)); } @@ -123,7 +79,7 @@ public class ConfigAdapter extends RecyclerView.Adapter @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { Group item = mItems.get(position); - holder.binding.name.setText(item.getDisplayName()); + holder.binding.name.setText(item.getName()); setListStyle(holder); holder.binding.getRoot().setSelected(item.isSelected()); holder.binding.getRoot().setOnClickListener(view -> listener.onItemClick(item)); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/LiveAdapter.java b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/LiveAdapter.java index da7c1b7236..c2a1c7d7a3 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/LiveAdapter.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/LiveAdapter.java @@ -55,7 +55,7 @@ public class LiveAdapter extends RecyclerView.Adapter { @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { Live item = mItems.get(position); - holder.binding.text.setText(item.getDisplayName()); + holder.binding.text.setText(item.getName()); holder.binding.text.setSelected(item.isSelected()); holder.binding.boot.setImageResource(item.getBootIcon()); holder.binding.pass.setImageResource(item.getPassIcon()); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java index b1da2c359d..628bbf5d9f 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java @@ -18,7 +18,6 @@ import com.fongmi.android.tv.setting.SiteHealthStore; import com.fongmi.android.tv.setting.SiteBlockSetting; import com.fongmi.android.tv.setting.SiteOrderStore; import com.fongmi.android.tv.setting.SiteNameStore; -import com.fongmi.android.tv.ui.helper.SiteDialogTheme; import java.util.ArrayList; import java.util.List; @@ -27,7 +26,6 @@ import java.util.Locale; public class SiteAdapter extends RecyclerView.Adapter { private final OnClickListener listener; - private final SiteDialogTheme theme; private final List mAllItems; private final List mItems; private String group; @@ -37,9 +35,8 @@ public class SiteAdapter extends RecyclerView.Adapter { private boolean block; private int column = 1; - public SiteAdapter(OnClickListener listener, SiteDialogTheme theme) { + public SiteAdapter(OnClickListener listener) { this.listener = listener; - this.theme = theme; this.mAllItems = new ArrayList<>(); this.mItems = new ArrayList<>(); this.column = 1; @@ -156,11 +153,7 @@ public class SiteAdapter extends RecyclerView.Adapter { @NonNull @Override public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - AdapterSiteBinding binding = AdapterSiteBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); - theme.apply(binding.text); - theme.tint(binding.search); - theme.tint(binding.change); - return new ViewHolder(binding); + return new ViewHolder(AdapterSiteBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false)); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteGroupAdapter.java b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteGroupAdapter.java index 03a885bb7b..89fc272234 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteGroupAdapter.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/SiteGroupAdapter.java @@ -4,10 +4,10 @@ import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.helper.SiteDialogTheme; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.button.MaterialButton; @@ -18,13 +18,11 @@ import java.util.Objects; public class SiteGroupAdapter extends RecyclerView.Adapter { private final OnClickListener listener; - private final SiteDialogTheme theme; private final List items; private String selectedGroup; - public SiteGroupAdapter(OnClickListener listener, SiteDialogTheme theme) { + public SiteGroupAdapter(OnClickListener listener) { this.listener = listener; - this.theme = theme; this.items = new ArrayList<>(); this.selectedGroup = ""; } @@ -77,7 +75,9 @@ public class SiteGroupAdapter extends RecyclerView.Adapter { private final OnClickListener listener; - private int selected; + private final int selected; private final int[] mItems; public ThemeAdapter(OnClickListener listener, int[] items, int selected) { @@ -24,11 +24,6 @@ public class ThemeAdapter extends RecyclerView.Adapter this.mItems = items; } - public void setSelected(int color) { - selected = color; - notifyDataSetChanged(); - } - public interface OnClickListener { void onItemClick(int color); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java index bce4071ae9..4e560fa107 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java @@ -20,7 +20,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.Updater; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.ui.audio.AudioMiniPlayer; import com.fongmi.android.tv.ui.custom.CustomWallView; import com.fongmi.android.tv.utils.ResUtil; @@ -47,16 +46,10 @@ public abstract class BaseActivity extends AppCompatActivity { enableEdgeToEdge(); enableDynamicColor(); super.onCreate(savedInstanceState); - ThemeController.applyFromPreferences(this); - // getBinding() inflates on every call, so resolve it once: the root that is set as - // content must be the same instance that initView()/initEvent() configure. - View content = getBinding().getRoot(); - setContentView(content); - ThemeController.bindTheme(content); + setContentView(getBinding().getRoot()); audioMiniPlayer = new AudioMiniPlayer(this); EventBus.getDefault().register(this); initView(savedInstanceState); - ThemeController.bindTheme(content); setBackCallback(); initEvent(); } @@ -106,11 +99,12 @@ public abstract class BaseActivity extends AppCompatActivity { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return; DisplayCutout cutout = ResUtil.getDisplay(this).getCutout(); if (cutout == null) return; - // 切口必须按所在侧避让:把四边按位或后对称写进左右,会把竖屏的顶部切口高度变成左右内缩, - // 全屏时控制栏与底部菜单因此整体缩窄,左右露出画面黑边。 + int top = cutout.getSafeInsetTop(); int left = cutout.getSafeInsetLeft(); - int right = leftOnly ? 0 : cutout.getSafeInsetRight(); - layout.setPadding(left, 0, right, 0); + int right = cutout.getSafeInsetRight(); + int bottom = cutout.getSafeInsetBottom(); + int padding = left | right | top | bottom; + layout.setPadding(padding, 0, leftOnly ? 0 : padding, 0); } protected void noPadding(ViewGroup layout) { @@ -141,7 +135,7 @@ public abstract class BaseActivity extends AppCompatActivity { @Subscribe(threadMode = ThreadMode.MAIN) public void onSubscribe(Object o) { - if (o instanceof RefreshEvent event && (event.getType() == RefreshEvent.Type.LANGUAGE || event.getType() == RefreshEvent.Type.THEME)) recreate(); + if (o instanceof RefreshEvent event && event.getType() == RefreshEvent.Type.LANGUAGE) recreate(); } protected void onBackInvoked() { diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/custom/CustomKeyDown.java b/app/src/mobile/java/com/fongmi/android/tv/ui/custom/CustomKeyDown.java index 5693448198..7c6e72cd10 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/custom/CustomKeyDown.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/custom/CustomKeyDown.java @@ -36,14 +36,10 @@ public class CustomKeyDown extends GestureDetector.SimpleOnGestureListener imple private boolean changeTime; private boolean multiTouch; private boolean animating; - private boolean shortDrama; private boolean touch; private boolean lock; private float bright; private float currentBright; - private float anchorY = Float.NaN; - private float downX; - private float downY; private float brightLimit = BrightnessPolicy.NO_LIMIT; private float volume; private float scale; @@ -69,51 +65,13 @@ public class CustomKeyDown extends GestureDetector.SimpleOnGestureListener imple int action = e.getActionMasked(); boolean end = action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL; if (action == MotionEvent.ACTION_DOWN) multiTouch = false; - // 起点在这里记而不是 onDown:onDown 在边缘/缩放/锁定时会提前返回, - // 而 changeScale 会在 onScaleEnd 之后 500ms 才解除,那段窗口里按下的手势 - // 拿不到自己的起点,长按转调节时会拿上一次的起点算位移,一按就跳。 - if (action == MotionEvent.ACTION_DOWN) { - downX = e.getX(); - downY = e.getY(); - } if (action == MotionEvent.ACTION_POINTER_DOWN) multiTouch = true; - // 被父容器拦截或来电时只收到 CANCEL:HUD 与倍速同样要收尾, - // 否则 widget 会一直盖在画面上、倍速卡在长按后的速率。 - if (end) listener.onTouchEnd(); - // 短剧的亮度/音量在长按之后才生效,此时 GestureDetector 已不再回调 onScroll, - // 只能在这里自行处理移动事件,详见 handleAdjust。用实时指数判断而不是 multiTouch: - // 后者在 ACTION_POINTER_UP 时不会复位,会把抬起一根手指后的单指调节一起挡掉。 - if (shortDrama && action == MotionEvent.ACTION_MOVE && e.getPointerCount() == 1 && !lock && !changeScale) handleAdjust(e); + if (action == MotionEvent.ACTION_UP) listener.onTouchEnd(); // 手势被父容器拦截或来电时只收到 CANCEL,同样要落盘,否则窗口已改而记忆值未更新 if (changeBright && end) PlayerSetting.putBrightness(currentBright); - if (changeSpeed && end) listener.onSpeedEnd(); - // seek 只在正常抬手时提交:CANCEL 属于手势被打断,维持原位置更符合预期。 + if (changeSpeed && action == MotionEvent.ACTION_UP) listener.onSpeedEnd(); if (changeTime && action == MotionEvent.ACTION_UP) listener.onSeekEnd(time); - boolean handled = e.getPointerCount() == 2 ? scaleDetector.onTouchEvent(e) : detector.onTouchEvent(e); - // 必须等 detector 处理完再清:onFling 是 detector 在 UP 时回调的,它要读 - // changeSpeed/changeBright/changeVolume 才能判断这次抬手是否该切集。 - // 提前清掉会让「调完亮度松手」被当成切集手势。 - if (end) clearGesture(); - return handled; - } - - /** - * 一次手势结束就把功能标记清干净。 - *

- * {@link #reset()} 挂在 onDown 上,而 onDown 在边缘/缩放/锁定时会提前返回, - * 于是上一次手势的 changeBright/anchorY 可能活到下一次手势——短剧的亮度/音量 - * 由 onTouchEvent 直接驱动,不再有 onScroll 的兜底,残留状态会直接造成 - * 「没长按就跳亮度」。所以收尾统一在这里做,不依赖下一次 onDown 是否被接受。 - */ - private void clearGesture() { - changeTime = false; - changeSpeed = false; - changeBright = false; - changeVolume = false; - anchorY = Float.NaN; - // 不动 touch:它表示「本次手势还没选定功能」,是就绪态而非残留动作。 - // 在这里清成 false 会让缩放后 500ms 内(changeScale 还没解除时)起手的那一次 - // 拖动选不到任何功能,白滑一次。它由 reset() 置真、checkFunc 置假,无需插手。 + return e.getPointerCount() == 2 ? scaleDetector.onTouchEvent(e) : detector.onTouchEvent(e); } private void applyBrightness() { @@ -163,17 +121,6 @@ public class CustomKeyDown extends GestureDetector.SimpleOnGestureListener imple this.lock = lock; } - /** - * 短剧竖屏形态下换用另一套手势轴向,参考红果短剧、抖音等主流竖屏播放器: - * 上下滑切上下集(全屏可用),亮度/音量改为长按后再上下滑,左半亮度右半音量。 - *

- * 原来「中间竖滑切集 + 左右 1/4 竖滑调亮度音量」在铺满屏幕的竖屏短剧里极易误触 - * (用户反馈):想切集却滑到了边缘,调亮度又常常切了集。 - */ - public void setShortDrama(boolean shortDrama) { - this.shortDrama = shortDrama; - } - public float getScale() { return scale; } @@ -223,7 +170,6 @@ public class CustomKeyDown extends GestureDetector.SimpleOnGestureListener imple private void reset() { time = 0; touch = true; - anchorY = Float.NaN; changeTime = false; changeSpeed = false; changeBright = false; @@ -262,43 +208,6 @@ public class CustomKeyDown extends GestureDetector.SimpleOnGestureListener imple return true; } - /** - * 短剧的「长按后上下滑调亮度/音量」。 - *

- * 不能挂在 {@link #onScroll} 上:GestureDetector 触发长按后会把后续 ACTION_MOVE - * 全部吞掉(mInLongPress 分支直接 break),onScroll 再也不会回调,所以这条手势 - * 必须由 onTouchEvent 自己驱动。 - */ - private void handleAdjust(MotionEvent e) { - // 只认「本次手势自己长按出来」的调节,避免上一次手势的残留标记把亮度带跑。 - if (!changeSpeed && Float.isNaN(anchorY)) return; - if (changeSpeed) { - float dx = e.getX() - downX; - float dy = e.getY() - downY; - if (Math.abs(dy) <= Math.abs(dx) || Math.abs(dy) < ResUtil.dp2px(20)) return; - startAdjust(e); - } - // 以「长按转调节」的那一刻为基准,避免把长按期间的位移一次性算进亮度/音量。 - float deltaY = anchorY - e.getY(); - if (changeBright) setBright(deltaY); - if (changeVolume) setVolume(deltaY); - } - - /** - * 长按倍速中途转为亮度/音量调节:先把倍速交还,再以当前触点为新基准。 - */ - private void startAdjust(MotionEvent e) { - listener.onSpeedEnd(); - changeSpeed = false; - touch = false; - anchorY = e.getY(); - float saved = PlayerSetting.getBrightness(); - bright = saved >= 0 ? saved : Util.getBrightness(activity); - currentBright = bright; - volume = manager.getStreamVolume(AudioManager.STREAM_MUSIC); - checkSide(e); - } - @Override public boolean onDoubleTap(@NonNull MotionEvent e) { if (isMultiple(e) || isEdge(e) || changeScale) return true; @@ -315,21 +224,15 @@ public class CustomKeyDown extends GestureDetector.SimpleOnGestureListener imple @Override public boolean onFling(MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) { - // 短剧上下滑切集不再受左右 1/4 的亮度/音量分区限制(那两块已改为长按触发), - // 但 isEdge 的 24dp 边框仍然保留:那里要让给系统的返回/通知栏手势。 - if (isMultiple(e1) || isEdge(e1) || (!shortDrama && isSide(e1)) || changeScale || lock || animating) return true; - // 这次手势已经用于调亮度/音量或倍速,抬手不应再被当成切集。 - if (changeSpeed || changeBright || changeVolume) return true; + if (isMultiple(e1) || isEdge(e1) || isSide(e1) || changeScale || lock || animating) return true; checkFunc(e1, e2); return true; } private void checkFunc(float distanceX, float distanceY, MotionEvent e2) { if ((float) Math.sqrt(distanceX * distanceX + distanceY * distanceY) < ResUtil.dp2px(20)) return; - // 短剧的上下滑整屏留给切集(onFling),亮度/音量改由 startAdjust(长按后再滑)接管, - // 所以这里只保留横滑拖进度,不再按左右 1/4 分派亮度/音量。 if (distanceX >= distanceY) changeTime = true; - else if (!shortDrama && isSide(e2)) checkSide(e2); + else if (isSide(e2)) checkSide(e2); touch = false; } @@ -337,13 +240,10 @@ public class CustomKeyDown extends GestureDetector.SimpleOnGestureListener imple float dx = e2.getX() - e1.getX(); float dy = e2.getY() - e1.getY(); double angle = Math.toDegrees(Math.atan2(Math.abs(dy), Math.abs(dx))); - // 短剧的切集动画沿用同一套竖向回弹,但不跟随直播的「反转」开关: - // 竖屏短剧里上滑=下一集是抖音/红果的既定语义,反转会让用户完全迷失。 - boolean invert = !shortDrama && LiveSetting.isInvert(); if (angle > 70 && e1.getY() - e2.getY() > DISTANCE) { - videoView.animate().translationYBy(ResUtil.dp2px(invert ? 24 : -24)).setDuration(150).withStartAction(() -> animating = true).withEndAction(() -> videoView.animate().translationY(0).setDuration(100).withStartAction(listener::onFlingUp).withEndAction(() -> animating = false).start()).start(); + videoView.animate().translationYBy(ResUtil.dp2px(LiveSetting.isInvert() ? 24 : -24)).setDuration(150).withStartAction(() -> animating = true).withEndAction(() -> videoView.animate().translationY(0).setDuration(100).withStartAction(listener::onFlingUp).withEndAction(() -> animating = false).start()).start(); } else if (angle > 70 && e2.getY() - e1.getY() > DISTANCE) { - videoView.animate().translationYBy(ResUtil.dp2px(invert ? -24 : 24)).setDuration(150).withStartAction(() -> animating = true).withEndAction(() -> videoView.animate().translationY(0).setDuration(100).withStartAction(listener::onFlingDown).withEndAction(() -> animating = false).start()).start(); + videoView.animate().translationYBy(ResUtil.dp2px(LiveSetting.isInvert() ? -24 : 24)).setDuration(150).withStartAction(() -> animating = true).withEndAction(() -> videoView.animate().translationY(0).setDuration(100).withStartAction(listener::onFlingDown).withEndAction(() -> animating = false).start()).start(); } } @@ -365,9 +265,8 @@ public class CustomKeyDown extends GestureDetector.SimpleOnGestureListener imple } private void setVolume(float deltaY) { - int height = Math.max(videoView.getMeasuredHeight(), 1); + int height = videoView.getMeasuredHeight(); int maxVolume = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC); - if (maxVolume <= 0) return; float deltaV = deltaY * 2.0f / height * maxVolume; float index = volume + deltaV; if (index > maxVolume) index = maxVolume; diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java index 3916b95fe3..9c74ea1b9d 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java @@ -1,12 +1,9 @@ package com.fongmi.android.tv.ui.dialog; import android.app.Activity; -import android.text.Editable; -import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.EditText; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; @@ -14,27 +11,17 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.AdBlockStatsStore; -import com.fongmi.android.tv.bean.AdBlockLog; -import com.fongmi.android.tv.bean.AdBlockLogFilter; import com.fongmi.android.tv.bean.AdBlockStats; import com.fongmi.android.tv.bean.RuleHitRecord; -import com.fongmi.android.tv.databinding.AdapterAdBlockLogBinding; import com.fongmi.android.tv.databinding.DialogAdBlockStatsBinding; import com.fongmi.android.tv.impl.Callback; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.utils.AdBlockTimeFormatter; -import com.fongmi.android.tv.widget.AdBlockChartView; -import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Date; import java.util.Comparator; -import java.util.EnumMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; /** @@ -45,13 +32,6 @@ public class AdBlockStatsDialog { private final DialogAdBlockStatsBinding binding; private final AlertDialog dialog; private final Activity activity; - private final EnumMap logFilters = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final EnumMap> logSelections = - new EnumMap<>(AdBlockLogFilter.Column.class); - private List currentLogs = new ArrayList<>(); - private BlockLogAdapter logAdapter; - private boolean logFiltersBound; public static AdBlockStatsDialog create(Activity activity) { return new AdBlockStatsDialog(activity); @@ -60,7 +40,7 @@ public class AdBlockStatsDialog { private AdBlockStatsDialog(Activity activity) { this.activity = activity; this.binding = DialogAdBlockStatsBinding.inflate(LayoutInflater.from(activity)); - this.dialog = new WebHtvAlertDialogBuilder(activity) + this.dialog = new MaterialAlertDialogBuilder(activity) .setView(binding.getRoot()) .create(); } @@ -69,126 +49,11 @@ public class AdBlockStatsDialog { initView(); loadStats(); dialog.show(); - - android.view.Window window = dialog.getWindow(); - if (window != null) { - android.util.DisplayMetrics metrics = activity.getResources().getDisplayMetrics(); - int horizontalMargin = Math.round(16 * metrics.density); - int verticalMargin = Math.round(16 * metrics.density); - int width = Math.max(1, DialogSafeArea.width(activity, window) - horizontalMargin * 2); - int height = Math.max(1, DialogSafeArea.height(activity, window) - verticalMargin * 2); - android.view.WindowManager.LayoutParams params = window.getAttributes(); - params.width = width; - params.height = height; - params.gravity = android.view.Gravity.CENTER; - window.setBackgroundDrawable(new android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT)); - window.getDecorView().setPadding(0, 0, 0, 0); - window.setAttributes(params); - window.setLayout(width, height); - binding.getRoot().setMinimumHeight(height); - } } private void initView() { binding.reset.setOnClickListener(v -> onReset()); binding.close.setOnClickListener(v -> dialog.dismiss()); - if (binding.statsTabs.getTabCount() == 0) { - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_overview)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_site_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_rule_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_pipeline_rank)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_log)); - binding.statsTabs.addTab(binding.statsTabs.newTab().setText(R.string.ad_stats_chart)); - binding.statsTabs.addOnTabSelectedListener(new com.google.android.material.tabs.TabLayout.OnTabSelectedListener() { - @Override public void onTabSelected(com.google.android.material.tabs.TabLayout.Tab tab) { showPage(tab.getPosition()); } - @Override public void onTabUnselected(com.google.android.material.tabs.TabLayout.Tab tab) {} - @Override public void onTabReselected(com.google.android.material.tabs.TabLayout.Tab tab) {} - }); - } - if (!logFiltersBound) { - bindLogFilters(); - logFiltersBound = true; - } - showPage(binding.statsTabs.getSelectedTabPosition()); - } - - private void bindLogFilters() { - bindCategoricalFilter(binding.siteFilter, AdBlockLogFilter.Column.SITE_NAME, R.string.ad_log_site_name); - bindCategoricalFilter(binding.siteDomainFilter, AdBlockLogFilter.Column.SITE_DOMAIN, R.string.ad_log_site_domain); - bindCategoricalFilter(binding.vodFilter, AdBlockLogFilter.Column.VOD_NAME, R.string.ad_log_playback_context); - bindCategoricalFilter(binding.lineFilter, AdBlockLogFilter.Column.LINE_NAME, R.string.ad_log_line_name); - bindCategoricalFilter(binding.episodeFilter, AdBlockLogFilter.Column.EPISODE_NAME, R.string.ad_log_episode_name); - bindCategoricalFilter(binding.ruleFilter, AdBlockLogFilter.Column.RULE, R.string.ad_log_rule_domain); - bindCategoricalFilter(binding.adDomainFilter, AdBlockLogFilter.Column.AD_DOMAIN, R.string.ad_log_ad_domain); - bindCategoricalFilter(binding.pipelineFilter, AdBlockLogFilter.Column.PIPELINE, R.string.ad_log_pipeline); - bindTextFilter(binding.blockedAtFilter, AdBlockLogFilter.Column.BLOCKED_AT); - bindTextFilter(binding.segmentStartFilter, AdBlockLogFilter.Column.SEGMENT_START); - bindTextFilter(binding.segmentEndFilter, AdBlockLogFilter.Column.SEGMENT_END); - bindTextFilter(binding.segmentDurationFilter, AdBlockLogFilter.Column.SEGMENT_DURATION); - } - - private void bindCategoricalFilter(MaterialButton button, AdBlockLogFilter.Column column, int title) { - button.setOnClickListener(v -> showCategoricalFilter(button, column, title)); - updateCategoricalButton(button, column); - } - - private void showCategoricalFilter(MaterialButton button, AdBlockLogFilter.Column column, int title) { - Set selected = logSelections.get(column); - AdBlockLogFilterDialog.show(activity, activity.getString(title), currentLogs, - selected == null ? java.util.Set.of() : new java.util.LinkedHashSet<>(selected), - log -> displayValue(log, column), values -> { - if (values == null || values.isEmpty()) logSelections.remove(column); - else logSelections.put(column, new java.util.LinkedHashSet<>(values)); - updateCategoricalButton(button, column); - if (logAdapter != null) { - logAdapter.setSelection(column, values); - updateLogEmptyState(); - } - }); - } - - private void updateCategoricalButton(MaterialButton button, AdBlockLogFilter.Column column) { - Set selected = logSelections.get(column); - if (selected == null || selected.isEmpty()) { - button.setText(R.string.ad_log_filter_hint); - } else if (selected.size() == 1) { - button.setText(selected.iterator().next()); - } else { - button.setText(activity.getString(R.string.ad_log_filter_selected, selected.size())); - } - button.setEnabled(!currentLogs.isEmpty()); - } - - private void bindTextFilter(EditText input, AdBlockLogFilter.Column column) { - input.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - - @Override - public void afterTextChanged(Editable s) { - String query = s == null ? "" : s.toString(); - if (query.trim().isEmpty()) logFilters.remove(column); - else logFilters.put(column, query); - if (logAdapter != null) { - logAdapter.setFilter(column, query); - updateLogEmptyState(); - } - } - }); - } - - private void showPage(int position) { - binding.overviewPage.setVisibility(position == 0 ? View.VISIBLE : View.GONE); - binding.sitePage.setVisibility(position == 1 ? View.VISIBLE : View.GONE); - binding.rulePage.setVisibility(position == 2 ? View.VISIBLE : View.GONE); - binding.pipelinePage.setVisibility(position == 3 ? View.VISIBLE : View.GONE); - binding.logPage.setVisibility(position == 4 ? View.VISIBLE : View.GONE); - binding.chartPage.setVisibility(position == 5 ? View.VISIBLE : View.GONE); } private void loadStats() { @@ -205,29 +70,18 @@ public class AdBlockStatsDialog { binding.aiSuccessRate.setText("0%"); } - // 站点维度统计概览与排行使用同一份快照 + // 站点拦截排行 List siteRank = buildSiteRank(stats); - binding.siteCount.setText(String.valueOf(stats.getSiteBlocked().size())); - if (siteRank.isEmpty()) { - binding.topSite.setText("-"); - binding.topSiteShare.setText("0%"); - } else { - SiteRankItem top = siteRank.get(0); - binding.topSite.setText(top.getDisplayName()); - double share = stats.getTotalBlocked() > 0 ? top.getCount() * 100.0 / stats.getTotalBlocked() : 0.0; - binding.topSiteShare.setText(String.format(Locale.getDefault(), "%.1f%%", share)); - } - - // 站点、规则和播放链路默认显示汇总项,点击汇总项展开对应的逐条日志。 if (siteRank.isEmpty()) { binding.siteRankEmpty.setVisibility(View.VISIBLE); binding.siteRankRecycler.setVisibility(View.GONE); } else { binding.siteRankEmpty.setVisibility(View.GONE); binding.siteRankRecycler.setVisibility(View.VISIBLE); - binding.siteRankRecycler.setAdapter(new GroupedLogAdapter(siteRank, stats, GroupType.SOURCE)); + binding.siteRankRecycler.setAdapter(new SiteRankAdapter(siteRank)); } + // 规则命中排行 List ruleRank = AdBlockStatsStore.getTopRules(10); if (ruleRank.isEmpty()) { binding.ruleRankEmpty.setVisibility(View.VISIBLE); @@ -235,126 +89,24 @@ public class AdBlockStatsDialog { } else { binding.ruleRankEmpty.setVisibility(View.GONE); binding.ruleRankRecycler.setVisibility(View.VISIBLE); - binding.ruleRankRecycler.setAdapter(new RuleGroupedLogAdapter(ruleRank, stats)); + binding.ruleRankRecycler.setAdapter(new RuleRankAdapter(ruleRank)); } - - List pipelineRank = buildPipelineRank(stats); - if (pipelineRank.isEmpty()) { - binding.pipelineRankEmpty.setVisibility(View.VISIBLE); - binding.pipelineRankRecycler.setVisibility(View.GONE); - } else { - binding.pipelineRankEmpty.setVisibility(View.GONE); - binding.pipelineRankRecycler.setVisibility(View.VISIBLE); - binding.pipelineRankRecycler.setAdapter(new GroupedLogAdapter(pipelineRank, stats, GroupType.PIPELINE)); - } - - List logs = stats.getBlockLogs(); - currentLogs = new ArrayList<>(logs); - updateAllCategoricalButtons(); - logAdapter = new BlockLogAdapter(logs, logFilters, logSelections, this::filterValues); - binding.logRecycler.setAdapter(logAdapter); - updateLogEmptyState(); - - List chartItems = buildSiteRank(stats); - binding.chartEmpty.setVisibility(chartItems.isEmpty() ? View.VISIBLE : View.GONE); - binding.chartView.setVisibility(chartItems.isEmpty() ? View.GONE : View.VISIBLE); - List chartEntries = chartItems.stream() - .map(item -> new AdBlockChartView.Entry(item.getDisplayName(), item.getCount())) - .collect(Collectors.toList()); - binding.chartView.setEntries(chartEntries); - } - - private void updateAllCategoricalButtons() { - updateCategoricalButton(binding.siteFilter, AdBlockLogFilter.Column.SITE_NAME); - updateCategoricalButton(binding.siteDomainFilter, AdBlockLogFilter.Column.SITE_DOMAIN); - updateCategoricalButton(binding.vodFilter, AdBlockLogFilter.Column.VOD_NAME); - updateCategoricalButton(binding.lineFilter, AdBlockLogFilter.Column.LINE_NAME); - updateCategoricalButton(binding.episodeFilter, AdBlockLogFilter.Column.EPISODE_NAME); - updateCategoricalButton(binding.ruleFilter, AdBlockLogFilter.Column.RULE); - updateCategoricalButton(binding.adDomainFilter, AdBlockLogFilter.Column.AD_DOMAIN); - updateCategoricalButton(binding.pipelineFilter, AdBlockLogFilter.Column.PIPELINE); - } - - private Map filterValues(AdBlockLog item) { - EnumMap values = new EnumMap<>(AdBlockLogFilter.Column.class); - for (AdBlockLogFilter.Column column : AdBlockLogFilter.Column.values()) { - values.put(column, displayValue(item, column)); - } - return values; - } - - private String displayValue(AdBlockLog item, AdBlockLogFilter.Column column) { - String unknown = activity.getString(R.string.ad_log_unknown); - switch (column) { - case SITE_NAME: return value(item.getSiteName(), unknown); - case SITE_DOMAIN: return value(item.getSiteDomain(), unknown); - case VOD_NAME: return value(item.getVodName(), unknown); - case LINE_NAME: return value(item.getLineName(), unknown); - case EPISODE_NAME: return value(item.getEpisodeName(), unknown); - case RULE: return value(AdBlockStatsStore.getRuleDisplayName(item.getRuleId()), unknown); - case AD_DOMAIN: return value(item.getAdDomain(), unknown); - case BLOCKED_AT: return item.getBlockedAt() > 0 - ? new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(new Date(item.getBlockedAt())) : unknown; - case SEGMENT_START: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentStartSeconds()) : unknown; - case SEGMENT_END: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentEndSeconds()) : unknown; - case SEGMENT_DURATION: return item.hasSegmentTiming() ? AdBlockTimeFormatter.formatSeconds(item.getSegmentDurationSeconds()) : unknown; - case PIPELINE: return value(item.getPipelineName(), unknown); - default: return unknown; - } - } - - private static String value(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; - } - - private void updateLogEmptyState() { - boolean noLogs = logAdapter == null || logAdapter.getSourceItemCount() == 0; - boolean noMatches = !noLogs && logAdapter.getItemCount() == 0; - binding.logEmpty.setText(noLogs ? R.string.ad_stats_empty : R.string.ad_stats_filter_empty); - binding.logEmpty.setVisibility(noLogs || noMatches ? View.VISIBLE : View.GONE); - binding.logTableScroll.setVisibility(noLogs ? View.GONE : View.VISIBLE); } private List buildSiteRank(AdBlockStats stats) { - Map displayNames = new java.util.HashMap<>(); - for (AdBlockLog log : stats.getBlockLogs()) { - String name = log.getSiteName(); - if (name == null || name.isBlank()) continue; - if (!log.getSiteKey().isBlank()) displayNames.putIfAbsent(log.getSiteKey(), name); - displayNames.putIfAbsent(log.getSourceName(), name); - } return stats.getSiteBlocked().entrySet().stream() - .map(entry -> new SiteRankItem(entry.getKey(), - displayNames.getOrDefault(entry.getKey(), entry.getKey()), entry.getValue())) + .map(entry -> new SiteRankItem(entry.getKey(), entry.getValue())) .sorted(Comparator.comparingLong(SiteRankItem::getCount).reversed()) .limit(10) .collect(Collectors.toList()); } - private List buildPipelineRank(AdBlockStats stats) { - return stats.getPipelineCounts().entrySet().stream() - .map(entry -> new SiteRankItem(entry.getKey(), entry.getValue())) - .sorted(Comparator.comparingLong(SiteRankItem::getCount).reversed()) - .collect(Collectors.toList()); - } - - private void clearTextFilters() { - binding.blockedAtFilter.setText(""); - binding.segmentStartFilter.setText(""); - binding.segmentEndFilter.setText(""); - binding.segmentDurationFilter.setText(""); - updateAllCategoricalButtons(); - } - private void onReset() { - new WebHtvAlertDialogBuilder(activity) + new MaterialAlertDialogBuilder(activity) .setTitle(R.string.ad_stats_reset) .setMessage(R.string.ad_stats_reset_confirm) .setPositiveButton(android.R.string.ok, (d, which) -> { AdBlockStatsStore.reset(); - logFilters.clear(); - logSelections.clear(); - clearTextFilters(); loadStats(); }) .setNegativeButton(android.R.string.cancel, null) @@ -364,16 +116,10 @@ public class AdBlockStatsDialog { // 站点排行项 private static class SiteRankItem { private final String siteKey; - private final String displayName; private final long count; public SiteRankItem(String siteKey, long count) { - this(siteKey, siteKey, count); - } - - public SiteRankItem(String siteKey, String displayName, long count) { this.siteKey = siteKey; - this.displayName = displayName; this.count = count; } @@ -381,213 +127,11 @@ public class AdBlockStatsDialog { return siteKey; } - public String getDisplayName() { - return displayName; - } - public long getCount() { return count; } } - private enum GroupType { SOURCE, PIPELINE } - - private static class GroupedLogAdapter extends RecyclerView.Adapter { - private final List groups; - private final AdBlockStats stats; - private final GroupType type; - private final java.util.Set expanded = new java.util.HashSet<>(); - private final List visibleItems = new ArrayList<>(); - - GroupedLogAdapter(List groups, AdBlockStats stats, GroupType type) { - this.groups = groups; - this.stats = stats; - this.type = type; - rebuild(); - } - - private void rebuild() { - visibleItems.clear(); - for (SiteRankItem group : groups) { - visibleItems.add(group); - if (expanded.contains(group.getSiteKey())) { - visibleItems.addAll(type == GroupType.SOURCE - ? stats.getBlockLogsBySite(group.getSiteKey()) - : stats.getBlockLogsByPipeline(group.getSiteKey())); - } - } - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - Object visible = visibleItems.get(position); - if (visible instanceof SiteRankItem) { - SiteRankItem group = (SiteRankItem) visible; - boolean isExpanded = expanded.contains(group.getSiteKey()); - holder.binding.name.setText((isExpanded ? "▼ " : "▶ ") + group.getDisplayName()); - holder.binding.source.setVisibility(View.GONE); - holder.binding.count.setText(String.valueOf(group.getCount())); - holder.itemView.setOnClickListener(view -> { - if (!expanded.add(group.getSiteKey())) expanded.remove(group.getSiteKey()); - rebuild(); - notifyDataSetChanged(); - }); - } else { - bindLog(holder, (AdBlockLog) visible); - } - } - - private void bindLog(ViewHolder holder, AdBlockLog log) { - holder.binding.name.setText(" " + log.getAdDomain()); - holder.binding.source.setText(log.getSourceName() + " · " + log.getPipelineName() + " · " + log.getRuleId()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", log.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(null); - } - - @Override - public int getItemCount() { - return visibleItems.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private static class RuleGroupedLogAdapter extends RecyclerView.Adapter { - private final List groups; - private final AdBlockStats stats; - private final java.util.Set expanded = new java.util.HashSet<>(); - private final List visibleItems = new ArrayList<>(); - - RuleGroupedLogAdapter(List groups, AdBlockStats stats) { - this.groups = groups; - this.stats = stats; - rebuild(); - } - - private void rebuild() { - visibleItems.clear(); - for (RuleHitRecord group : groups) { - visibleItems.add(group); - if (expanded.contains(group.getRuleId())) visibleItems.addAll(stats.getBlockLogsByRule(group.getRuleId())); - } - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - Object visible = visibleItems.get(position); - if (visible instanceof RuleHitRecord) { - RuleHitRecord group = (RuleHitRecord) visible; - boolean isExpanded = expanded.contains(group.getRuleId()); - String name = "hls.legacy-fallback".equals(group.getRuleId()) ? "内部兜底规则" : group.getRuleName(); - if (name == null || name.isEmpty()) name = group.getRuleId(); - holder.binding.name.setText((isExpanded ? "▼ " : "▶ ") + name); - holder.binding.source.setText(group.getRuleSource()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.valueOf(group.getHitCount())); - holder.itemView.setOnClickListener(view -> { - if (!expanded.add(group.getRuleId())) expanded.remove(group.getRuleId()); - rebuild(); - notifyDataSetChanged(); - }); - } else { - AdBlockLog log = (AdBlockLog) visible; - holder.binding.name.setText(" " + log.getAdDomain()); - holder.binding.source.setText(log.getSourceName() + " · " + log.getPipelineName()); - holder.binding.source.setVisibility(View.VISIBLE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", log.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(null); - } - } - - @Override - public int getItemCount() { - return visibleItems.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private static class ExpandableLogAdapter extends RecyclerView.Adapter { - private final List items; - private boolean expanded; - - ExpandableLogAdapter(List items) { - this.items = items; - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.adapter_ad_stats_item, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - AdBlockLog item = items.get(position); - holder.binding.name.setText(item.getAdDomain()); - holder.binding.source.setText(item.getSourceName() + " · " + item.getPipelineName() + " · " + item.getRuleId()); - holder.binding.source.setVisibility(expanded ? View.VISIBLE : View.GONE); - holder.binding.count.setText(String.format(Locale.getDefault(), "%.1fs", item.getSegmentDurationSeconds())); - holder.itemView.setOnClickListener(view -> { - expanded = !expanded; - notifyDataSetChanged(); - }); - } - - @Override - public int getItemCount() { - return items.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding binding; - - ViewHolder(@NonNull View itemView) { - super(itemView); - binding = com.fongmi.android.tv.databinding.AdapterAdStatsItemBinding.bind(itemView); - } - } - } - - private List getBlockLogsBySource(AdBlockStats stats, String source) { - return stats.getBlockLogsBySource(source); - } - - private List getBlockLogsByRule(AdBlockStats stats, String rule) { - return stats.getBlockLogsByRule(rule); - } - - private List getBlockLogsByPipeline(AdBlockStats stats, String pipeline) { - return stats.getBlockLogsByPipeline(pipeline); - } - // 站点排行适配器 private static class SiteRankAdapter extends RecyclerView.Adapter { private final List items; @@ -605,7 +149,7 @@ public class AdBlockStatsDialog { @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { SiteRankItem item = items.get(position); - holder.binding.name.setText(item.getDisplayName()); + holder.binding.name.setText(item.getSiteKey()); holder.binding.count.setText(String.valueOf(item.getCount())); holder.binding.source.setVisibility(View.GONE); } @@ -625,106 +169,6 @@ public class AdBlockStatsDialog { } } - private static class BlockLogAdapter extends RecyclerView.Adapter { - private final List sourceItems; - private List items; - private final EnumMap filters = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final EnumMap> selections = - new EnumMap<>(AdBlockLogFilter.Column.class); - private final AdBlockLogFilter.ValueProvider valueProvider; - private final SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()); - - BlockLogAdapter(List items, Map initialFilters, - Map> initialSelections, - AdBlockLogFilter.ValueProvider valueProvider) { - this.sourceItems = items == null ? new ArrayList<>() : new ArrayList<>(items); - if (initialFilters != null) filters.putAll(initialFilters); - if (initialSelections != null) { - for (Map.Entry> entry : initialSelections.entrySet()) { - if (entry.getValue() != null && !entry.getValue().isEmpty()) { - selections.put(entry.getKey(), new java.util.LinkedHashSet<>(entry.getValue())); - } - } - } - this.valueProvider = valueProvider; - applyFilters(); - } - - void setFilter(AdBlockLogFilter.Column column, String query) { - if (query == null || query.trim().isEmpty()) filters.remove(column); - else filters.put(column, query); - applyFilters(); - notifyDataSetChanged(); - } - - void setSelection(AdBlockLogFilter.Column column, Set values) { - if (values == null || values.isEmpty()) selections.remove(column); - else selections.put(column, new java.util.LinkedHashSet<>(values)); - applyFilters(); - notifyDataSetChanged(); - } - - int getSourceItemCount() { - return sourceItems.size(); - } - - private void applyFilters() { - items = AdBlockLogFilter.filter(sourceItems, filters, selections, valueProvider); - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - AdapterAdBlockLogBinding binding = AdapterAdBlockLogBinding.inflate( - LayoutInflater.from(parent.getContext()), parent, false); - return new ViewHolder(binding); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - AdBlockLog item = items.get(position); - String unknown = holder.itemView.getContext().getString(R.string.ad_log_unknown); - holder.binding.siteName.setText(value(item.getSiteName(), unknown)); - holder.binding.siteDomain.setText(value(item.getSiteDomain(), unknown)); - holder.binding.playbackContext.setText(value(item.getVodName(), unknown)); - holder.binding.lineName.setText(value(item.getLineName(), unknown)); - holder.binding.episodeName.setText(value(item.getEpisodeName(), unknown)); - String rule = AdBlockStatsStore.getRuleDisplayName(item.getRuleId()); - String domain = value(item.getAdDomain(), unknown); - holder.binding.ruleDomain.setText(value(rule, unknown)); - holder.binding.adDomain.setText(domain); - holder.binding.blockedAt.setText(item.getBlockedAt() > 0 - ? timeFormat.format(new Date(item.getBlockedAt())) : unknown); - holder.binding.segmentStart.setText(item.hasSegmentTiming() ? seconds(item.getSegmentStartSeconds()) : unknown); - holder.binding.segmentEnd.setText(item.hasSegmentTiming() ? seconds(item.getSegmentEndSeconds()) : unknown); - holder.binding.segmentDuration.setText(item.hasSegmentTiming() ? seconds(item.getSegmentDurationSeconds()) : unknown); - holder.binding.pipeline.setText(value(item.getPipelineName(), unknown)); - } - - private static String seconds(double value) { - return AdBlockTimeFormatter.formatSeconds(value); - } - - private static String value(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; - } - - @Override - public int getItemCount() { - return items.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final AdapterAdBlockLogBinding binding; - - ViewHolder(AdapterAdBlockLogBinding binding) { - super(binding.getRoot()); - this.binding = binding; - } - } - } - // 规则排行适配器 private static class RuleRankAdapter extends RecyclerView.Adapter { private final List items; diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java index ec69a2b7a2..855915f600 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java @@ -15,7 +15,6 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.UserAdRuleStore; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.databinding.DialogAdRuleEditBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -56,7 +55,7 @@ public class AdRuleEditDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java index d29b0aa6f8..69c5d58e57 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleManageDialog.java @@ -1,6 +1,5 @@ package com.fongmi.android.tv.ui.dialog; -import android.app.Dialog; import android.content.Context; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; @@ -8,11 +7,8 @@ import android.view.Gravity; import android.view.View; import android.view.Window; import android.view.WindowManager; -import android.widget.LinearLayout; -import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; import androidx.viewbinding.ViewBinding; @@ -30,12 +26,10 @@ import com.fongmi.android.tv.bean.HlsAdRule; import com.fongmi.android.tv.bean.Rule; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.databinding.DialogAdRuleManageBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.AdRuleAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.button.MaterialButton; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -74,7 +68,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -92,8 +86,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter binding.add.setOnClickListener(v -> onAddManual()); binding.stats.setOnClickListener(v -> onStats()); binding.importCandidates.setOnClickListener(v -> onImportCandidates()); - binding.enableImported.setOnClickListener(v -> onEnableImported()); - binding.disableImported.setOnClickListener(v -> onDisableImported()); } private void loadData() { @@ -128,8 +120,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter int pending = ImportedAdRuleCandidateStore.pending().size(); binding.importCandidates.setVisibility(pending == 0 ? View.GONE : View.VISIBLE); binding.importCandidates.setText(getString(R.string.ad_rule_import_candidates, pending)); - boolean hasImported = userRules.stream().anyMatch(rule -> UserAdRule.isInterfaceSource(rule.getSource())); - binding.importBulkActions.setVisibility(hasImported ? View.VISIBLE : View.GONE); } private void onAddManual() { @@ -140,25 +130,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter AdBlockStatsDialog.create((FragmentActivity) requireActivity()).show(); } - private void onEnableImported() { - if (UserAdRuleStore.setInterfaceRulesEnabled(true) == 0) return; - loadData(); - if (callback != null) callback.onRuleChanged(); - } - - private void onDisableImported() { - new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) - .setTitle(R.string.ad_rule_disable_imported) - .setMessage(R.string.ad_rule_disable_imported_confirm) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - UserAdRuleStore.setInterfaceRulesEnabled(false); - loadData(); - if (callback != null) callback.onRuleChanged(); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - private void onImportCandidates() { List candidates = ImportedAdRuleCandidateStore.pending(); if (candidates.isEmpty()) return; @@ -171,91 +142,19 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter .toArray(String[]::new); boolean[] selected = new boolean[candidates.size()]; for (int i = 0; i < selected.length; i++) selected[i] = ImportedAdRuleCandidate.RISK_LOW.equals(candidates.get(i).getRiskLevel()); - final AlertDialog[] dialogRef = new AlertDialog[1]; - View toolbar = createCandidateToolbar( - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 0), - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 1), - () -> updateCandidateSelection(dialogRef[0], candidates, selected, 2), - () -> ignoreCandidates(dialogRef[0], candidates)); - MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) + new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.ad_rule_import_title) - .setCustomTitle(toolbar) .setMultiChoiceItems(labels, selected, (dialog, which, checked) -> selected[which] = checked) - .setPositiveButton(R.string.ad_rule_import_enable_action, (dialog, which) -> importSelectedCandidates(candidates, selected, true)) - .setNeutralButton(R.string.ad_rule_import_action, (dialog, which) -> importSelectedCandidates(candidates, selected, false)) - .setNegativeButton(android.R.string.cancel, null); - dialogRef[0] = builder.create(); - dialogRef[0].show(); - } - - private void ignoreCandidates(Dialog dialog, List candidates) { - List ids = new ArrayList<>(); - for (ImportedAdRuleCandidate candidate : candidates) ids.add(candidate.getId()); - ImportedAdRuleCandidateStore.ignoreCandidates(ids); - if (dialog != null) dialog.dismiss(); - loadData(); - } - - private void importSelectedCandidates(List candidates, boolean[] selected, boolean enabled) { - List ids = new ArrayList<>(); - for (int i = 0; i < candidates.size(); i++) if (selected[i]) ids.add(candidates.get(i).getId()); - if (!ids.isEmpty()) ImportedAdRuleCandidateStore.importCandidates(ids, enabled); - onRuleEdited(); - } - - private View createCandidateToolbar(Runnable selectLowRisk, Runnable selectAll, Runnable invert, Runnable ignoreAll) { - LinearLayout root = new LinearLayout(requireContext()); - root.setOrientation(LinearLayout.VERTICAL); - root.setPadding(ResUtil.dp2px(24), ResUtil.dp2px(8), ResUtil.dp2px(24), 0); - TextView title = new TextView(requireContext()); - title.setText(R.string.ad_rule_import_title); - title.setTextSize(20); - root.addView(title, new LinearLayout.LayoutParams(-1, -2)); - MaterialButton lowRisk = candidateActionButton(R.string.ad_rule_select_low_risk); - lowRisk.setOnClickListener(v -> selectLowRisk.run()); - MaterialButton all = candidateActionButton(R.string.ad_rule_select_all); - all.setOnClickListener(v -> selectAll.run()); - MaterialButton invertButton = candidateActionButton(R.string.ad_rule_invert_selection); - invertButton.setOnClickListener(v -> invert.run()); - MaterialButton ignore = candidateActionButton(R.string.ad_rule_ignore_all); - ignore.setOnClickListener(v -> ignoreAll.run()); - root.addView(lowRisk, new LinearLayout.LayoutParams(-1, -2)); - LinearLayout row = new LinearLayout(requireContext()); - row.setOrientation(LinearLayout.HORIZONTAL); - LinearLayout.LayoutParams first = new LinearLayout.LayoutParams(0, -2, 1f); - first.setMargins(0, ResUtil.dp2px(4), ResUtil.dp2px(2), 0); - row.addView(all, first); - LinearLayout.LayoutParams middle = new LinearLayout.LayoutParams(0, -2, 1f); - middle.setMargins(ResUtil.dp2px(2), ResUtil.dp2px(4), ResUtil.dp2px(2), 0); - row.addView(invertButton, middle); - LinearLayout.LayoutParams last = new LinearLayout.LayoutParams(0, -2, 1f); - last.setMargins(ResUtil.dp2px(2), ResUtil.dp2px(4), 0, 0); - row.addView(ignore, last); - root.addView(row); - return root; - } - - private MaterialButton candidateActionButton(int textRes) { - MaterialButton button = new MaterialButton(requireContext()); - button.setText(textRes); - button.setAllCaps(false); - button.setTextSize(12); - button.setMinHeight(ResUtil.dp2px(40)); - return button; - } - - private void updateCandidateSelection(Dialog dialog, List candidates, - boolean[] selected, int mode) { - if (mode == 0) { - for (int i = 0; i < selected.length; i++) selected[i] = ImportedAdRuleCandidate.RISK_LOW.equals(candidates.get(i).getRiskLevel()); - } else if (mode == 1) { - for (int i = 0; i < selected.length; i++) selected[i] = true; - } else { - for (int i = 0; i < selected.length; i++) selected[i] = !selected[i]; - } - if (dialog instanceof AlertDialog alert && alert.getListView() != null) { - for (int i = 0; i < selected.length; i++) alert.getListView().setItemChecked(i, selected[i]); - } + .setPositiveButton(R.string.ad_rule_import_action, (dialog, which) -> { + for (int i = 0; i < candidates.size(); i++) if (selected[i]) ImportedAdRuleCandidateStore.importCandidate(candidates.get(i).getId()); + onRuleEdited(); + }) + .setNeutralButton(R.string.ad_rule_ignore_all, (dialog, which) -> { + for (ImportedAdRuleCandidate candidate : candidates) ImportedAdRuleCandidateStore.ignore(candidate.getId()); + loadData(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); } private void onRuleEdited() { @@ -277,12 +176,9 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter @Override public void onHlsRuleClick(HlsRuleConfig.Entry item) { - HlsRuleConfig.Entry current = HlsRuleConfig.getEntries().stream() - .filter(entry -> item.key().equals(entry.key())) - .findFirst().orElse(item); - new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) - .setTitle(current.name().isBlank() ? current.id() : current.name()) - .setMessage(getHlsRuleDetail(current)) + new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + .setTitle(item.name().isBlank() ? item.id() : item.name()) + .setMessage(getHlsRuleDetail(item)) .setPositiveButton(android.R.string.ok, null) .show(); } @@ -290,7 +186,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void showRuleDetail(String name, String summary, List hosts, List regex, List exclude, Runnable editAction) { String message = (summary.isEmpty() ? "" : summary + "\n\n") + "域名\n" + listText(hosts) + "\n\nURL 规则\n" + listText(regex) + "\n\n白名单\n" + listText(exclude); - MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) .setTitle(name) .setMessage(message) .setPositiveButton(android.R.string.ok, null); @@ -302,11 +198,6 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter String status = item.valid() ? getString(item.enabled() ? R.string.ad_rule_hls_enabled : R.string.ad_rule_hls_disabled) : getString(R.string.ad_rule_hls_invalid, item.error()); - if (HlsRuleConfig.LEGACY_FALLBACK_KEY.equals(item.id())) { - return getString(R.string.ad_rule_hls_builtin_summary, item.version(), status) - + "\n\n规则 ID\n" + item.id() - + "\n\n具体判断规则\n" + HlsRuleConfig.LEGACY_FALLBACK_DETAIL; - } HlsAdRule rule = BuiltinHlsRuleLoader.getRules().stream().filter(value -> item.id().equals(value.getId())).findFirst().orElse(null); if (rule == null) return getString(R.string.ad_rule_hls_builtin_summary, item.version(), status) + "\n\n规则 ID\n" + item.id(); JsonObject json = App.gson().toJsonTree(rule).getAsJsonObject(); @@ -347,7 +238,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter } private void confirmDisable(String name, int messageRes, Runnable action) { - new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) + new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) .setTitle(name) .setMessage(messageRes) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) @@ -359,7 +250,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter public void onUserToggleClick(UserAdRule item, boolean enabled) { if (!enabled) { int message = UserAdRule.SOURCE_AI.equals(item.getSource()) ? R.string.ad_rule_ai_disable_confirm - : UserAdRule.isInterfaceSource(item.getSource()) ? R.string.ad_rule_imported_disable_confirm + : item.getSource() != null && item.getSource().startsWith("interface_") ? R.string.ad_rule_imported_disable_confirm : R.string.ad_rule_manual_disable_confirm; confirmDisable(item.getName(), message, () -> setUserEnabled(item, false)); } @@ -369,7 +260,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void setUserEnabled(UserAdRule item, boolean enabled) { item.setEnabled(enabled); UserAdRuleStore.update(item); - adapter.refreshUserEnabled(item); + loadData(); if (callback != null) callback.onRuleChanged(); } @@ -381,20 +272,20 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void setDefaultEnabled(String ruleId, boolean enabled) { DisabledDefaultRuleStore.setDisabled(ruleId, !enabled); - adapter.refreshDefaultEnabled(ruleId, enabled); + loadData(); if (callback != null) callback.onRuleChanged(); } @Override public void onHlsToggleClick(HlsRuleConfig.Entry item, boolean enabled) { HlsRuleStateStore.setEnabled(item.key(), enabled); - adapter.refreshHlsEnabled(item.key(), enabled); + loadData(); if (callback != null) callback.onRuleChanged(); } @Override public void onDeleteClick(UserAdRule item) { - new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) + new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) .setTitle(R.string.ad_rule_delete_confirm) .setMessage(getString(R.string.ad_rule_delete_message, item.getName())) .setPositiveButton(R.string.ad_rule_delete_confirm, (dialog, which) -> deleteUserRule(item)) diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java index 3cc95a88fa..fc9b67e53d 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java @@ -15,7 +15,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.AdDetectionResult; import com.fongmi.android.tv.databinding.DialogAdRulePreviewBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -54,7 +53,7 @@ public class AdRulePreviewDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushMethodDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushMethodDialog.java deleted file mode 100644 index 6c0a8df916..0000000000 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushMethodDialog.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.view.LayoutInflater; - -import androidx.fragment.app.FragmentActivity; -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.bean.Device; -import com.fongmi.android.tv.databinding.DialogApkPushMethodBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -public class ApkPushMethodDialog extends BaseAlertDialog { - - private static final String TAG = "apk_push_method"; - - private final Device device; - private DialogApkPushMethodBinding binding; - private Listener listener; - - private ApkPushMethodDialog(Device device) { - this.device = device; - } - - public static ApkPushMethodDialog create(Device device) { - return new ApkPushMethodDialog(device); - } - - public ApkPushMethodDialog listener(Listener listener) { - this.listener = listener; - return this; - } - - public void show(FragmentActivity activity) { - if (activity.getSupportFragmentManager().isStateSaved()) return; - if (activity.getSupportFragmentManager().findFragmentByTag(TAG) == null) show(activity.getSupportFragmentManager(), TAG); - } - - @Override - protected ViewBinding getBinding() { - return binding = DialogApkPushMethodBinding.inflate(LayoutInflater.from(requireActivity())); - } - - @Override - protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); - } - - @Override - protected void initView() { - binding.target.setText(getString(R.string.apk_push_method_target, device == null ? "" : device.getName())); - } - - @Override - protected void initEvent() { - binding.local.setOnClickListener(view -> select(true)); - binding.link.setOnClickListener(view -> select(false)); - binding.cancel.setOnClickListener(view -> dismiss()); - } - - @Override - public void onStart() { - super.onStart(); - setWidth(0.88f); - } - - private void select(boolean local) { - Listener callback = listener; - dismissAllowingStateLoss(); - if (callback == null || device == null) return; - if (local) callback.onLocal(device); - else callback.onLink(device); - } - - @Override - public void onDestroyView() { - binding = null; - super.onDestroyView(); - } - - public interface Listener { - - void onLocal(Device device); - - void onLink(Device device); - } -} diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushUrlDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushUrlDialog.java deleted file mode 100644 index fb0472bd2f..0000000000 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushUrlDialog.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.inputmethod.EditorInfo; - -import androidx.annotation.NonNull; -import androidx.fragment.app.FragmentActivity; -import androidx.viewbinding.ViewBinding; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.Constant; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.bean.Device; -import com.fongmi.android.tv.databinding.DialogRemoteTrustTextCommandBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.Sniffer; -import com.fongmi.android.tv.utils.Util; -import com.github.catvod.net.OkHttp; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import java.io.IOException; - -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.FormBody; -import okhttp3.HttpUrl; -import okhttp3.Response; - -public class ApkPushUrlDialog extends BaseAlertDialog { - - private static final String ACK = "APK URL accepted"; - private static final String TAG = "apk_push_url"; - - private final okhttp3.OkHttpClient client = OkHttp.client(Constant.TIMEOUT_SYNC); - private final Device device; - private DialogRemoteTrustTextCommandBinding binding; - private Call call; - - private ApkPushUrlDialog(Device device) { - this.device = device; - } - - public static ApkPushUrlDialog create(Device device) { - return new ApkPushUrlDialog(device); - } - - public void show(FragmentActivity activity) { - if (activity.getSupportFragmentManager().isStateSaved()) return; - if (activity.getSupportFragmentManager().findFragmentByTag(TAG) == null) show(activity.getSupportFragmentManager(), TAG); - } - - @Override - protected ViewBinding getBinding() { - return binding = DialogRemoteTrustTextCommandBinding.inflate(LayoutInflater.from(requireActivity())); - } - - @Override - protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); - } - - @Override - protected void initView() { - binding.title.setText(getString(R.string.apk_push_url_target, device == null ? "" : device.getName())); - binding.inputLayout.setHint(R.string.apk_push_url_hint); - binding.positive.setText(R.string.apk_push_url_send); - CharSequence clip = Util.getClipText(); - if (!TextUtils.isEmpty(clip)) { - String url = Sniffer.getUrl(clip.toString()); - if (isValid(url)) binding.input.setText(url); - } - } - - @Override - public void onStart() { - super.onStart(); - binding.input.requestFocus(); - } - - @Override - protected void initEvent() { - binding.negative.setOnClickListener(view -> dismiss()); - binding.positive.setOnClickListener(view -> send()); - binding.input.setOnEditorActionListener((textView, actionId, event) -> { - if (actionId != EditorInfo.IME_ACTION_DONE) return false; - send(); - return true; - }); - } - - private void send() { - String url = binding.input.getText() == null ? "" : binding.input.getText().toString().trim(); - if (TextUtils.isEmpty(url)) { - Notify.show(R.string.apk_push_url_required); - binding.input.requestFocus(); - return; - } - if (!isValid(url)) { - Notify.show(R.string.apk_push_url_invalid); - binding.input.requestFocus(); - return; - } - if (device == null || TextUtils.isEmpty(device.getIp())) { - Notify.show(R.string.device_offline); - dismissAllowingStateLoss(); - return; - } - binding.positive.setEnabled(false); - FormBody body = new FormBody.Builder().add("device", Device.get().toString()).add("url", url).build(); - call = OkHttp.newCall(client, device.getIp().concat("/action?do=apk_url"), body); - call.enqueue(new Callback() { - @Override - public void onFailure(@NonNull Call call, @NonNull IOException e) { - if (call.isCanceled()) return; - App.post(() -> failed(e.getMessage())); - } - - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - try (Response res = response) { - String value = res.body() == null ? "" : res.body().string().trim(); - boolean successful = res.isSuccessful(); - int code = res.code(); - App.post(() -> complete(successful, code, value)); - } catch (IOException e) { - App.post(() -> failed(e.getMessage())); - } - } - }); - } - - private void complete(boolean successful, int code, String body) { - if (binding == null) return; - if (successful && ACK.equals(body)) { - Notify.show(R.string.apk_push_url_sent); - dismissAllowingStateLoss(); - } else if (successful) { - binding.positive.setEnabled(true); - Notify.show(R.string.apk_push_url_unsupported); - } else { - failed(TextUtils.isEmpty(body) ? "HTTP " + code : body); - } - } - - private void failed(String reason) { - if (binding == null) return; - binding.positive.setEnabled(true); - Notify.show(TextUtils.isEmpty(reason) ? getString(R.string.apk_push_url_failed) : getString(R.string.apk_push_url_failed_reason, reason)); - } - - private boolean isValid(String value) { - HttpUrl url = HttpUrl.parse(value); - return url != null && "https".equals(url.scheme()) && url.username().isEmpty() && url.password().isEmpty(); - } - - @Override - public void onDestroyView() { - if (call != null) call.cancel(); - binding = null; - super.onDestroyView(); - } -} diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java index 16cff26164..fb7c1d1e65 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java @@ -1,6 +1,7 @@ package com.fongmi.android.tv.ui.dialog; import android.app.Dialog; +import android.graphics.Color; import android.os.Bundle; import android.text.TextUtils; import android.view.Gravity; @@ -17,24 +18,15 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.AppearanceRowTheme; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeProfile; -import com.fongmi.android.tv.theme.ThemeProfileStore; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.textview.MaterialTextView; -import java.util.ArrayList; -import java.util.List; +public final class AppearanceDialog extends DialogFragment implements ThemeDialog.Listener { -public final class AppearanceDialog extends DialogFragment { - - private final List rows = new ArrayList<>(); private String[] uiScales; private String[] languages; private String[] imageSizes; private MaterialTextView uiScaleValue; - private MaterialTextView themeModeValue; private MaterialTextView themeValue; private MaterialTextView imageSizeValue; private MaterialTextView languageValue; @@ -43,10 +35,6 @@ public final class AppearanceDialog extends DialogFragment { new AppearanceDialog().show(fragment.getChildFragmentManager(), AppearanceDialog.class.getSimpleName()); } - /** One dialog row, kept so a theme change can re-colour it without a rebuild. */ - private record Row(LinearLayout root, MaterialTextView title, MaterialTextView summary) { - } - @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { @@ -60,7 +48,6 @@ public final class AppearanceDialog extends DialogFragment { LinearLayout content = new LinearLayout(requireContext()); content.setOrientation(LinearLayout.VERTICAL); uiScaleValue = addRow(content, R.string.setting_ui_scale, uiScales[Setting.getUiScaleIndex()], this::chooseUiScale); - themeModeValue = addRow(content, R.string.setting_theme_mode, getThemeModeText(), this::chooseThemeMode); themeValue = addRow(content, R.string.setting_theme_color, getThemeText(), view -> ThemeDialog.show(this)); imageSizeValue = addRow(content, R.string.setting_size, imageSizes[PlayerSetting.getSize()], this::chooseImageSize); languageValue = addRow(content, R.string.setting_language, languages[Setting.getLanguageIndex()], this::chooseLanguage); @@ -71,6 +58,7 @@ public final class AppearanceDialog extends DialogFragment { LinearLayout row = new LinearLayout(requireContext()); row.setOrientation(LinearLayout.HORIZONTAL); row.setGravity(Gravity.CENTER_VERTICAL); + row.setBackgroundResource(R.drawable.selector_git_cloud_card); row.setClickable(true); row.setFocusable(true); row.setPadding(dp(16), 0, dp(16), 0); @@ -78,12 +66,14 @@ public final class AppearanceDialog extends DialogFragment { MaterialTextView title = new MaterialTextView(requireContext()); title.setText(titleRes); + title.setTextColor(Color.parseColor("#202124")); title.setTextSize(15); title.setSingleLine(true); row.addView(title, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); MaterialTextView summary = new MaterialTextView(requireContext()); summary.setText(value); + summary.setTextColor(Color.parseColor("#5F6368")); summary.setTextSize(14); summary.setGravity(Gravity.END); summary.setSingleLine(true); @@ -92,28 +82,12 @@ public final class AppearanceDialog extends DialogFragment { summaryParams.leftMargin = dp(12); row.addView(summary, summaryParams); - // Row fill, stroke and both text colours must come from one palette. The row used - // to keep the fixed light selector_git_cloud_card while its text already followed - // ThemeController.current(), which on the TV dark table measured 1.16:1 / 1.36:1. - AppearanceRowTheme.apply(row, title, summary, ThemeController.current()); - rows.add(new Row(row, title, summary)); - LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(58)); rowParams.bottomMargin = dp(8); content.addView(row, rowParams); return summary; } - /** - * Re-colours the rows with the active palette. - * - *

Called when the dialog is shown and again after a theme change, so an open - * dialog follows a new profile without being rebuilt. - */ - private void applyRowTheme() { - for (Row row : rows) AppearanceRowTheme.apply(row.root(), row.title(), row.summary(), ThemeController.current()); - } - private void chooseUiScale(View view) { ChoiceDialog.showSingle(this, R.string.setting_ui_scale, uiScales, Setting.getUiScaleIndex(), which -> { if (which == Setting.getUiScaleIndex()) return; @@ -124,32 +98,6 @@ public final class AppearanceDialog extends DialogFragment { }); } - private void chooseThemeMode(View view) { - String[] modes = {getString(R.string.setting_theme_mode_system), getString(R.string.setting_theme_mode_light), getString(R.string.setting_theme_mode_dark)}; - int current = switch (Setting.getThemeMode()) { - case 0 -> 1; - case 1 -> 2; - default -> 0; - }; - ChoiceDialog.showSingle(this, R.string.setting_theme_mode, modes, current, which -> { - int mode = which == 0 ? -1 : which - 1; - if (mode == Setting.getThemeMode()) return; - Setting.putThemeMode(mode); - themeModeValue.setText(getThemeModeText()); - ThemeController.applyNightModeToApp(); - dismissAllowingStateLoss(); - RefreshEvent.theme(); - }); - } - - private String getThemeModeText() { - return switch (Setting.getThemeMode()) { - case 0 -> getString(R.string.setting_theme_mode_light); - case 1 -> getString(R.string.setting_theme_mode_dark); - default -> getString(R.string.setting_theme_mode_system); - }; - } - private void chooseImageSize(View view) { ChoiceDialog.showSingle(this, R.string.setting_size, imageSizes, PlayerSetting.getSize(), which -> { imageSizeValue.setText(imageSizes[which]); @@ -169,24 +117,15 @@ public final class AppearanceDialog extends DialogFragment { } @Override - public void onStart() { - super.onStart(); - // A profile applied while this dialog stayed on screen must reach the rows, and the - // rows are only attached after createContent(), so the repaint happens here. - applyRowTheme(); - } - - void onThemeProfileApplied() { - themeValue.setText(getThemeText()); - applyRowTheme(); + public void setTheme(int color) { + themeValue.setText(themeText(color)); + Setting.putThemeColor(color); dismissAllowingStateLoss(); RefreshEvent.theme(); } private String getThemeText() { - ThemeProfile profile = ThemeProfileStore.load(); - String name = profile.displayName(); - return "Default".equals(name) ? themeText(Setting.getThemeColor()) : name; + return themeText(Setting.getThemeColor()); } private String themeText(int color) { diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java index 9404bafff8..35bd2f208a 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java @@ -14,7 +14,6 @@ import android.view.inputmethod.EditorInfo; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; @@ -25,8 +24,6 @@ import com.fongmi.android.tv.bean.Config; import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.databinding.DialogConfigBinding; import com.fongmi.android.tv.impl.ConfigListener; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.playback.PlaybackIdentityResolver; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.Notify; @@ -34,16 +31,11 @@ import com.fongmi.android.tv.utils.ResUtil; import com.github.catvod.utils.Path; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import java.util.ArrayList; -import java.util.List; - public class ConfigDialog extends BaseAlertDialog { private DialogConfigBinding binding; - private Config config; private boolean append = true; private boolean edit; - private ConfigListener listener; private String ori; private int type; @@ -71,21 +63,10 @@ public class ConfigDialog extends BaseAlertDialog { return this; } - public ConfigDialog target(Config config) { - this.config = config; - return this; - } - public void show(Fragment fragment) { - listener = fragment instanceof ConfigListener ? (ConfigListener) fragment : null; show(fragment.getChildFragmentManager(), null); } - public void show(FragmentActivity activity) { - listener = activity instanceof ConfigListener ? (ConfigListener) activity : null; - show(activity.getSupportFragmentManager(), null); - } - @Override protected ViewBinding getBinding() { return binding = DialogConfigBinding.inflate(getLayoutInflater()); @@ -93,18 +74,17 @@ public class ConfigDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override protected void initView() { - if (config == null) config = edit ? getConfig() : Config.create(type); + Config config = getConfig(); binding.title.setText(getDialogTitle()); binding.positive.setText(edit ? R.string.dialog_edit : R.string.dialog_positive); binding.name.setText(config.getName()); binding.url.setText(ori = config.getUrl()); binding.url.setSelection(TextUtils.isEmpty(ori) ? 0 : ori.length()); - binding.addresses.setText(addressesText(config)); } @Override @@ -114,8 +94,7 @@ public class ConfigDialog extends BaseAlertDialog { binding.choose.setEndIconOnClickListener(this::onChoose); // 猫源本地包是一整个文件夹(index.js + index.config.js),文件选择器选不到目录, // 所以单独给一个入口。选 zip 仍走上面那个文件选择。 - binding.choose.setStartIconVisible(type == 0); - if (type == 0) binding.choose.setStartIconOnClickListener(this::onChooseDir); + binding.choose.setStartIconOnClickListener(this::onChooseDir); binding.url.addTextChangedListener(new CustomTextListener() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { @@ -199,50 +178,29 @@ public class ConfigDialog extends BaseAlertDialog { private void onPositive() { String url = binding.url.getText().toString().trim(); String name = binding.name.getText().toString().trim(); - Config config = saveConfig(url, name, binding.addresses.getText().toString()); + Config config = saveConfig(url, name); if (config == null) { Notify.show(R.string.remote_trust_config_url_required); binding.url.requestFocus(); return; } - ConfigListener target = listener; - if (target == null && getParentFragment() instanceof ConfigListener) target = (ConfigListener) getParentFragment(); - if (target == null && requireActivity() instanceof ConfigListener) target = (ConfigListener) requireActivity(); - if (target != null) target.setConfig(config); - PlaybackIdentityResolver.resolveSaved(config); + ((ConfigListener) requireParentFragment()).setConfig(config); dismiss(); } - private Config saveConfig(String url, String name, String addresses) { - Config saved; + private Config saveConfig(String url, String name) { + Config config; if (url.isEmpty()) { if (!edit) return null; if (!TextUtils.isEmpty(ori)) Config.delete(ori, type); return getStoredConfig(); } else if (edit) { - saved = Config.find(config.getId()).url(url).name(name).update(); + config = Config.find(ori, type).url(url).name(name).update(); } else { Config exists = AppDatabase.get().getConfigDao().find(url, type); - saved = exists != null ? exists.name(name).update() : null; - if (saved == null) { - return config = Config.create(type).url(url).name(name) - .urls(addresses(url, addresses)).insert().update(); - } + config = exists != null ? exists.name(name).update() : Config.create(type).url(url).name(name).update(); } - return config = saved.urls(addresses(url, addresses)).update(); - } - - private List addresses(String primary, String text) { - List result = new ArrayList<>(); - result.add(primary); - for (String item : text.split("\\R")) if (!TextUtils.isEmpty(item.trim())) result.add(item.trim()); - return result; - } - - private String addressesText(Config config) { - List addresses = new ArrayList<>(config.getUrls()); - addresses.remove(config.getUrl()); - return TextUtils.join("\n", addresses); + return config; } private void configureWindow() { @@ -263,13 +221,10 @@ public class ConfigDialog extends BaseAlertDialog { private final ActivityResultLauncher launcher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null || result.getData().getData() == null) return; String name = binding.name.getText().toString().trim(); - String path = FileChooser.getPersistentPathFromUri(result.getData().getData()); - if (TextUtils.isEmpty(path)) { - Notify.show(R.string.dialog_config_choose_failed); - return; - } + String path = FileChooser.getPathFromUri(result.getData().getData()); + if (TextUtils.isEmpty(path)) return; String url = "file:/" + path.replace(Path.rootPath(), ""); - ((ConfigListener) requireParentFragment()).setConfig(saveConfig(url, name, binding.addresses.getText().toString())); + ((ConfigListener) requireParentFragment()).setConfig(saveConfig(url, name)); dismiss(); }); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ControlDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ControlDialog.java index 358a6dddd7..a8b8fa8b5b 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ControlDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ControlDialog.java @@ -271,7 +271,7 @@ public class ControlDialog extends BaseBottomSheetDialog implements ParseAdapter protected ViewBinding getBinding(@NonNull LayoutInflater inflater, @Nullable ViewGroup container) { binding = DialogControlBinding.inflate(inflater, container, false); speeds = Arrays.asList(binding.speed05, binding.speed075, binding.speed10, binding.speed125, binding.speed15, binding.speed175, binding.speed20, binding.speed25, binding.speed30, binding.speed50); - displays = Arrays.asList(binding.displayTime, binding.displayTraffic, binding.displaySize, binding.displayProgress, binding.displayMini, binding.displayTitle, binding.displayParams, binding.displayMediaBadges); + displays = Arrays.asList(binding.displayTime, binding.displayTraffic, binding.displaySize, binding.displayProgress, binding.displayMini, binding.displayTitle, binding.displayParams); return binding; } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialog.java index 46a1f7ba79..bc0bf91a16 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialog.java @@ -34,9 +34,7 @@ public class DanmakuApiDialog extends BaseAlertDialog { @Override protected void initView() { String text; - // 只回填真实配置过的地址:用 getEffectiveApiUrl() 会把本地包运行时算出来的回环地址 - // 显示在输入框里,用户不改直接确定就把它固化进 prefs。 - binding.text.setText(text = DanmakuSetting.getConfiguredApiUrl()); + binding.text.setText(text = DanmakuSetting.getEffectiveApiUrl()); binding.text.setSelection(TextUtils.isEmpty(text) ? 0 : text.length()); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java index b742275112..1cdaf8668f 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java @@ -2,6 +2,7 @@ package com.fongmi.android.tv.ui.dialog; import android.app.Activity; import android.app.Dialog; +import android.content.res.ColorStateList; import android.content.res.Configuration; import android.text.TextUtils; import android.view.LayoutInflater; @@ -26,13 +27,13 @@ import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.bean.TmdbPerson; import com.fongmi.android.tv.service.TmdbService; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.EpisodeStillAdapter; import com.fongmi.android.tv.ui.adapter.TmdbPersonAdapter; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; +import com.google.android.material.button.MaterialButton; import com.google.android.material.card.MaterialCardView; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.gson.JsonObject; import java.util.ArrayList; @@ -56,16 +57,8 @@ public class EpisodeDetailDialog { java.util.List preloadedPhotos, java.util.List preloadedGuests, android.content.DialogInterface.OnDismissListener dismissListener) { - show(activity, episode, null, site, preloadedPhotos, preloadedGuests, dismissListener); - } - - public static void show(FragmentActivity activity, Episode episode, TmdbEpisode boundTmdbEpisode, - com.fongmi.android.tv.bean.Site site, - java.util.List preloadedPhotos, - java.util.List preloadedGuests, - android.content.DialogInterface.OnDismissListener dismissListener) { if (activity == null || episode == null) return; - TmdbEpisode tmdbEpisode = boundTmdbEpisode != null ? boundTmdbEpisode : episode.getTmdbEpisode(); + TmdbEpisode tmdbEpisode = episode.getTmdbEpisode(); if (tmdbEpisode == null) { // 电影没有分集对象,尝试从详情页获取影片级数据 if (activity instanceof com.fongmi.android.tv.ui.host.TmdbDetailHost) { @@ -100,6 +93,7 @@ public class EpisodeDetailDialog { Dialog dialog = new Dialog(activity, android.R.style.Theme_Black_NoTitleBar_Fullscreen); dialog.setContentView(view); + view.findViewById(R.id.close).setOnClickListener(v -> dialog.dismiss()); if (dismissListener != null) dialog.setOnDismissListener(dismissListener); dialog.show(); applyWindowSize(dialog); @@ -177,6 +171,7 @@ public class EpisodeDetailDialog { Dialog dialog = new Dialog(activity, android.R.style.Theme_Black_NoTitleBar_Fullscreen); dialog.setContentView(view); + view.findViewById(R.id.close).setOnClickListener(v -> dialog.dismiss()); if (dismissListener != null) dialog.setOnDismissListener(dismissListener); dialog.show(); applyWindowSize(dialog); @@ -202,7 +197,7 @@ public class EpisodeDetailDialog { if (movieDetail == null) return; Task.execute(() -> { try { - TmdbConfig config = TmdbConfig.effectiveCurrent(); + TmdbConfig config = TmdbConfig.objectFrom(Setting.getTmdbConfig()); if (config == null || !config.isReady()) return; TmdbService service = new TmdbService(); List photos = service.photos(movieDetail, config); @@ -286,14 +281,16 @@ public class EpisodeDetailDialog { TextView overview = view.findViewById(R.id.overview); TextView photoTitle = view.findViewById(R.id.photoTitle); TextView guestsTitle = view.findViewById(R.id.guestsTitle); + MaterialButton close = view.findViewById(R.id.close); - int overlay = applyScrimOpacity(light ? 0x99F4F7FA : 0xB3000000, light); + int overlay = light ? 0x99F4F7FA : 0xB3000000; int panelColor = light ? 0xFFF4F7FA : 0xFF2A2A2A; int imageBg = light ? 0xFFE7EDF3 : 0xFF1A1A1A; int primary = light ? 0xFF12202D : 0xFFFFFFFF; int secondary = light ? 0x9912202D : 0xFFAAAAAA; int body = light ? 0xCC12202D : 0xFFDDDDDD; int stroke = light ? 0x33424B57 : 0xFF4A4A4A; + int control = light ? 0xFFE7EDF3 : 0xFF2A2A2A; if (root != null) root.setBackgroundColor(overlay); if (panel != null) { @@ -307,12 +304,14 @@ public class EpisodeDetailDialog { overview.setTextColor(body); photoTitle.setTextColor(primary); guestsTitle.setTextColor(primary); + close.setTextColor(primary); + close.setStrokeColor(ColorStateList.valueOf(stroke)); + close.setBackgroundTintList(ColorStateList.valueOf(control)); + close.setRippleColor(ColorStateList.valueOf(light ? 0x1F12202D : 0x33FFFFFF)); } private static void bindHorizontalList(RecyclerView view, int spacingDp) { view.setLayoutManager(new LinearLayoutManager(view.getContext(), LinearLayoutManager.HORIZONTAL, false)); - view.setNestedScrollingEnabled(false); - view.setOverScrollMode(View.OVER_SCROLL_NEVER); view.addItemDecoration(new RecyclerView.ItemDecoration() { @Override public void getItemOffsets(@NonNull android.graphics.Rect outRect, @NonNull View child, RecyclerView parent, @NonNull RecyclerView.State state) { @@ -326,7 +325,7 @@ public class EpisodeDetailDialog { if (episode.getTmdbId() == 0) return; Task.execute(() -> { try { - TmdbConfig config = TmdbConfig.effectiveCurrent(); + TmdbConfig config = TmdbConfig.objectFrom(Setting.getTmdbConfig()); if (config == null || !config.isReady()) return; TmdbService service = new TmdbService(); JsonObject episodeJson = service.episode(episode.getTmdbId(), episode.getSeasonNumber(), episode.getNumber(), config); @@ -353,13 +352,6 @@ public class EpisodeDetailDialog { guestsList.setVisibility(View.VISIBLE); TmdbPersonAdapter adapter = new TmdbPersonAdapter(person -> TmdbPersonDialog.show(activity, person, null)); adapter.setLight(light); - boolean cinema = Setting.isTmdbCinemaStyle(); - adapter.setCinema(cinema); - if (cinema) { - ViewGroup.LayoutParams params = guestsList.getLayoutParams(); - params.height = ResUtil.dp2px(104); - guestsList.setLayoutParams(params); - } adapter.setItems(guests); guestsList.setAdapter(adapter); } @@ -370,23 +362,10 @@ public class EpisodeDetailDialog { return Setting.resolveTmdbDetailLightTheme(Setting.getTmdbDetailTheme(), night == Configuration.UI_MODE_NIGHT_YES); } - /** - * Keeps this dialog's own scrim colour and lets the user drive only its alpha. - * - *

The shipped scrim is translucent white in light mode and translucent black in - * dark mode; the theme token is translucent black in both. Substituting the token - * was measured to invert the light-mode scrim from lightening to darkening, so the - * user's {@code scrimOpacity} is applied to the dialog's existing colour instead. - * An unset slot returns {@code base} untouched, keeping the default byte-identical. - */ - private static int applyScrimOpacity(int base, boolean light) { - return ThemeController.applyScrimOpacity(base, ThemeController.configuredScrimOpacity(light)); - } - private static void showSimpleDialog(FragmentActivity activity, Episode episode, android.content.DialogInterface.OnDismissListener dismissListener) { // 标题放固定文案,源站文件名放可换行的正文,避免长名被单行标题截断 - androidx.appcompat.app.AlertDialog dialog = new WebHtvAlertDialogBuilder(activity) + androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(activity) .setTitle(R.string.detail_tmdb_empty) .setMessage(episode.getName()) .setPositiveButton(R.string.dialog_negative, null) diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java index 26cfd5342b..388efc0828 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/HistoryDialog.java @@ -6,13 +6,10 @@ import android.graphics.drawable.ColorDrawable; import android.view.Gravity; import android.view.Window; import android.view.WindowManager; -import android.view.View; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; @@ -22,10 +19,8 @@ import com.fongmi.android.tv.api.config.WallConfig; import com.fongmi.android.tv.bean.Config; import com.fongmi.android.tv.databinding.DialogHistoryBinding; import com.fongmi.android.tv.impl.ConfigListener; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.ConfigAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; -import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -34,11 +29,9 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl private DialogHistoryBinding binding; private ConfigListener listener; private ConfigAdapter adapter; - private ItemTouchHelper sortTouchHelper; private int type; private boolean readOnly; - private boolean manage; public static HistoryDialog create() { return new HistoryDialog(); @@ -64,11 +57,6 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl return this; } - public HistoryDialog manage() { - manage = true; - return this; - } - public void show(FragmentActivity activity) { show(activity.getSupportFragmentManager(), null); } @@ -94,7 +82,7 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override @@ -104,31 +92,7 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl binding.recycler.setHasFixedSize(false); if (isFull()) binding.recycler.setMaxHeight(ResUtil.dp2px(264)); binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 8)); - binding.recycler.setAdapter(adapter.readOnly(readOnly).protectCurrent(manage).addAll(type, getConfig())); - binding.add.setVisibility(manage ? View.VISIBLE : View.GONE); - binding.add.setOnClickListener(v -> onAdd()); - if (type == 0 && !readOnly) attachSortHelper(); - } - - private void onAdd() { - ConfigDialog dialog = ConfigDialog.create(); - if (type == 0) dialog.vod(); - else if (type == 1) dialog.live(); - else dialog.wall(); - if (getParentFragment() != null) dialog.show(getParentFragment()); - else dialog.show(requireActivity()); - dismiss(); - } - - private void attachSortHelper() { - sortTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { - @Override public boolean isLongPressDragEnabled() { return false; } - @Override public boolean onMove(@NonNull RecyclerView view, @NonNull RecyclerView.ViewHolder from, @NonNull RecyclerView.ViewHolder to) { - return adapter.drag(from.getBindingAdapterPosition(), to.getBindingAdapterPosition()); - } - @Override public void onSwiped(@NonNull RecyclerView.ViewHolder holder, int direction) { } - }); - sortTouchHelper.attachToRecyclerView(binding.recycler); + binding.recycler.setAdapter(adapter.readOnly(readOnly).addAll(type, getConfig())); } private Config getConfig() { @@ -146,44 +110,15 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl dismiss(); } - @Override - public boolean onTextLongClick(ConfigAdapter.ViewHolder holder) { - if (type != 0 || readOnly || sortTouchHelper == null) return false; - sortTouchHelper.startDrag(holder); - return true; - } - - @Override - public void onEditClick(Config item) { - ConfigDialog dialog = ConfigDialog.create().target(item).edit(); - if (type == 0) dialog.vod(); - else if (type == 1) dialog.live(); - else dialog.wall(); - if (getParentFragment() != null) dialog.show(getParentFragment()); - else dialog.show(requireActivity().getSupportFragmentManager(), null); - dismiss(); - } - @Override public void onDeleteClick(Config item) { - if (adapter.isProtectedCurrent(item)) { - Notify.show(R.string.config_current_delete_message); - return; - } - new WebHtvAlertDialogBuilder(requireContext()) - .setTitle(R.string.config_delete_title) - .setMessage(getString(R.string.config_delete_message, item.getDesc())) - .setNegativeButton(R.string.dialog_negative, null) - .setPositiveButton(R.string.dialog_positive, (dialog, which) -> { - if (adapter.remove(item) == 0) dismiss(); - }) - .show(); + if (adapter.remove(item) == 0) dismiss(); } @Override public void onStart() { super.onStart(); - if (adapter.getItemCount() == 0 && !manage) dismiss(); + if (adapter.getItemCount() == 0) dismiss(); else configureWindow(); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/LiveControlDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/LiveControlDialog.java index b42446e9d7..16c04be0c0 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/LiveControlDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/LiveControlDialog.java @@ -81,13 +81,9 @@ public class LiveControlDialog extends BaseBottomSheetDialog { binding.sheetWall.setVisibility(View.GONE); binding.player.setText(parent.control.action.player.getText()); binding.decode.setText(parent.control.action.decode.getText()); - binding.source.setText(parent.control.action.source.getText()); - binding.source.setContentDescription(parent.control.action.source.getContentDescription()); - binding.playParams.setSelected(parent.control.action.playParams.isSelected()); binding.invert.setSelected(parent.control.action.invert.isSelected()); binding.across.setSelected(parent.control.action.across.isSelected()); binding.change.setSelected(parent.control.action.change.isSelected()); - syncConfiguredButtonVisibility(); setTrackVisible(); setListStyleSelected(); setScaleText(); @@ -123,7 +119,6 @@ public class LiveControlDialog extends BaseBottomSheetDialog { binding.player.setOnClickListener(v -> dismiss(parent.control.action.player)); binding.player.setOnLongClickListener(v -> longClick(binding.player, parent.control.action.player)); binding.decode.setOnClickListener(v -> click(binding.decode, parent.control.action.decode)); - binding.playParams.setOnClickListener(v -> dismiss(parent.control.action.playParams)); binding.text.setOnClickListener(v -> onTrack(binding.text)); binding.audio.setOnClickListener(v -> onTrack(binding.audio)); binding.video.setOnClickListener(v -> onTrack(binding.video)); @@ -185,23 +180,9 @@ public class LiveControlDialog extends BaseBottomSheetDialog { if (binding == null || parent == null) return; binding.player.setText(parent.control.action.player.getText()); binding.decode.setText(parent.control.action.decode.getText()); - // 换源时宿主 control.action.source 已更新,弹窗保持打开也必须跟着改名与读屏语义。 - binding.source.setText(parent.control.action.source.getText()); - binding.source.setContentDescription(parent.control.action.source.getContentDescription()); - binding.playParams.setSelected(parent.control.action.playParams.isSelected()); - syncConfiguredButtonVisibility(); setTrackVisible(); } - /** - * 面板与底部动作栏保持一致:用户在「播放器按钮」里关掉的直播同名按钮,面板内也不再出现。 - */ - private void syncConfiguredButtonVisibility() { - binding.player.setVisibility(parent.control.action.player.getVisibility()); - binding.decode.setVisibility(parent.control.action.decode.getVisibility()); - binding.playParams.setVisibility(parent.control.action.playParams.getVisibility()); - } - private void setScale(int mode) { listener().onLiveScalePanel(mode); setScaleText(); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java index 329d87f45e..5f50e4b0dd 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java @@ -19,8 +19,6 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterPlayerOsdBinding; import com.fongmi.android.tv.databinding.DialogPlayerOsdBinding; -import com.fongmi.android.tv.theme.PlayerSettingsRowTheme; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.ResUtil; import java.util.Arrays; @@ -112,12 +110,11 @@ public final class PlayerOsdDialog extends DialogFragment { public void onBindViewHolder(@NonNull ViewHolder holder, int position) { boolean visible = checked[position]; holder.binding.name.setText(items[position]); + holder.binding.name.setAlpha(visible ? 1f : 0.55f); holder.binding.visible.setImageResource(visible ? R.drawable.ic_player_button_visible : R.drawable.ic_player_button_hidden); holder.binding.visible.setContentDescription(getString(visible ? R.string.setting_show : R.string.setting_hide)); holder.binding.visible.setSelected(visible); holder.binding.visible.setActivated(visible); - PlayerSettingsRowTheme.apply(holder.binding.root, holder.binding.name, ThemeController.current(), visible); - PlayerSettingsRowTheme.applySwitch(holder.binding.visible, ThemeController.current()); holder.binding.root.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition())); holder.binding.visible.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition())); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PushPlayUrlDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PushPlayUrlDialog.java index 03b5dd7de9..d8bfee2d06 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PushPlayUrlDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PushPlayUrlDialog.java @@ -12,7 +12,6 @@ import com.fongmi.android.tv.Constant; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Device; import com.fongmi.android.tv.databinding.DialogRemoteTrustTextCommandBinding; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.Sniffer; import com.fongmi.android.tv.utils.Util; @@ -53,7 +52,7 @@ public class PushPlayUrlDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/RecommendationFeedbackDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/RecommendationFeedbackDialog.java index 74e7a294cd..4dfa5347a2 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/RecommendationFeedbackDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/RecommendationFeedbackDialog.java @@ -12,9 +12,9 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogRecommendationFeedbackBinding; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.service.RecommendationFeedbackStore; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.RecommendationFeedbackAdapter; import com.fongmi.android.tv.utils.Notify; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class RecommendationFeedbackDialog implements RecommendationFeedbackAdapter.OnClickListener { @@ -33,7 +33,7 @@ public class RecommendationFeedbackDialog implements RecommendationFeedbackAdapt binding.recycler.setLayoutManager(new LinearLayoutManager(activity)); binding.recycler.setAdapter(adapter); binding.recycler.setItemAnimator(null); - dialog = new WebHtvAlertDialogBuilder(activity) + dialog = new MaterialAlertDialogBuilder(activity) .setTitle(R.string.recommendation_feedback_title) .setView(binding.getRoot()) .setNegativeButton(R.string.detail_close, null) diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java index c41a20b70b..902fc1c7ae 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/SiteDialog.java @@ -12,7 +12,6 @@ import android.view.WindowManager; import android.view.inputmethod.EditorInfo; import androidx.annotation.NonNull; -import androidx.core.widget.TextViewCompat; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.ItemTouchHelper; @@ -32,7 +31,6 @@ import com.fongmi.android.tv.ui.adapter.SiteAdapter; import com.fongmi.android.tv.ui.adapter.SiteGroupAdapter; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; -import com.fongmi.android.tv.ui.helper.SiteDialogTheme; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -81,20 +79,13 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi @Override protected MaterialAlertDialogBuilder getBuilder() { - return builder().setView(getBinding().getRoot()); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); } @Override protected void initView() { - SiteDialogTheme theme = SiteDialogTheme.resolve(binding.getRoot().getContext(), Setting.getDynamicColor()); - binding.getRoot().setBackgroundResource(R.drawable.shape_site_dialog); - binding.keyword.setTextColor(theme.onSurface()); - binding.keyword.setHintTextColor(theme.onSurfaceVariant()); - TextViewCompat.setCompoundDrawableTintList(binding.keyword, theme.accent()); - theme.tint(binding.block); - theme.tint(binding.search); - adapter = new SiteAdapter(this, theme); - groupAdapter = new SiteGroupAdapter(this::onGroupClick, theme); + adapter = new SiteAdapter(this); + groupAdapter = new SiteGroupAdapter(this::onGroupClick); groups = getGroups(); binding.recycler.setAdapter(adapter); binding.groupList.setLayoutManager(new LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)); @@ -249,7 +240,7 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi selectedGroup = group.equals(selectedGroup) ? "" : group; updateGroupView(); filter(); - binding.recycler.post(() -> binding.recycler.scrollToPosition(adapter.getSelectedPosition())); + binding.recycler.scrollToPosition(0); if (!TextUtils.isEmpty(selectedGroup)) centerGroup(view); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java index 3621814027..7f2825fac7 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java @@ -1,520 +1,47 @@ package com.fongmi.android.tv.ui.dialog; -import android.app.Dialog; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; -import android.widget.Button; -import android.widget.HorizontalScrollView; -import android.widget.LinearLayout; -import android.widget.ScrollView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; +import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.event.RefreshEvent; +import com.fongmi.android.tv.databinding.DialogThemeBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeColorPickerDialog; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeEditor; -import com.fongmi.android.tv.theme.ThemeEditorUi; -import com.fongmi.android.tv.theme.ThemePaletteStyle; -import com.fongmi.android.tv.theme.ThemePresets; -import com.fongmi.android.tv.theme.ThemePreviewView; -import com.fongmi.android.tv.theme.ThemeProfile; -import com.fongmi.android.tv.theme.ThemeProfileCodec; -import com.fongmi.android.tv.theme.ThemeProfileStore; -import com.fongmi.android.tv.theme.ThemeTokens; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; -import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Util; +import com.fongmi.android.tv.ui.adapter.ThemeAdapter; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import java.util.EnumMap; +public class ThemeDialog extends BaseAlertDialog implements ThemeAdapter.OnClickListener { -/** Locally themed editing session. Only Save publishes the draft to the rest of the app. */ -public final class ThemeDialog extends DialogFragment implements ThemePreviewView.Callbacks { - - private final EnumMap cards = new EnumMap<>(ThemePresets.Preset.class); - private final EnumMap styles = new EnumMap<>(ThemePaletteStyle.class); - private ThemeEditor editor; - private ThemeTokens previewTokens; - private boolean dark; - private int wallpaperColor; - private int savedScroll; - /** - * System-bar insets reported by the dialog window, or {@code null} before they arrive. - * - *

{@code onStart} runs before the window is always laid out, so the insets can still - * be missing when the window is first sized; the listener installed in {@link #onStart} - * re-sizes the window once they are known. - */ - private ThemeDialogLayout.Insets windowInsets; - private LinearLayout root; - private ThemePreviewView panel; - private ScrollView scroll; - private TextView title; - private TextView note; - private TextView presetTitle; - private TextView paletteTitle; - private TextView modeTitle; - private TextView status; - private Button lightButton; - private Button darkButton; - private Button importButton; - private Button resetButton; - private Button cancelButton; - private Button saveButton; + private static final int[] COLORS = {-1, 0, 0xFF6750A4, 0xFF3949AB, 0xFF1E88E5, 0xFF00ACC1, 0xFF00897B, 0xFF43A047, 0xFF7CB342, 0xFFFB8C00, 0xFFE53935, 0xFFD81B60, 0xFF8E24AA, 0xFF6D4C41,}; + private DialogThemeBinding binding; public static void show(Fragment fragment) { - new ThemeDialog().show(fragment.getChildFragmentManager(), ThemeDialog.class.getSimpleName()); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - editor = ThemeEditor.load(); - // 预览模式必须与 App 当前实际渲染的模式一致:此前这里用 Util.isLeanback() 把 TV 强行 - // 置为深色预览,于是用户在浅色模式下重新打开编辑器会看到「深色」被选中,误以为保存失败。 - dark = ThemeController.isNight(requireContext()); - wallpaperColor = Setting.getWallColor(); - if (savedInstanceState != null) { - dark = savedInstanceState.getBoolean("preview_dark", dark); - savedScroll = savedInstanceState.getInt("preview_scroll", 0); - String draft = savedInstanceState.getString("preview_draft"); - if (draft != null) { - try { - editor.replace(ThemeProfileCodec.parse(draft)); - } catch (RuntimeException ignored) { /* Invalid saved UI state must not block opening settings. */ } - } - } - previewTokens = editor.preview(dark, wallpaperColor); - root = ThemeEditorUi.column(requireContext()); - // This subtree belongs to the draft, not the builder's persisted-theme binder. - root.setTag("webhtv:ignore"); - ThemeEditorUi.padding(root, 18, 16); - buildHeader(); - - scroll = new ScrollView(requireContext()); - scroll.setClipToPadding(false); - LinearLayout content = ThemeEditorUi.column(requireContext()); - content.setPadding(0, 0, 0, dp(12)); - presetTitle = heading(R.string.theme_editor_presets); - content.addView(presetTitle, ThemeEditorUi.fullWidth(requireContext(), 14)); - // Default is first in the very first configuration group, ahead of palette styles. - content.addView(buildPresetRow(), ThemeEditorUi.fullWidth(requireContext(), 10)); - content.addView(buildModeRow(), ThemeEditorUi.fullWidth(requireContext(), 14)); - paletteTitle = heading(R.string.theme_editor_palette_style); - content.addView(paletteTitle, ThemeEditorUi.fullWidth(requireContext(), 12)); - content.addView(buildPaletteRow(), ThemeEditorUi.fullWidth(requireContext(), 8)); - panel = ThemePreviewView.createPanel(requireContext(), editor, dark, previewTokens, this); - content.addView(panel); - scroll.addView(content); - root.addView(scroll, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); - buildFooter(); - render(); - // Custom actions avoid AlertDialog's unconditional positive-button dismissal on save failure. - return new WebHtvAlertDialogBuilder(requireContext()).setView(root).create(); - } - - private void buildHeader() { - LinearLayout header = ThemeEditorUi.row(requireContext()); - title = ThemeEditorUi.text(requireContext(), getString(R.string.setting_theme_color), 22, previewTokens.colorOnSurface()); - ThemeEditorUi.heading(title); - header.addView(title, ThemeEditorUi.weighted()); - importButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_import); - importButton.setOnClickListener(view -> ThemeImportDialog.show(this, profile -> { - ThemeEditor.Result result = editor.replace(profile); - if (result.success()) render(); - else setStatus(result.error()); - })); - header.addView(importButton); - root.addView(header); - note = ThemeEditorUi.text(requireContext(), getString(R.string.theme_editor_inline_hint), 12, previewTokens.colorOnSurfaceVariant()); - root.addView(note, ThemeEditorUi.fullWidth(requireContext(), 6)); - } - - private View buildPresetRow() { - LinearLayout row = ThemeEditorUi.row(requireContext()); - row.setPadding(dp(2), dp(2), dp(2), dp(2)); - for (ThemePresets.Preset preset : ThemePresets.Preset.values()) { - PresetCard card = new PresetCard(preset); - cards.put(preset, card); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(128), ViewGroup.LayoutParams.WRAP_CONTENT); - params.setMarginEnd(dp(10)); - row.addView(card.view, params); - } - return horizontalScroll(row); - } - - private View buildModeRow() { - LinearLayout row = ThemeEditorUi.row(requireContext()); - modeTitle = heading(R.string.theme_editor_edit_mode); - row.addView(modeTitle, ThemeEditorUi.weighted()); - lightButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_light); - darkButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_dark); - lightButton.setOnClickListener(view -> { dark = false; render(); }); - darkButton.setOnClickListener(view -> { dark = true; render(); }); - LinearLayout.LayoutParams lightParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - lightParams.setMarginEnd(dp(8)); - row.addView(lightButton, lightParams); - row.addView(darkButton); - return row; - } - - private View buildPaletteRow() { - LinearLayout row = ThemeEditorUi.row(requireContext()); - row.setPadding(dp(2), dp(2), dp(2), dp(2)); - for (ThemePaletteStyle style : ThemePaletteStyle.values()) { - Button button = ThemeEditorUi.button(requireContext(), paletteLabel(style)); - button.setOnClickListener(view -> { - editor.replace(ThemePresets.withPaletteStyle(editor.draft(), style)); - render(); - }); - styles.put(style, button); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.setMarginEnd(dp(8)); - row.addView(button, params); - } - return horizontalScroll(row); - } - - private View horizontalScroll(View child) { - HorizontalScrollView scroll = new HorizontalScrollView(requireContext()); - scroll.setHorizontalScrollBarEnabled(false); - scroll.addView(child); - return scroll; - } - - private void buildFooter() { - status = ThemeEditorUi.text(requireContext(), "", 12, previewTokens.colorOnSurfaceVariant()); - status.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE); - root.addView(status, ThemeEditorUi.fullWidth(requireContext(), 10)); - LinearLayout actions = ThemeEditorUi.row(requireContext()); - cancelButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_cancel); - resetButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_reset); - saveButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_apply); - cancelButton.setOnClickListener(view -> dismiss()); - resetButton.setOnClickListener(view -> { editor.reset(); render(); }); - saveButton.setOnClickListener(view -> applyDraft()); - for (Button button : new Button[]{cancelButton, resetButton, saveButton}) { - LinearLayout.LayoutParams params = ThemeEditorUi.weighted(); - if (button != saveButton) params.setMarginEnd(dp(8)); - actions.addView(button, params); - } - root.addView(actions, ThemeEditorUi.fullWidth(requireContext(), 8)); - } - - /** One snapshot drives every real control; no view is recreated during a colour edit or drag. */ - private void render() { - previewTokens = editor.preview(dark, wallpaperColor); - ThemeTokens tokens = previewTokens; - title.setTextColor(tokens.colorOnSurface()); - note.setTextColor(tokens.colorOnSurfaceVariant()); - for (TextView heading : new TextView[]{presetTitle, paletteTitle, modeTitle}) { - heading.setTextColor(tokens.colorOnSurfaceVariant()); - } - ThemeEditorUi.buttonColors(importButton, tokens.colorSecondaryContainer(), tokens.colorOnSecondaryContainer(), tokens, false); - modeButton(lightButton, !dark); - modeButton(darkButton, dark); - ThemeProfile draft = editor.draft(); - for (PresetCard card : cards.values()) card.render(draft); - for (ThemePaletteStyle style : styles.keySet()) { - boolean selected = style.id().equals(draft.paletteStyle); - modeButton(styles.get(style), selected); - } - if (panel != null) panel.render(editor, dark, tokens); - ThemeEditorUi.buttonColors(cancelButton, tokens.colorSurfaceContainer(), tokens.colorOnSurface(), tokens, false); - ThemeEditorUi.buttonColors(resetButton, tokens.colorSurfaceContainer(), tokens.colorOnSurface(), tokens, false); - ThemeEditorUi.buttonColors(saveButton, tokens.colorPrimary(), tokens.colorOnPrimary(), tokens, false); - setStatus(getString(editor.isDirty() ? R.string.theme_editor_dirty : R.string.theme_editor_saved_state)); - Dialog dialog = getDialog(); - if (dialog != null && dialog.getWindow() != null) { - Window window = dialog.getWindow(); - window.setBackgroundDrawable(ThemeEditorUi.shape(requireContext(), - ThemeEditorUi.withAlpha(tokens.colorSurface(), tokens.dialogOpacity()), 0, 0, 24)); - window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - WindowManager.LayoutParams params = window.getAttributes(); - params.dimAmount = ThemeEditorUi.alpha(tokens.colorScrim()); - window.setAttributes(params); - } - } - - private void modeButton(Button button, boolean selected) { - ThemeTokens tokens = previewTokens; - ThemeEditorUi.buttonColors(button, selected ? tokens.colorPrimaryContainer() : tokens.colorSurfaceContainer(), - selected ? tokens.colorOnPrimaryContainer() : tokens.colorOnSurface(), tokens, selected); - } - - private final class PresetCard { - final ThemePresets.Preset preset; - final ThemeProfile profile; - final ThemeTokens light; - final ThemeTokens night; - final LinearLayout view; - final TextView name; - final TextView detail; - final View[] swatches = new View[4]; - - PresetCard(ThemePresets.Preset preset) { - this.preset = preset; - profile = preset.profile(); - ThemeEditor sample = new ThemeEditor(profile); - light = sample.preview(false, wallpaperColor); - night = sample.preview(true, wallpaperColor); - view = ThemeEditorUi.column(requireContext()); - ThemeEditorUi.padding(view, 12, 12); - view.setFocusable(true); - view.setTag("theme-preset:" + preset.name()); - view.setOnClickListener(ignored -> { - ThemeProfile next = profile.copy(); - if (preset != ThemePresets.Preset.DEFAULT) next.name = getString(preset.label); - next.mode = editor.draft().mode; - editor.replace(next); - ThemeDialog.this.render(); - }); - name = ThemeEditorUi.text(requireContext(), getString(preset.label), 14, light.colorOnSurface()); - ThemeEditorUi.heading(name); - name.setSingleLine(true); - view.addView(name); - detail = ThemeEditorUi.text(requireContext(), "", 10, light.colorOnSurfaceVariant()); - detail.setSingleLine(true); - view.addView(detail, ThemeEditorUi.fullWidth(requireContext(), 6)); - LinearLayout row = ThemeEditorUi.row(requireContext()); - for (int i = 0; i < swatches.length; i++) { - swatches[i] = new View(requireContext()); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, dp(18), 1); - if (i < swatches.length - 1) params.setMarginEnd(dp(4)); - row.addView(swatches[i], params); - } - view.addView(row, ThemeEditorUi.fullWidth(requireContext(), 10)); - } - - void render(ThemeProfile draft) { - ThemeTokens tokens = dark ? night : light; - boolean active = ThemePresets.find(draft) == preset; - boolean modified = false; - if (active) { - ThemeProfile comparable = draft.copy(); - comparable.name = profile.name; - comparable.mode = profile.mode; - modified = !ThemeProfileCodec.encode(comparable).equals(ThemeProfileCodec.encode(profile)); - } - name.setText((active ? "✓ " : "") + getString(preset.label)); - name.setTextColor(tokens.colorOnSurface()); - detail.setText(modified ? R.string.theme_editor_preset_modified - : preset == ThemePresets.Preset.DEFAULT ? R.string.theme_editor_preset_original - : preset == ThemePresets.Preset.WALLPAPER ? R.string.theme_editor_preset_dynamic : R.string.theme_editor_preset_pair); - detail.setTextColor(tokens.colorOnSurfaceVariant()); - view.setSelected(active); - view.setContentDescription(getString(preset.label) + (active ? " · " + getString(R.string.theme_editor_selected) : "")); - view.setBackground(ThemeEditorUi.interactive(requireContext(), tokens.colorSurface(), tokens.colorOutline(), - previewTokens.colorFocus(), active)); - int[] colors = {tokens.colorPrimary(), tokens.colorSecondaryContainer(), tokens.colorSurfaceContainerHigh(), tokens.colorSuccess()}; - for (int i = 0; i < swatches.length; i++) swatches[i].setBackground(ThemeEditorUi.shape(requireContext(), colors[i], 0, 0, 5)); - } + new ThemeDialog().show(fragment.getChildFragmentManager(), null); } @Override - public void onStart() { - super.onStart(); - Dialog dialog = getDialog(); - if (dialog == null) return; - configureWindow(dialog); - render(); - // The window can be laid out before the platform reports its system-bar insets, in - // which case the first sizing falls back to the display bounds and the footer row - // ends up under the navigation bar. Re-sizing when the real insets arrive is what - // makes the fix independent of that ordering. - Window window = dialog.getWindow(); - View decorView = window == null ? null : window.getDecorView(); - if (decorView != null) { - ViewCompat.setOnApplyWindowInsetsListener(decorView, (view, insets) -> { - androidx.core.graphics.Insets bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); - ThemeDialogLayout.Insets next = new ThemeDialogLayout.Insets( - bars.left, bars.top, bars.right, bars.bottom); - if (!next.equals(windowInsets)) { - windowInsets = next; - Dialog current = getDialog(); - if (current != null && current.getWindow() != null) configureWindow(current); - } - return insets; - }); - ViewCompat.requestApplyInsets(decorView); - } - if (savedScroll > 0) scroll.post(() -> scroll.scrollTo(0, savedScroll)); - } - - private void configureWindow(Dialog dialog) { - Window window = dialog.getWindow(); - if (window == null) return; - int margin = ResUtil.dp2px(ThemeDialogLayout.marginDp(Util.isLeanback())); - View decorView = window.getDecorView(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); - ThemeDialogLayout.Area area = ThemeDialogLayout.safeArea(screenWidth, screenHeight, - systemBarInsets(screenWidth, screenHeight)); - int width = ThemeDialogLayout.width(area.width(), margin); - int height = ThemeDialogLayout.height(area.height(), margin); - WindowManager.LayoutParams params = window.getAttributes(); - params.width = width; - params.height = height; - params.gravity = Gravity.CENTER; - Drawable panel = ThemeDialogLayout.panelBackground(decorView.getBackground()); - if (panel != null) window.setBackgroundDrawable(panel); - decorView.setPadding(0, 0, 0, 0); - window.setAttributes(params); - window.setLayout(width, height); - if (root != null && root.getLayoutParams() != null) { - ViewGroup.LayoutParams rootParams = root.getLayoutParams(); - rootParams.width = ViewGroup.LayoutParams.MATCH_PARENT; - rootParams.height = ViewGroup.LayoutParams.MATCH_PARENT; - root.setLayoutParams(rootParams); - } - } - - /** - * The system bars the editor window has to stay clear of. - * - *

Insets reported by the dialog window are exact and preferred. Until they arrive, the - * space the host already gave up to the bars (the display minus its content view) is - * used; that value also covers the host's own chrome, so it can only over-reserve, never - * clip. A host content view that has not been laid out yet reports nothing, and the - * display is used until the listener re-sizes the window. - */ - private ThemeDialogLayout.Insets systemBarInsets(int screenWidth, int screenHeight) { - // 1) The dialog window's own report, which is the exact value whenever the platform - // actually fills it in. - if (windowInsets != null && !windowInsets.isEmpty()) return windowInsets; - // 2) The host activity's root window insets. This is the same source Util.isFullscreen - // already trusts, and it stays correct while a floating dialog window keeps reporting - // systemBars() = [0,0,0,0] on API 28. - ThemeDialogLayout.Insets host = hostWindowInsets(); - if (!host.isEmpty()) return host; - // 3) The space the host content view has already given up to the bars. - ThemeDialogLayout.Insets fallback = - ThemeDialogLayout.Insets.ofContent(screenWidth, screenHeight, contentWidth(), contentHeight()); - return fallback.isEmpty() ? ThemeDialogLayout.Insets.none() : fallback; - } - - /** - * The system bars the host activity has to keep clear. - * - *

Used because a floating dialog window is not guaranteed to report its own insets: on - * API 28 the editor's window reports {@code systemBars() = [0,0,0,0]} while the navigation bar - * is on screen, and accepting that as "no bars" is what pushed the footer row under it.

- */ - private ThemeDialogLayout.Insets hostWindowInsets() { - try { - WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(requireActivity().getWindow().getDecorView()); - if (insets == null) return ThemeDialogLayout.Insets.none(); - androidx.core.graphics.Insets bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); - return new ThemeDialogLayout.Insets(bars.left, bars.top, bars.right, bars.bottom); - } catch (RuntimeException error) { - return ThemeDialogLayout.Insets.none(); - } - } - - /** - * The host activity's content view size. - * - *

Its difference from the display is the system-bar space the host gave up, which is - * the safe-area fallback while the dialog window's own insets are still unknown. - */ - private int contentWidth() { - View content = requireActivity().findViewById(android.R.id.content); - return content == null ? 0 : content.getWidth(); - } - - private int contentHeight() { - View content = requireActivity().findViewById(android.R.id.content); - return content == null ? 0 : content.getHeight(); + protected ViewBinding getBinding() { + return binding = DialogThemeBinding.inflate(getLayoutInflater()); } @Override - public void onSaveInstanceState(@NonNull Bundle state) { - super.onSaveInstanceState(state); - if (editor != null) state.putString("preview_draft", ThemeProfileCodec.encode(editor.draft())); - state.putBoolean("preview_dark", dark); - state.putInt("preview_scroll", scroll == null ? savedScroll : scroll.getScrollY()); + protected MaterialAlertDialogBuilder getBuilder() { + return builder().setTitle(R.string.setting_theme_color).setView(getBinding().getRoot()); } @Override - public void onDestroyView() { - super.onDestroyView(); - cards.clear(); - styles.clear(); - root = null; - panel = null; - scroll = null; - // The next window reports its own insets; a stale value from this one must not be reused. - windowInsets = null; + protected void initView() { + binding.recycler.setAdapter(new ThemeAdapter(this, COLORS, Setting.getThemeColor())); } @Override - public void onColorSlotClicked(ThemeEditor.Slot slot, boolean slotDark) { - String initial = ThemeEditorUi.hex(ThemePreviewView.colorOf(previewTokens, slot)); - ThemeColorPickerDialog.create(requireContext(), getString(ThemePreviewView.labelOf(slot)), initial, previewTokens, hex -> { - ThemeEditor.Result result = editor.set(slot, slotDark, hex, 0f); - if (result.success()) render(); - else setStatus(result.error()); - }).show(); + public void onItemClick(int color) { + ((Listener) requireParentFragment()).setTheme(color); + dismiss(); } - @Override - public void onDraftChanged() { - render(); - } + public interface Listener { - private void applyDraft() { - ThemeProfileStore.ApplyResult result = editor.apply(); - if (!result.success()) { - setStatus(getString(R.string.theme_editor_save_failed, result.error())); - return; - } - dismissAllowingStateLoss(); - // AppearanceDialog already publishes the refresh event. Do not post a second recreation. - if (getParentFragment() instanceof AppearanceDialog appearance) appearance.onThemeProfileApplied(); - else RefreshEvent.theme(); - } - - private TextView heading(int label) { - TextView heading = ThemeEditorUi.text(requireContext(), getString(label), 13, previewTokens.colorOnSurfaceVariant()); - ThemeEditorUi.heading(heading); - return heading; - } - - private void setStatus(String message) { - status.setTextColor(previewTokens.colorOnSurfaceVariant()); - status.setText(message == null ? "" : message); - } - - private int paletteLabel(ThemePaletteStyle style) { - return switch (style) { - case TONAL_SPOT -> R.string.theme_editor_palette_tonal; - case VIBRANT -> R.string.theme_editor_palette_vibrant; - case EXPRESSIVE -> R.string.theme_editor_palette_expressive; - case RAINBOW -> R.string.theme_editor_palette_rainbow; - case FRUIT_SALAD -> R.string.theme_editor_palette_fruit_salad; - case FIDELITY -> R.string.theme_editor_palette_fidelity; - case CONTENT -> R.string.theme_editor_palette_content; - case NEUTRAL -> R.string.theme_editor_palette_neutral; - case MONOCHROME -> R.string.theme_editor_palette_monochrome; - }; - } - - private int dp(int value) { - return ThemeEditorUi.dp(requireContext(), value); + void setTheme(int color); } } + diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java index bc5c6029a8..3de7d3f5fc 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java @@ -14,10 +14,6 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Update; import com.fongmi.android.tv.databinding.DialogUpdateBinding; import com.fongmi.android.tv.impl.UpdateListener; -import com.fongmi.android.tv.theme.ThemeController; -import com.fongmi.android.tv.theme.ThemeTokens; -import com.fongmi.android.tv.theme.UpdateVersionRowTheme; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.AppVersion; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.MarkdownText; @@ -75,7 +71,7 @@ public class UpdateDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()).setCancelable(false); + return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()).setCancelable(false); } @Override @@ -159,7 +155,6 @@ public class UpdateDialog extends BaseAlertDialog { View content = stableChannel ? binding.stableContent : binding.betaContent; item.setSelected(expanded); content.setVisibility(expanded ? View.VISIBLE : View.GONE); - applyCardTheme(channel, expanded); if (stableChannel) { binding.stableVersion.setText(getVersion(update)); binding.stableStatus.setText(getStatus(update)); @@ -181,27 +176,6 @@ public class UpdateDialog extends BaseAlertDialog { } } - /** - * Paints one version card from the active palette. - * - *

The card used to take its fill from {@code selector_update_version_item} while its - * text came from three separate {@code ColorStateList}s. Those are different resource - * graphs: {@code ThemeBinder} rewrites the text colours but leaves the card's - * {@code StateListDrawable} untouched, so on a “壁纸取色” or custom palette the text and - * the fill could drift apart and the card went unreadable (reported: a solid - * {@code #0F1D26} card carrying {@code #49454F} text, 1.5:1). Handing both to - * {@link UpdateVersionRowTheme} keeps them on one snapshot and guarantees AA. - */ - private void applyCardTheme(String channel, boolean expanded) { - ThemeTokens tokens = ThemeController.current(); - boolean stableChannel = Update.CHANNEL_STABLE.equals(channel); - if (stableChannel) { - UpdateVersionRowTheme.apply(binding.stableItem, binding.stableName, binding.stableVersion, binding.stableStatus, binding.stableExpand, binding.stableDesc, expanded, tokens); - } else { - UpdateVersionRowTheme.apply(binding.betaItem, binding.betaName, binding.betaVersion, binding.betaStatus, binding.betaExpand, binding.betaDesc, expanded, tokens); - } - } - private void renderAction() { Update update = getSelected(); if (update == null || !update.hasUpdate()) binding.cancel.setText(R.string.about_acknowledge); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDialog.java index b196e03956..704b5343ff 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDialog.java @@ -6,7 +6,7 @@ import android.view.View; import androidx.appcompat.app.AlertDialog; import com.fongmi.android.tv.App; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class WebDialog { @@ -17,7 +17,7 @@ public class WebDialog { } private WebDialog(View view) { - this.dialog = new WebHtvAlertDialogBuilder(App.activity()).setView(view).create(); + this.dialog = new MaterialAlertDialogBuilder(App.activity()).setView(view).create(); this.dialog.setOnDismissListener((DialogInterface.OnDismissListener) view); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java index 17a28a849b..a804e9bdd3 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java @@ -42,7 +42,6 @@ import com.fongmi.android.tv.databinding.FragmentCollectBinding; import com.fongmi.android.tv.model.SearchProgress; import com.fongmi.android.tv.model.SiteViewModel; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.setting.SiteGroupOrderStore; import com.fongmi.android.tv.setting.SiteHealthStore; import com.fongmi.android.tv.ui.activity.FolderActivity; @@ -639,11 +638,8 @@ public class CollectFragment extends BaseFragment implements MenuProvider, Colle } private GradientDrawable getGroupPopupBackground() { - // 同 SearchFragment.getScopePopupBackground():面板写死 Color.WHITE 时,深色模式下 - // 近白字的 colorOnSurface 压在白底上只有 1.29:1,分组下拉根本看不清。 - // 面板与 item 文字改用同一张表(M3 菜单容器 colorSurfaceContainer)。 GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(ThemeController.current().colorSurfaceContainer()); + drawable.setColor(Color.WHITE); drawable.setCornerRadius(ResUtil.dp2px(6)); return drawable; } @@ -670,7 +666,7 @@ public class CollectFragment extends BaseFragment implements MenuProvider, Colle view.setSingleLine(true); view.setGravity(Gravity.CENTER_VERTICAL); view.setIncludeFontPadding(false); - view.setTextColor(ThemeController.current().colorOnSurface()); + view.setTextColor(0xFF202124); view.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16); view.setPadding(ResUtil.dp2px(18), 0, ResUtil.dp2px(18), 0); view.setBackgroundResource(getSelectableItemBackground()); @@ -739,12 +735,13 @@ public class CollectFragment extends BaseFragment implements MenuProvider, Colle Collect all = Collect.all(); all.setSelected("all".equals(activeSiteKey)); items.add(all); + boolean fixedOrder = Setting.getSearchResultSort() == 1; for (int i = 1; i < mAllCollectItems.size(); i++) { Collect raw = mAllCollectItems.get(i); if (!matchFilter(raw.getSite())) continue; String siteKey = raw.getSite().getKey(); List visible = getVisibleItems(siteKey); - if (!SearchSourceVisibility.shouldShow(!visible.isEmpty())) continue; + if (!SearchSourceVisibility.shouldShow(mSimilarity, fixedOrder, !visible.isEmpty())) continue; Collect item = new Collect(raw.getSite(), visible); item.setPage(mPaging.getPage(raw.getSite().getKey())); item.setSelected(raw.getSite().getKey().equals(activeSiteKey)); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SearchFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SearchFragment.java index e884aa261b..846b1e7225 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SearchFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SearchFragment.java @@ -39,7 +39,6 @@ import com.fongmi.android.tv.bean.Word; import com.fongmi.android.tv.databinding.FragmentSearchBinding; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.setting.SiteGroupOrderStore; import com.fongmi.android.tv.ui.adapter.HotWordAdapter; import com.fongmi.android.tv.ui.adapter.RecordAdapter; @@ -346,12 +345,8 @@ public class SearchFragment extends BaseFragment implements MenuProvider, WordAd } private GradientDrawable getScopePopupBackground() { - // 面板不能再写死 Color.WHITE:item 文字取主题的 colorOnSurface,深色模式下那是近白色 - // (#E2E2E9),压在写死的白底上实测只有 1.29:1,就是用户报告的「深色模式搜索界面 - // 的分组下拉界面看不清文字」。面板改用 M3 菜单容器角色 colorSurfaceContainer,与 item - // 文字来自同一张日夜表,ThemeResolver 另有一道 onSurface/surfaceContainer ≥ 4.5:1 的门。 GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(ThemeController.current().colorSurfaceContainer()); + drawable.setColor(Color.WHITE); drawable.setCornerRadius(ResUtil.dp2px(6)); return drawable; } @@ -378,7 +373,7 @@ public class SearchFragment extends BaseFragment implements MenuProvider, WordAd view.setSingleLine(true); view.setGravity(Gravity.CENTER_VERTICAL); view.setIncludeFontPadding(false); - view.setTextColor(ThemeController.current().colorOnSurface()); + view.setTextColor(0xFF202124); view.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16); view.setPadding(ResUtil.dp2px(18), 0, ResUtil.dp2px(18), 0); view.setBackgroundResource(getSelectableItemBackground()); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAdFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAdFragment.java index 7c8c042b07..e17ff454d1 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAdFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAdFragment.java @@ -1,32 +1,61 @@ package com.fongmi.android.tv.ui.fragment; +import android.content.ContentResolver; +import android.net.Uri; +import android.text.InputType; +import android.text.TextUtils; +import android.view.Gravity; import android.view.LayoutInflater; -import android.util.TypedValue; import android.view.View; import android.view.ViewGroup; +import android.widget.EditText; import android.widget.TextView; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import androidx.viewbinding.ViewBinding; +import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; +import com.fongmi.android.tv.ad.audio.AdAudioRuleSnapshot; +import com.fongmi.android.tv.ad.audio.AdAudioRuleStore; +import com.fongmi.android.tv.ad.audio.AdAudioSetting; +import com.fongmi.android.tv.ad.audio.AdSkipPolicyController; +import com.fongmi.android.tv.ad.audio.ProbeRuleDownloader; +import com.fongmi.android.tv.ad.audio.ProbeRuleStore; +import com.fongmi.android.tv.ad.audio.SpeechAdConfig; +import com.fongmi.android.tv.ad.audio.SpeechAdSetting; 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.databinding.FragmentSettingAdBinding; -import com.fongmi.android.tv.player.IntroSkipKinds; +import com.fongmi.android.tv.server.Server; +import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.subtitle.RealtimeSubtitleSpeechRecognitionFactory; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.AdBlockStatsDialog; import com.fongmi.android.tv.ui.dialog.AdRuleManageDialog; +import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; +import com.fongmi.android.tv.utils.Task; import com.github.catvod.crawler.SpiderDebug; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.Locale; public class SettingAdFragment extends BaseFragment { private FragmentSettingAdBinding mBinding; private String[] introSkipMode; + private volatile AdAudioRuleSnapshot adAudioSnapshot = AdAudioRuleStore.get().current(); + private volatile long probeRevision; + private volatile int probeRuleCount; + private final ActivityResultLauncher adAudioRulePicker = + registerForActivityResult(new ActivityResultContracts.OpenDocument(), this::importAdAudioRules); public static SettingAdFragment newInstance() { return new SettingAdFragment(); @@ -45,6 +74,17 @@ public class SettingAdFragment extends BaseFragment { protected void initView() { introSkipMode = ResUtil.getStringArray(R.array.select_auto_skip_intro_outro); setText(); + loadSnapshots(); + } + + /** 规则文件的读取和解析都在磁盘上,只能异步做,结果缓存在字段里供 setText 使用。 */ + private void loadSnapshots() { + Task.execute(() -> { + adAudioSnapshot = AdAudioRuleStore.get().load(); + probeRuleCount = ProbeRuleStore.get().load().ruleSet().rules().size(); + probeRevision = ProbeRuleStore.get().revision(); + App.post(this::setText); + }); } @Override @@ -53,8 +93,16 @@ public class SettingAdFragment extends BaseFragment { mBinding.aiAdDetection.setOnClickListener(this::setAiAdDetection); mBinding.adRuleManage.setOnClickListener(view -> AdRuleManageDialog.create().show(requireActivity(), this::setText)); mBinding.adBlockStats.setOnClickListener(view -> AdBlockStatsDialog.create(requireActivity()).show()); + mBinding.adAudioFingerprint.setOnClickListener(this::toggleAdAudioFingerprint); + mBinding.adAudioFingerprint.setOnLongClickListener(this::manageAdAudioRules); + mBinding.adAudioAutoSkip.setOnClickListener(this::toggleAdAudioAutoSkip); + mBinding.probeRuleSource.setOnClickListener(this::editProbeRuleSource); + mBinding.probeRuleRefresh.setOnClickListener(this::refreshProbeRules); + mBinding.speechAdEnabled.setOnClickListener(this::toggleSpeechAdEnabled); + mBinding.speechAdKeywords.setOnClickListener(this::editSpeechAdKeywords); + mBinding.speechAdSkipSeconds.setOnClickListener(this::editSpeechAdSkipSeconds); + mBinding.speechAdSkipMode.setOnClickListener(this::selectSpeechAdSkipMode); mBinding.autoSkipIntroOutro.setOnClickListener(this::setAutoSkipIntroOutro); - mBinding.introSkipKinds.setOnClickListener(view -> IntroSkipKinds.show(requireActivity(), this::setText)); } private void setText() { @@ -63,13 +111,33 @@ public class SettingAdFragment extends BaseFragment { safeSet("adblock", mBinding.adblockText, () -> getSwitch(Setting.isAdblock())); safeRun("aiAdDetection", () -> { mBinding.aiAdDetection.setVisibility(Setting.isAiConfigReady() ? View.VISIBLE : View.GONE); - setSummary(mBinding.aiAdDetectionText, getSwitch(Setting.isAiAdDetection())); + mBinding.aiAdDetectionText.setText(getSwitch(Setting.isAiAdDetection())); }, () -> setError(mBinding.aiAdDetectionText)); safeSet("adRuleManage", mBinding.adRuleManageText, () -> getString(R.string.ad_rule_count_with_pending, UserAdRuleStore.load().size() + RuleConfig.get().getDefaultRules().size(), ImportedAdRuleCandidateStore.pending().size())); + safeSet("adAudioFingerprint", mBinding.adAudioFingerprintText, this::getAdAudioFingerprintText); + safeSet("adAudioAutoSkip", mBinding.adAudioAutoSkipText, () -> getSwitch(AdAudioSetting.isAutoSkipEnabled())); + safeSet("probeRuleSource", mBinding.probeRuleSourceText, this::getProbeRuleSourceText); + safeSet("probeRuleRefresh", mBinding.probeRuleRefreshText, this::getProbeRuleRefreshText); + safeRun("speechAd", () -> { + SpeechAdConfig speech = SpeechAdSetting.snapshot(); + String enabled = getSwitch(speech.enabled()); + if (speech.enabled() && !RealtimeSubtitleSpeechRecognitionFactory.isSelectedModelReady()) { + enabled += " · " + getString(R.string.speech_ad_model_not_ready); + } + mBinding.speechAdEnabledText.setText(enabled); + mBinding.speechAdKeywordsText.setText(getString(R.string.speech_ad_keyword_count, speech.keywords().values().size())); + mBinding.speechAdSkipSecondsText.setText(getString(R.string.speech_ad_skip_seconds_value, speech.skipSeconds())); + mBinding.speechAdSkipModeText.setText(speech.mode() == AdSkipPolicyController.Mode.AUTO + ? R.string.speech_ad_skip_mode_auto : R.string.speech_ad_skip_mode_prompt); + }, () -> { + setError(mBinding.speechAdEnabledText); + setError(mBinding.speechAdKeywordsText); + setError(mBinding.speechAdSkipSecondsText); + setError(mBinding.speechAdSkipModeText); + }); safeSet("autoSkipIntroOutro", mBinding.autoSkipIntroOutroText, () -> introSkipMode[Setting.getIntroSkipMode()]); - safeSet("introSkipKinds", mBinding.introSkipKindsText, IntroSkipKinds::summary); } private boolean canSetText() { @@ -77,22 +145,7 @@ public class SettingAdFragment extends BaseFragment { } private void safeSet(String name, TextView view, TextSupplier supplier) { - safeRun(name, () -> setSummary(view, supplier.get()), () -> setError(view)); - } - - /** - * 写入取值摘要,并把颜色与底材复位到常规状态。 - * - *

复位这一步是必需的:{@link #setError} 会把同一个 TextView 染成错误色并换上一块深底, - * 而这些摘要全部**复用同一个控件**——不复位的话,某一行失败一次之后,即使后续刷新成功, - * 它也会一直显示为红字加深底。 - */ - private void setSummary(TextView view, CharSequence text) { - if (view == null) return; - view.setText(text); - view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaper)); - // 这些 TextView 在布局里原本都没有背景,所以置空就是"恢复原样"。 - view.setBackground(null); + safeRun(name, () -> view.setText(supplier.get()), () -> setError(view)); } private void safeRun(String name, Runnable action, Runnable fallback) { @@ -108,27 +161,8 @@ public class SettingAdFragment extends BaseFragment { } } - /** - * 内联错误:设计文档 §9.9 要求错误态有「错误色」。 - * - *

不能直接用 {@code ?attr/colorError}:那是跟随调色板的角色,浅色表的深红 - * ({@code #B3261E})压在深色壁纸行色带上实测只有约 1.06:1,完全不可读。这里用与壁纸 - * 同族的 {@code ?attr/webhtvColorOnWallpaperError},并同时换上一块**实心深底** - * ({@code @drawable/shape_error_value})——错误色直接画在行色带上只有 4.05–4.50:1, - * 略低于正文门槛 4.5:1;实心底上是 8.84:1,稳过门槛。底材是实心色,因此这个比值与 - * flavor 无关,与电视端共用同一份 drawable 与同一个角色。 - */ private void setError(TextView view) { - if (view == null) return; - view.setText(R.string.error_config_get); - view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaperError)); - view.setBackgroundResource(R.drawable.shape_error_value); - } - - /** 解析主题属性里的颜色值;未赋值时返回 0(透明)。 */ - private int themeColor(int attr) { - TypedValue value = new TypedValue(); - return requireContext().getTheme().resolveAttribute(attr, value, true) ? value.data : 0; + if (view != null) view.setText(R.string.error_config_get); } private interface TextSupplier { @@ -150,6 +184,246 @@ public class SettingAdFragment extends BaseFragment { setText(); } + private void toggleAdAudioAutoSkip(View view) { + AdAudioSetting.setAutoSkipEnabled(!AdAudioSetting.isAutoSkipEnabled()); + notifyAdAudioRuntime(); + setText(); + } + + private String getAdAudioFingerprintText() { + String enabled = getSwitch(AdAudioSetting.isEnabled()); + AdAudioRuleSnapshot snapshot = adAudioSnapshot; + if (snapshot == null || snapshot.hasError()) { + return enabled + " · " + getString(R.string.setting_ad_audio_rule_error); + } + int count = snapshot.ruleSet().rules().size(); + if (count == 0) return enabled + " · " + getString(R.string.setting_ad_audio_no_rules); + return enabled + " · " + getString(R.string.setting_ad_audio_rule_count, count, snapshot.version()); + } + + private void toggleAdAudioFingerprint(View view) { + AdAudioSetting.setEnabled(!AdAudioSetting.isEnabled()); + notifyAdAudioRuntime(); + setText(); + } + + private boolean manageAdAudioRules(View view) { + new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.setting_ad_audio_title) + .setItems(new String[]{ + getString(R.string.setting_ad_audio_import), + getString(R.string.setting_ad_audio_clear) + }, (dialog, which) -> { + if (which == 0) adAudioRulePicker.launch(new String[]{"application/json", "text/json"}); + else confirmClearAdAudioRules(); + }) + .setNegativeButton(R.string.dialog_negative, null) + .show(); + return true; + } + + private void importAdAudioRules(Uri uri) { + if (uri == null || !canSetText()) return; + ContentResolver resolver = requireContext().getContentResolver(); + Task.execute(() -> { + AdAudioRuleSnapshot imported = null; + try { + imported = AdAudioRuleStore.get().importUri(resolver, uri); + adAudioSnapshot = imported; + } catch (Exception e) { + SpiderDebug.log("ad-audio-rules", e); + } + AdAudioRuleSnapshot result = imported; + App.post(() -> { + if (!canSetText()) return; + Notify.show(result == null ? getString(R.string.setting_ad_audio_import_failed) + : getString(R.string.setting_ad_audio_imported, result.ruleSet().rules().size(), result.version())); + notifyAdAudioRuntime(); + setText(); + }); + }); + } + + private void confirmClearAdAudioRules() { + new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.setting_ad_audio_clear) + .setMessage(R.string.setting_ad_audio_clear_confirm) + .setPositiveButton(R.string.dialog_positive, (dialog, which) -> Task.execute(() -> { + boolean success = true; + try { + adAudioSnapshot = AdAudioRuleStore.get().clear(); + } catch (RuntimeException e) { + SpiderDebug.log("ad-audio-rules", e); + success = false; + } + boolean cleared = success; + App.post(() -> { + if (!canSetText()) return; + Notify.show(cleared ? R.string.setting_ad_audio_clear_done : R.string.setting_ad_audio_import_failed); + notifyAdAudioRuntime(); + setText(); + }); + })) + .setNegativeButton(R.string.dialog_negative, null) + .show(); + } + + private String getProbeRuleSourceText() { + String url = AdAudioSetting.getProbeRuleUrl(); + if (TextUtils.isEmpty(url)) return getString(R.string.setting_ad_probe_source_off); + if (AdAudioSetting.DEFAULT_PROBE_RULE_URL.equals(url)) return getString(R.string.setting_ad_probe_source_default); + return url; + } + + /** 空字符串表示关闭社区规则源;非空时只接受 https,规则没有签名,传输层是唯一的真实性保障。 */ + private void editProbeRuleSource(View view) { + EditText input = new EditText(requireContext()); + input.setSingleLine(true); + input.setText(AdAudioSetting.getProbeRuleUrl()); + input.setSelection(input.length()); + AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.setting_ad_probe_source) + .setMessage(R.string.setting_ad_probe_source_hint) + .setView(input) + .setNegativeButton(R.string.dialog_negative, null) + .setPositiveButton(R.string.dialog_positive, null) + .create(); + alert.setOnShowListener(dialog -> alert.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + String value = input.getText().toString().trim(); + if (!value.isEmpty() && !value.toLowerCase(Locale.ROOT).startsWith("https://")) { + Notify.show(R.string.setting_ad_probe_url_invalid); + return; + } + AdAudioSetting.setProbeRuleUrl(value); + setText(); + alert.dismiss(); + })); + alert.show(); + } + + private String getProbeRuleRefreshText() { + if (probeRevision <= 0L) return getString(R.string.setting_ad_probe_never); + return getString(R.string.setting_ad_probe_revision, probeRevision, probeRuleCount); + } + + private void refreshProbeRules(View view) { + mBinding.probeRuleRefreshText.setText(R.string.setting_ad_probe_refreshing); + boolean started = ProbeRuleDownloader.refreshNow(new ProbeRuleDownloader.Callback() { + @Override + public void onSuccess(AdAudioRuleSnapshot snapshot) { + if (mBinding == null) return; + Notify.show(snapshot.hasRules() + ? getString(R.string.setting_ad_probe_refreshed, snapshot.version(), snapshot.ruleSet().rules().size()) + : getString(R.string.setting_ad_probe_unchanged, 0)); + // loadSnapshots() 自己会在后台读完后 post setText(),这里不能再直接调, + // 否则会用刷新前的 revision 覆盖掉「正在刷新」,闪一下旧值。 + loadSnapshots(); + notifyAdAudioRuntime(); + } + + @Override + public void onFailure(Throwable error) { + if (mBinding == null) return; + Notify.show(getString(R.string.setting_ad_probe_failed, String.valueOf(error.getMessage()))); + setText(); + } + + @Override + public void onDisabled() { + if (mBinding == null) return; + Notify.show(R.string.setting_ad_probe_source_disabled); + setText(); + } + }); + // 已有刷新在跑时不会有回调,这里必须自己复位,否则文本永远停在「正在刷新」。 + if (!started) setText(); + } + + private void toggleSpeechAdEnabled(View view) { + SpeechAdSetting.setEnabled(!SpeechAdSetting.snapshot().enabled()); + notifyAdAudioRuntime(); + setText(); + } + + private void editSpeechAdKeywords(View view) { + SpeechAdConfig speech = SpeechAdSetting.snapshot(); + EditText input = new EditText(requireContext()); + input.setSingleLine(false); + input.setMinLines(3); + input.setGravity(Gravity.TOP); + input.setText(String.join(",", speech.keywords().values())); + input.setSelection(input.length()); + AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.speech_ad_keywords) + .setView(input) + .setNegativeButton(R.string.dialog_negative, null) + .setPositiveButton(R.string.dialog_positive, null) + .create(); + alert.setOnShowListener(dialog -> alert.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + SpeechAdSetting.setKeywords(input.getText().toString()); + notifyAdAudioRuntime(); + setText(); + alert.dismiss(); + })); + alert.show(); + } + + private void editSpeechAdSkipSeconds(View view) { + SpeechAdConfig speech = SpeechAdSetting.snapshot(); + EditText input = new EditText(requireContext()); + input.setInputType(InputType.TYPE_CLASS_NUMBER); + input.setText(String.valueOf(speech.skipSeconds())); + input.setSelectAllOnFocus(true); + AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.speech_ad_skip_seconds) + .setView(input) + .setNegativeButton(R.string.dialog_negative, null) + .setPositiveButton(R.string.dialog_positive, null) + .create(); + alert.setOnShowListener(dialog -> alert.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + final int value; + try { + value = Integer.parseInt(input.getText().toString().trim()); + } catch (RuntimeException e) { + input.setError(getString(R.string.speech_ad_skip_seconds_invalid)); + input.requestFocus(); + return; + } + int normalized = SpeechAdConfig.create(false, "", value, AdSkipPolicyController.Mode.PROMPT.name()).skipSeconds(); + if (normalized != value) { + input.setError(getString(R.string.speech_ad_skip_seconds_invalid)); + input.requestFocus(); + return; + } + SpeechAdSetting.setSkipSeconds(value); + notifyAdAudioRuntime(); + setText(); + alert.dismiss(); + })); + alert.show(); + } + + private void selectSpeechAdSkipMode(View view) { + SpeechAdConfig speech = SpeechAdSetting.snapshot(); + String[] modes = {getString(R.string.speech_ad_skip_mode_prompt), getString(R.string.speech_ad_skip_mode_auto)}; + int checked = speech.mode() == AdSkipPolicyController.Mode.AUTO ? 1 : 0; + new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.speech_ad_skip_mode) + .setNegativeButton(R.string.dialog_negative, null) + .setSingleChoiceItems(modes, checked, (dialog, which) -> { + SpeechAdSetting.setMode(which == 1 ? AdSkipPolicyController.Mode.AUTO : AdSkipPolicyController.Mode.PROMPT); + notifyAdAudioRuntime(); + setText(); + dialog.dismiss(); + }).show(); + } + + private void notifyAdAudioRuntime() { + PlaybackService service = Server.get().getService(); + if (service == null || service.player() == null || service.player().isReleased()) return; + service.player().reloadAdAudioSettings(); + } + @Override public void onHiddenChanged(boolean hidden) { if (!hidden) setText(); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAiFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAiFragment.java index c990d33bf6..526ccfc5df 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAiFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAiFragment.java @@ -15,13 +15,13 @@ import com.fongmi.android.tv.databinding.FragmentSettingAiBinding; import com.fongmi.android.tv.service.RecommendationFeedbackStore; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.subtitle.RealtimeSubtitleController; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.AiConfigDialog; import com.fongmi.android.tv.ui.dialog.RecommendationFeedbackDialog; import com.fongmi.android.tv.ui.dialog.SubtitleSettingsDialog; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class SettingAiFragment extends BaseFragment { @@ -87,7 +87,7 @@ public class SettingAiFragment extends BaseFragment { setText(); return; } - new WebHtvAlertDialogBuilder(requireContext()) + new MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.personal_recommendation_confirm_title) .setMessage(R.string.personal_recommendation_confirm_message) .setNegativeButton(R.string.dialog_negative, null) diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingDanmakuFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingDanmakuFragment.java index f507e35ee3..f2280cc046 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingDanmakuFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingDanmakuFragment.java @@ -66,7 +66,7 @@ public class SettingDanmakuFragment extends BaseFragment implements DanmakuListe } private void updateAutoVisibility() { - boolean show = DanmakuSetting.isLoad() && (DanmakuSetting.hasValidApiUrl() || DanmakuSetting.isBundleSource()); + boolean show = DanmakuSetting.isLoad() && DanmakuSetting.hasValidApiUrl(); mBinding.danmakuAuto.setVisibility(show ? View.VISIBLE : View.GONE); updateSpiderVisibility(); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingEnhanceFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingEnhanceFragment.java index eacd170981..a2362c0347 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingEnhanceFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingEnhanceFragment.java @@ -2,7 +2,6 @@ package com.fongmi.android.tv.ui.fragment; import android.content.Intent; import android.net.Uri; -import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -20,7 +19,6 @@ import com.fongmi.android.tv.bean.ShortDramaConfig; import com.fongmi.android.tv.gitcloud.GitCloudAccountStore; import com.fongmi.android.tv.lab.LabActivity; import com.fongmi.android.tv.playback.ViewingRecordSyncStore; -import com.fongmi.android.tv.plugin.PluginSourceConfig; import com.fongmi.android.tv.remote.RemoteStore; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.databinding.FragmentSettingEnhanceBinding; @@ -30,7 +28,6 @@ import com.fongmi.android.tv.setting.SiteHealthStore; import com.fongmi.android.tv.setting.SiteNameStore; import com.fongmi.android.tv.ui.activity.FileActivity; import com.fongmi.android.tv.ui.activity.HomeActivity; -import com.fongmi.android.tv.ui.activity.PluginManagerActivity; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.AudioSourceDialog; import com.fongmi.android.tv.ui.dialog.ComicSourceDialog; @@ -127,7 +124,6 @@ public class SettingEnhanceFragment extends BaseFragment { })); mBinding.loginState.setOnClickListener(view -> LoginStateLearnDialog.show(this, this::setText)); mBinding.oneKeySync.setOnClickListener(v -> OneKeySyncDialog.create().show(requireActivity())); - mBinding.pluginManager.setOnClickListener(view -> PluginManagerActivity.start(requireActivity())); } private void reorderItems() { @@ -156,8 +152,7 @@ public class SettingEnhanceFragment extends BaseFragment { mBinding.shortDramaSource, mBinding.siteHealthSort, mBinding.debugLog, - mBinding.playbackWebhook, - mBinding.pluginManager + mBinding.playbackWebhook }; for (View view : order) parent.removeView(view); for (View view : order) parent.addView(view); @@ -185,7 +180,6 @@ public class SettingEnhanceFragment extends BaseFragment { safeSet("managePage", mBinding.managePageText, () -> getString(R.string.manage_page_web)); safeSet("remoteTrust", mBinding.remoteTrustText, () -> RemoteStore.summary(requireContext())); safeSet("gitCloud", mBinding.gitCloudText, () -> getString(R.string.git_cloud_account_count, GitCloudAccountStore.list().size())); - safeSet("pluginManager", mBinding.pluginManagerText, () -> getString(R.string.plugin_manager_entry_count, PluginSourceConfig.get().entries().size())); setShellProxyText(); setCustomCspText(); safeSet("loginState", mBinding.loginStateText, () -> { @@ -202,8 +196,8 @@ public class SettingEnhanceFragment extends BaseFragment { private void setShellProxyText() { safeRun("shellProxy", () -> { int count = ProxySetting.count(); - setSummary(mBinding.shellProxyText, getSwitch(Setting.isShellProxy()) + " · " + getString(R.string.setting_proxy_rule_count, count)); - setSummary(mBinding.shellProxyConfigText, getString(R.string.setting_proxy_rule_count, count)); + mBinding.shellProxyText.setText(getSwitch(Setting.isShellProxy()) + " · " + getString(R.string.setting_proxy_rule_count, count)); + mBinding.shellProxyConfigText.setText(getString(R.string.setting_proxy_rule_count, count)); }, () -> { setError(mBinding.shellProxyText); setError(mBinding.shellProxyConfigText); @@ -218,27 +212,12 @@ public class SettingEnhanceFragment extends BaseFragment { return; } CustomCspSetting.Count count = status.count(); - setSummary(mBinding.customCspText, getSwitch(status.enabled()) + " · " + getString(R.string.setting_custom_csp_count, count.active(), count.enabled())); + mBinding.customCspText.setText(getSwitch(status.enabled()) + " · " + getString(R.string.setting_custom_csp_count, count.active(), count.enabled())); }, () -> setError(mBinding.customCspText)); } private void safeSet(String name, TextView view, TextSupplier supplier) { - safeRun(name, () -> setSummary(view, supplier.get()), () -> setError(view)); - } - - /** - * 写入取值摘要,并把颜色与底材复位到常规状态。 - * - *

复位这一步是必需的:{@link #setError} 会把同一个 TextView 染成错误色并换上一块深底, - * 而这些摘要全部**复用同一个控件**——不复位的话,某一行失败一次之后,即使后续刷新成功, - * 它也会一直显示为红字加深底。 - */ - private void setSummary(TextView view, CharSequence text) { - if (view == null) return; - view.setText(text); - view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaper)); - // 这些 TextView 在布局里原本都没有背景,所以置空就是"恢复原样"。 - view.setBackground(null); + safeRun(name, () -> view.setText(supplier.get()), () -> setError(view)); } private void safeRun(String name, Runnable action, Runnable fallback) { @@ -254,27 +233,8 @@ public class SettingEnhanceFragment extends BaseFragment { } } - /** - * 内联错误:设计文档 §9.9 要求错误态有「错误色」。 - * - *

不能直接用 {@code ?attr/colorError}:那是跟随调色板的角色,浅色表的深红 - * ({@code #B3261E})压在深色壁纸行色带上实测只有约 1.06:1,完全不可读。这里用与壁纸 - * 同族的 {@code ?attr/webhtvColorOnWallpaperError},并同时换上一块**实心深底** - * ({@code @drawable/shape_error_value})——错误色直接画在行色带上只有 4.05–4.50:1, - * 略低于正文门槛 4.5:1;实心底上是 8.84:1,稳过门槛。底材是实心色,因此这个比值与 - * flavor 无关,与电视端共用同一份 drawable 与同一个角色。 - */ private void setError(TextView view) { - if (view == null) return; - view.setText(R.string.error_config_get); - view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaperError)); - view.setBackgroundResource(R.drawable.shape_error_value); - } - - /** 解析主题属性里的颜色值;未赋值时返回 0(透明)。 */ - private int themeColor(int attr) { - TypedValue value = new TypedValue(); - return requireContext().getTheme().resolveAttribute(attr, value, true) ? value.data : 0; + if (view != null) view.setText(R.string.error_config_get); } private interface TextSupplier { @@ -283,12 +243,12 @@ public class SettingEnhanceFragment extends BaseFragment { private void setDriveCheck(View view) { Setting.putDriveCheck(!Setting.isDriveCheck()); - setSummary(mBinding.driveCheckText, getSwitch(Setting.isDriveCheck())); + mBinding.driveCheckText.setText(getSwitch(Setting.isDriveCheck())); } private void setDebugLog(View view) { Setting.putDebugLog(!Setting.isDebugLog()); - setSummary(mBinding.debugLogText, getSwitch(Setting.isDebugLog())); + mBinding.debugLogText.setText(getSwitch(Setting.isDebugLog())); if (!Setting.isDebugLog()) return; DebugLogDialog.show(this); } @@ -328,12 +288,12 @@ public class SettingEnhanceFragment extends BaseFragment { private void setPlaybackArtworkWall(View view) { Setting.putPlaybackArtworkWall(!Setting.isPlaybackArtworkWall()); - setSummary(mBinding.playbackArtworkWallText, getSwitch(Setting.isPlaybackArtworkWall())); + mBinding.playbackArtworkWallText.setText(getSwitch(Setting.isPlaybackArtworkWall())); } private void setWebHomeFullscreen(View view) { Setting.putWebHomeFullscreen(!Setting.isWebHomeFullscreen()); - setSummary(mBinding.webHomeFullscreenText, getSwitch(Setting.isWebHomeFullscreen())); + mBinding.webHomeFullscreenText.setText(getSwitch(Setting.isWebHomeFullscreen())); if (requireActivity() instanceof HomeActivity activity) { if (!Setting.isWebHomeFullscreen()) { JsonObject object = new JsonObject(); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java index ec347ce553..39b98edbc2 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingFragment.java @@ -19,31 +19,30 @@ import com.fongmi.android.tv.bean.Live; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.databinding.FragmentSettingBinding; import com.fongmi.android.tv.db.AppDatabase; -import com.fongmi.android.tv.setting.ConfigSyncPolicy; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.impl.ConfigListener; import com.fongmi.android.tv.impl.LiveListener; import com.fongmi.android.tv.impl.SiteListener; +import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.ui.activity.HomeActivity; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.AboutDialog; -import com.fongmi.android.tv.ui.dialog.AppearanceDialog; import com.fongmi.android.tv.ui.dialog.ChoiceDialog; +import com.fongmi.android.tv.ui.dialog.ConfigDialog; import com.fongmi.android.tv.ui.dialog.HistoryDialog; import com.fongmi.android.tv.ui.dialog.LiveDialog; import com.fongmi.android.tv.ui.dialog.RestoreDialog; import com.fongmi.android.tv.ui.dialog.BackupProgressDialog; import com.fongmi.android.tv.ui.dialog.SiteDialog; +import com.fongmi.android.tv.ui.dialog.ThemeDialog; import com.fongmi.android.tv.utils.AppVersion; -import com.fongmi.android.tv.cache.CacheCenter; -import com.fongmi.android.tv.cache.CachePolicyStore; -import com.fongmi.android.tv.ui.dialog.CacheManagementDialog; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; +import com.fongmi.android.tv.utils.ResUtil; import com.github.catvod.bean.Doh; import com.github.catvod.net.OkHttp; @@ -54,9 +53,12 @@ import org.greenrobot.eventbus.ThreadMode; import java.util.ArrayList; import java.util.List; -public class SettingFragment extends BaseFragment implements ConfigListener, SiteListener, LiveListener { +public class SettingFragment extends BaseFragment implements ConfigListener, SiteListener, LiveListener, ThemeDialog.Listener { private FragmentSettingBinding mBinding; + private String[] size; + private String[] language; + private String[] uiScale; public static SettingFragment newInstance() { return new SettingFragment(); @@ -66,6 +68,12 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit return getString(value ? R.string.setting_on : R.string.setting_off); } + private String getThemeText() { + int color = Setting.getThemeColor(); + if (color == -1) return getString(R.string.setting_off); + return getString(color == 0 ? R.string.setting_auto : R.string.setting_custom); + } + private int getDohIndex() { return Math.max(0, VodConfig.get().getDoh().indexOf(Doh.objectFrom(Setting.getDoh()))); } @@ -97,28 +105,19 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit } private void setOtherText() { + mBinding.themeColorText.setText(getThemeText()); mBinding.dohText.setText(getDohList()[getDohIndex()]); mBinding.incognitoText.setText(getSwitch(Setting.isIncognito())); + mBinding.languageText.setText((language = ResUtil.getStringArray(R.array.select_language))[Setting.getLanguageIndex()]); + mBinding.sizeText.setText((size = ResUtil.getStringArray(R.array.select_size))[PlayerSetting.getSize()]); + mBinding.uiScaleText.setText((uiScale = ResUtil.getStringArray(R.array.select_ui_scale))[Setting.getUiScaleIndex()]); } private void setCacheText() { - if (!CachePolicyStore.isManagementEnabled()) { - FileUtil.getCacheSize(new Callback() { - @Override - public void success(String result) { - if (mBinding != null && isAdded()) mBinding.cacheText.setText(result); - } - }); - return; - } - mBinding.cacheText.setText(R.string.cache_management_scanning); - CacheCenter.get().requestSnapshot(false, snapshot -> { - if (mBinding != null && isAdded()) { - String total = FileUtil.byteCountToDisplaySize(snapshot.totalBytes()); - long quota = snapshot.systemQuotaBytes(); - mBinding.cacheText.setText(quota > 0 - ? getString(R.string.cache_management_inline, total, FileUtil.byteCountToDisplaySize(quota)) - : total); + FileUtil.getCacheSize(new Callback() { + @Override + public void success(String result) { + mBinding.cacheText.setText(result); } }); } @@ -129,12 +128,10 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit mBinding.doh.setOnClickListener(this::setDoh); mBinding.live.setOnClickListener(this::onLive); mBinding.wall.setOnClickListener(this::onWall); - mBinding.appearance.setOnClickListener(this::onAppearance); - mBinding.cache.setOnClickListener(this::onCacheClear); - // The plain click restores the pre-split one-key clear; the panel moved to the long press. - // The row stays single-line by user request, so §15.2's "not long-press only" intent is a - // recorded deviation rather than a printed hint (docs/CACHE-MGMT-01-cache-management-design.md). - mBinding.cache.setOnLongClickListener(this::onCacheManage); + mBinding.size.setOnClickListener(this::setSize); + mBinding.language.setOnClickListener(this::setLanguage); + mBinding.uiScale.setOnClickListener(this::setUiScale); + mBinding.cache.setOnClickListener(this::onCache); mBinding.backup.setOnClickListener(this::onBackup); mBinding.enhance.setOnClickListener(this::onEnhance); mBinding.tmdb.setOnClickListener(this::onTmdb); @@ -146,10 +143,14 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit mBinding.subtitle.setOnClickListener(this::onSubtitle); mBinding.restore.setOnClickListener(this::onRestore); mBinding.version.setOnClickListener(this::onVersion); + mBinding.vod.setOnLongClickListener(this::onVodEdit); mBinding.vodHome.setOnClickListener(this::onVodHome); + mBinding.live.setOnLongClickListener(this::onLiveEdit); mBinding.liveHome.setOnClickListener(this::onLiveHome); + mBinding.wall.setOnLongClickListener(this::onWallEdit); mBinding.incognito.setOnClickListener(this::setIncognito); mBinding.vodHistory.setOnClickListener(this::onVodHistory); + mBinding.themeColor.setOnClickListener(this::onThemeColor); mBinding.liveHistory.setOnClickListener(this::onLiveHistory); mBinding.wallDefault.setOnClickListener(this::setWallDefault); mBinding.wallRefresh.setOnClickListener(this::setWallRefresh); @@ -170,12 +171,7 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit private void load(Config config) { switch (config.getType()) { case 0: - String previousVodUrl = VodConfig.getUrl(); VodConfig.load(config, getCallback()); - if (ConfigSyncPolicy.shouldSyncLive(previousVodUrl, LiveConfig.getUrl())) { - Config liveConfig = AppDatabase.get().getConfigDao().find(config.getUrl(), 1); - if (liveConfig != null) LiveConfig.load(liveConfig, new Callback()); - } break; case 1: LiveConfig.load(config, getCallback()); @@ -218,16 +214,37 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit LiveConfig.get().setHome(item); } + @Override + public void setTheme(int color) { + Setting.putThemeColor(color); + RefreshEvent.theme(); + } + private void onVod(View view) { - HistoryDialog.create().vod().manage().show(this); + ConfigDialog.create().vod().show(this); } private void onLive(View view) { - HistoryDialog.create().live().manage().show(this); + ConfigDialog.create().live().show(this); } private void onWall(View view) { - HistoryDialog.create().wall().manage().show(this); + ConfigDialog.create().wall().show(this); + } + + private boolean onVodEdit(View view) { + ConfigDialog.create().vod().edit().show(this); + return true; + } + + private boolean onLiveEdit(View view) { + ConfigDialog.create().live().edit().show(this); + return true; + } + + private boolean onWallEdit(View view) { + ConfigDialog.create().wall().edit().show(this); + return true; } private void onVodHome(View view) { @@ -278,8 +295,8 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit getRoot().change(5); } - private void onAppearance(View view) { - AppearanceDialog.show(this); + private void onThemeColor(View view) { + ThemeDialog.show(this); } @@ -309,6 +326,31 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit mBinding.incognitoText.setText(getSwitch(Setting.isIncognito())); } + private void setSize(View view) { + ChoiceDialog.showSingle(this, R.string.setting_size, size, PlayerSetting.getSize(), which -> { + mBinding.sizeText.setText(size[which]); + PlayerSetting.putSize(which); + RefreshEvent.size(); + }); + } + + private void setLanguage(View view) { + ChoiceDialog.showSingle(this, R.string.setting_language, language, Setting.getLanguageIndex(), which -> { + if (which != Setting.getLanguageIndex()) { + Setting.putLanguageIndex(which); + RefreshEvent.language(); + } + }); + } + + private void setUiScale(View view) { + ChoiceDialog.showSingle(this, R.string.setting_ui_scale, uiScale, Setting.getUiScaleIndex(), which -> { + mBinding.uiScaleText.setText(uiScale[which]); + Setting.putUiScaleIndex(which); + requireActivity().recreate(); + }); + } + private void setDoh(View view) { ChoiceDialog.showSingle(this, R.string.setting_doh, getDohList(), getDohIndex(), which -> { setDoh(VodConfig.get().getDoh().get(which)); @@ -321,45 +363,13 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit mBinding.dohText.setText(doh.getName()); } - /** - * The settings row's plain click: clear every cache at once, the way this row behaved before it - * opened the cache management panel. - * - *

The full clean runs in the background and reports itself through a notification without - * opening the panel; the row re-reads itself when the cleanup publishes its change. With cache - * management switched off the legacy {@link FileUtil#clearCache} path is kept, because the panel - * and its cleanup engine belong to the feature that switch turns off.

- */ - private void onCacheClear(View view) { - if (!CachePolicyStore.isManagementEnabled()) { - FileUtil.clearCache(new Callback() { - @Override - public void success() { - setCacheText(); - } - }); - return; - } - CacheManagementDialog.cleanEverything(); - } - - /** - * The settings row's long press: open the cache management panel (graded cleanup, module - * limits, auto cleanup and the running state). - * - *

With the plain click now clearing everything this is the panel's only entry. The row stays - * single-line by user request, so the gesture is not printed under the title and the panel is - * reached by the long press alone (deviation recorded in - * docs/CACHE-MGMT-01-cache-management-design.md). With cache management switched off there is - * no panel to open, so the long press keeps the legacy behaviour and repeats the plain clear.

- */ - private boolean onCacheManage(View view) { - if (!CachePolicyStore.isManagementEnabled()) { - onCacheClear(view); - return true; - } - CacheManagementDialog.show(this); - return true; + private void onCache(View view) { + FileUtil.clearCache(new Callback() { + @Override + public void success() { + setCacheText(); + } + }); } private void onBackup(View view) { @@ -424,19 +434,6 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit setWallText(); } - /** - * Re-reads the cache inventory the moment a cleanup (or any other cache mutation) finishes. - * - *

The settings row used to keep showing the pre-cleanup value until the user switched to - * another page and back, because nothing told it the cache had changed.

- */ - @Subscribe(threadMode = ThreadMode.MAIN) - public void onRefreshEvent(RefreshEvent event) { - if (event.getType() != RefreshEvent.Type.CACHE) return; - if (mBinding == null || !isAdded()) return; - setCacheText(); - } - private void setWallText() { mBinding.wallUrl.setText(Setting.getWallDesc(WallConfig.getDesc())); } @@ -451,6 +448,5 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit public void onDestroyView() { super.onDestroyView(); EventBus.getDefault().unregister(this); - mBinding = null; } } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java index 6a6413c984..9b4706f872 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java @@ -1,7 +1,6 @@ package com.fongmi.android.tv.ui.fragment; import android.view.LayoutInflater; -import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; @@ -11,16 +10,12 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.FragmentSettingPersonalBinding; -import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; -import com.fongmi.android.tv.following.FollowingScheduler; -import com.fongmi.android.tv.following.FollowingSettings; import com.fongmi.android.tv.setting.AppBranding; import com.fongmi.android.tv.setting.AutoBackupPolicy; import com.fongmi.android.tv.setting.GroupRuleConfig; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.activity.AppBrandingActivity; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.GroupRuleDialog; @@ -29,6 +24,7 @@ import com.fongmi.android.tv.ui.dialog.SliderNumberDialog; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; import com.fongmi.android.tv.utils.Util; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.Locale; @@ -39,9 +35,7 @@ public class SettingPersonalFragment extends BaseFragment { private String[] searchColumn; private String[] siteColumn; private String[] globalHistoryMode; - private String[] interfaceFailoverMode; private String[] searchResultSort; - private boolean globalHistoryTouchStarted; public static SettingPersonalFragment newInstance() { return new SettingPersonalFragment(); @@ -64,24 +58,10 @@ public class SettingPersonalFragment extends BaseFragment { @Override protected void initEvent() { mBinding.searchThread.setOnClickListener(this::setSearchThread); - mBinding.following.setOnClickListener(this::setFollowing); mBinding.autoBackup.setOnClickListener(this::setAutoBackup); - mBinding.playbackOverlay.setOnClickListener(this::setPlaybackOverlay); mBinding.playBackToDetail.setOnClickListener(this::setPlayBackToDetail); mBinding.episodeHistory.setOnClickListener(this::setEpisodeHistory); - // Require a touch sequence which starts on this row. Keep the marker through - // ACTION_UP so the following real click can change the persisted mode. - mBinding.globalHistory.setOnTouchListener((view, event) -> { - if (event.getActionMasked() == MotionEvent.ACTION_DOWN) globalHistoryTouchStarted = true; - else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) globalHistoryTouchStarted = false; - return false; - }); - mBinding.globalHistory.setOnClickListener(view -> { - if (!globalHistoryTouchStarted) return; - globalHistoryTouchStarted = false; - setGlobalHistory(view); - }); - mBinding.interfaceFailover.setOnClickListener(this::setInterfaceFailover); + mBinding.globalHistory.setOnClickListener(this::setGlobalHistory); mBinding.playSpeed.setOnClickListener(this::setPlaySpeed); mBinding.groupRule.setOnClickListener(this::setGroupRule); mBinding.searchUi.setOnClickListener(this::setSearchUi); @@ -94,13 +74,10 @@ public class SettingPersonalFragment extends BaseFragment { private void setText() { mBinding.searchThreadText.setText(String.valueOf(Setting.getSearchThread())); - mBinding.followingText.setText(getSwitch(FollowingSettings.isEnabled())); mBinding.autoBackupText.setText(getSwitch(isAutoBackupEnabled())); - mBinding.playbackOverlayText.setText(getSwitch(Setting.isPlaybackOverlayEnabled())); mBinding.playBackToDetailText.setText(getSwitch(Setting.isPlayBackToDetail())); mBinding.episodeHistoryText.setText(getSwitch(Setting.isEpisodeHistory())); mBinding.globalHistoryText.setText((globalHistoryMode = getResources().getStringArray(R.array.select_global_history_mode))[Setting.getGlobalHistoryMode()]); - mBinding.interfaceFailoverText.setText((interfaceFailoverMode = getResources().getStringArray(R.array.select_interface_failover_mode))[Setting.getInterfaceFailoverMode()]); mBinding.playSpeedText.setText(getSpeedText(PlayerSetting.getDefaultSpeed())); mBinding.groupRuleText.setText(getString(R.string.setting_group_rule_summary, GroupRuleConfig.enabledCount(), GroupRuleConfig.totalCount())); mBinding.searchUiText.setText((searchUi = getResources().getStringArray(R.array.select_search_ui))[Setting.getSearchUi()]); @@ -130,20 +107,6 @@ public class SettingPersonalFragment extends BaseFragment { }); } - private void setFollowing(View view) { - boolean enabled = !FollowingSettings.isEnabled(); - FollowingSettings.setEnabled(enabled); - if (enabled) { - FollowingScheduler.ensurePeriodic(requireContext()); - FollowingScheduler.enqueueDueNow(requireContext()); - } else { - FollowingScheduler.cancelAll(requireContext()); - } - ConfigEvent.common(); - RefreshEvent.home(); - setText(); - } - private void setAutoBackup(View view) { if (isAutoBackupEnabled()) { Setting.putAutoBackup(false); @@ -164,11 +127,6 @@ public class SettingPersonalFragment extends BaseFragment { return AutoBackupPolicy.isEffective(Setting.isAutoBackup(), Setting.hasFileAccess()); } - private void setPlaybackOverlay(View view) { - Setting.putPlaybackOverlayEnabled(!Setting.isPlaybackOverlayEnabled()); - setText(); - } - private void setPlayBackToDetail(View view) { Setting.putPlayBackToDetail(!Setting.isPlayBackToDetail()); setText(); @@ -185,11 +143,6 @@ public class SettingPersonalFragment extends BaseFragment { setText(); } - private void setInterfaceFailover(View view) { - Setting.putInterfaceFailoverMode((Setting.getInterfaceFailoverMode() + 1) % interfaceFailoverMode.length); - setText(); - } - private void setPlaySpeed(View view) { SpeedSettingDialog.show(requireActivity(), R.string.setting_play_speed, PlayerSetting.getDefaultSpeed(), 0.5f, 5f, 0.25f, value -> { PlayerSetting.putDefaultSpeed(value); @@ -224,7 +177,7 @@ public class SettingPersonalFragment extends BaseFragment { } private void showResetAppDialog(View view) { - new WebHtvAlertDialogBuilder(requireActivity()) + new MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.dialog_reset_app) .setMessage(R.string.dialog_reset_app_data) .setNegativeButton(R.string.dialog_negative, null) diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPlayerFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPlayerFragment.java index 7d8a45fb4a..78016edb80 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPlayerFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPlayerFragment.java @@ -19,7 +19,6 @@ import com.fongmi.android.tv.player.lut.LutSetting; import com.fongmi.android.tv.player.mpv.MpvConfigStore; import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; import com.fongmi.android.tv.setting.PlayerButtonSetting; -import com.fongmi.android.tv.setting.LiveSetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.PreloadSetting; import com.fongmi.android.tv.setting.Setting; @@ -57,7 +56,6 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B private String[] playCache; private String[] render; private String[] scale; - private String[] badgePosition; private String[] osd; public static SettingPlayerFragment newInstance() { @@ -91,8 +89,6 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B setPreloadText(); mBinding.autoPlayText.setText(getSwitch(PlayerSetting.isAutoPlay())); mBinding.autoChangeText.setText(getSwitch(PlayerSetting.isAutoChange())); - mBinding.deferFlagSwitchText.setText(getSwitch(PlayerSetting.isDeferFlagSwitchPlayback())); - mBinding.liveSourceFallbackText.setText(getSwitch(LiveSetting.isSourceFallback())); mBinding.rememberBrightnessText.setText(getSwitch(PlayerSetting.isRememberBrightness())); mBinding.failureFallbackText.setText((failureFallback = ResUtil.getStringArray(R.array.select_player_failure_fallback))[PlayerSetting.getFailureFallback()]); mBinding.musicNotificationText.setText(getSwitch(PlayerSetting.isMusicNotification())); @@ -100,15 +96,15 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.audioDecodeText.setText(getSwitch(PlayerSetting.isAudioPrefer())); mBinding.audioPassThroughText.setText(getSwitch(PlayerSetting.isAudioPassThrough())); mBinding.videoDecodeText.setText(getSwitch(PlayerSetting.isVideoPrefer())); + mBinding.ffmpegModeText.setText(getFFmpegModeText()); mBinding.caption.setVisibility(PlayerSetting.hasCaption() ? View.VISIBLE : View.GONE); mBinding.osdText.setText(getOsdText(osd = ResUtil.getStringArray(R.array.select_player_osd))); - badgePosition = ResUtil.getStringArray(R.array.select_media_badge_position); - updateBadgePositionText(); mBinding.kernelText.setText((kernel = ResUtil.getStringArray(R.array.select_player_kernel))[PlayerSetting.getPlayer()]); mpvRender = ResUtil.getStringArray(R.array.select_mpv_render); mBinding.scaleText.setText((scale = ResUtil.getStringArray(R.array.select_scale))[PlayerSetting.getScale()]); mBinding.lutText.setText(LutSetting.getSummary()); setMpvRows(); + setFfmpegModeVisibility(); mBinding.renderText.setText((render = ResUtil.getStringArray(R.array.select_render))[PlayerSetting.getRender()]); mBinding.captionText.setText((caption = ResUtil.getStringArray(R.array.select_caption))[PlayerSetting.isCaption() ? 1 : 0]); mBinding.backgroundText.setText((background = ResUtil.getStringArray(R.array.select_background))[PlayerSetting.getBackground()]); @@ -124,12 +120,7 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.lut.setOnClickListener(this::onLut); mBinding.mpvConfig.setOnClickListener(view -> MpvConfigDialog.show(this, () -> mBinding.mpvConfigText.setText(MpvConfigStore.summary()))); mBinding.mpvRender.setOnClickListener(this::onMpvRender); - mBinding.blurayMenu.setOnClickListener(view -> { - PlayerSetting.putBlurayMenu(!PlayerSetting.isBlurayMenu()); - mBinding.blurayMenuText.setText(getSwitch(PlayerSetting.isBlurayMenu())); - }); mBinding.osd.setOnClickListener(this::onOsd); - mBinding.osdMediaBadgePosition.setOnClickListener(this::onBadgePosition); mBinding.playerButtons.setOnClickListener(view -> PlayerButtonConfigDialog.show(this, this::setPlayerButtonsText)); mBinding.padLive.setOnClickListener(this::setPadLiveMode); mBinding.speed.setOnClickListener(this::onSpeed); @@ -145,11 +136,6 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.preloadPause.setOnClickListener(this::onPreloadPause); mBinding.autoPlay.setOnClickListener(this::setAutoPlay); mBinding.autoChange.setOnClickListener(this::setAutoChange); - mBinding.deferFlagSwitch.setOnClickListener(this::setDeferFlagSwitch); - mBinding.liveSourceFallback.setOnClickListener(view -> { - LiveSetting.putSourceFallback(!LiveSetting.isSourceFallback()); - mBinding.liveSourceFallbackText.setText(getSwitch(LiveSetting.isSourceFallback())); - }); mBinding.rememberBrightness.setOnClickListener(this::setRememberBrightness); mBinding.failureFallback.setOnClickListener(this::setFailureFallback); mBinding.render.setOnClickListener(this::setRender); @@ -163,6 +149,7 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.audioDecode.setOnClickListener(this::setAudioDecode); mBinding.audioPassThrough.setOnClickListener(this::setAudioPassThrough); mBinding.videoDecode.setOnClickListener(this::setVideoDecode); + mBinding.ffmpegMode.setOnClickListener(this::setFfmpegMode); } private void onUa(View view) { @@ -187,6 +174,7 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.kernelText.setText(kernel[which]); PlayerSetting.putPlayer(which); setMpvRows(); + setFfmpegModeVisibility(); setPerformanceText(); }); } @@ -202,6 +190,19 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B LutDialog.show(this, () -> mBinding.lutText.setText(LutSetting.getSummary())); } + private void setMpvRows() { + boolean visible = PlayerSetting.getPlayer() == PlayerSetting.MPV; + mBinding.mpvConfig.setVisibility(visible ? View.VISIBLE : View.GONE); + mBinding.mpvRender.setVisibility(visible ? View.VISIBLE : View.GONE); + mBinding.mpvConfigText.setText(MpvConfigStore.summary()); + mBinding.mpvRenderText.setText(getMpvRenderText()); + } + + private void setFfmpegModeVisibility() { + boolean visible = PlayerSetting.getPlayer() == PlayerSetting.EXO; + mBinding.ffmpegMode.setVisibility(visible ? View.VISIBLE : View.GONE); + } + private void onMpvRender(View view) { ChoiceDialog.showSingle(this, R.string.player_mpv_render, mpvRender, PlayerSetting.getMpvRender(), which -> { PlayerSetting.putMpvRender(which); @@ -218,16 +219,6 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B return text; } - private void setMpvRows() { - boolean visible = PlayerSetting.getPlayer() == PlayerSetting.MPV; - mBinding.mpvConfig.setVisibility(visible ? View.VISIBLE : View.GONE); - mBinding.mpvRender.setVisibility(visible ? View.VISIBLE : View.GONE); - mBinding.blurayMenu.setVisibility(visible ? View.VISIBLE : View.GONE); - mBinding.mpvConfigText.setText(MpvConfigStore.summary()); - mBinding.mpvRenderText.setText(getMpvRenderText()); - mBinding.blurayMenuText.setText(getSwitch(PlayerSetting.isBlurayMenu())); - } - private void onOsd(View view) { PlayerOsdDialog.show(this, osd, getOsdChecked(), checked -> { setOsdChecked(checked); @@ -250,18 +241,6 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.padLiveText.setText(padLiveMode[index]); } - private void onBadgePosition(View view) { - ChoiceDialog.showSingle(this, R.string.osd_media_badge_position, badgePosition, PlayerSetting.getMediaBadgeChoice(), which -> { - PlayerSetting.putMediaBadgeChoice(which); - updateBadgePositionText(); - }); - } - - private void updateBadgePositionText() { - int which = PlayerSetting.getMediaBadgeChoice(); - mBinding.osdMediaBadgePositionText.setText(badgePosition != null && badgePosition.length > which ? badgePosition[which] : getString(R.string.setting_off)); - } - private boolean[] getOsdChecked() { return PlayerSetting.getDisplayChecked(); } @@ -456,11 +435,6 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.autoChangeText.setText(getSwitch(PlayerSetting.isAutoChange())); } - private void setDeferFlagSwitch(View view) { - PlayerSetting.putDeferFlagSwitchPlayback(!PlayerSetting.isDeferFlagSwitchPlayback()); - mBinding.deferFlagSwitchText.setText(getSwitch(PlayerSetting.isDeferFlagSwitchPlayback())); - } - private void setRememberBrightness(View view) { PlayerSetting.putRememberBrightness(!PlayerSetting.isRememberBrightness()); mBinding.rememberBrightnessText.setText(getSwitch(PlayerSetting.isRememberBrightness())); @@ -581,6 +555,21 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B setPerformanceText(); } + private void setFfmpegMode(View view) { + int mode = (PlayerSetting.getFFmpegMode() + 1) % 4; + PlayerSetting.putFFmpegMode(mode); + mBinding.ffmpegModeText.setText(getFFmpegModeText()); + } + + private String getFFmpegModeText() { + return switch (PlayerSetting.getFFmpegMode()) { + case PlayerSetting.FFMPEG_MODE_OFFICIAL -> "Official"; + case PlayerSetting.FFMPEG_MODE_SIMPLE -> "Simple"; + case PlayerSetting.FFMPEG_MODE_AUTO -> "自动"; + default -> "NextLib"; + }; + } + @Override public void onHiddenChanged(boolean hidden) { if (!hidden) initView(); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingSubtitleFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingSubtitleFragment.java index 2d3fe73e33..b6fa85ec62 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingSubtitleFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingSubtitleFragment.java @@ -12,7 +12,6 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.FragmentSettingSubtitleBinding; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.subtitle.provider.SubtitleProviderRegistry; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.SubtitleSettingsDialog; import com.fongmi.android.tv.utils.ResUtil; @@ -42,14 +41,14 @@ public class SettingSubtitleFragment extends BaseFragment { subtitleLanguageValues = ResUtil.getStringArray(R.array.select_subtitle_language_value); mBinding.subtitleAutoMatchText.setText(getSwitch(Setting.isSubtitleAutoMatchEnabled())); mBinding.subtitleLanguageText.setText(getSubtitleLanguageText()); - mBinding.subtitleAssrtTokenText.setText(getSubtitleSourceText()); + mBinding.subtitleAssrtTokenText.setText(getSubtitleAssrtTokenText()); } @Override protected void initEvent() { mBinding.subtitleAutoMatch.setOnClickListener(this::setSubtitleAutoMatch); mBinding.subtitleLanguage.setOnClickListener(this::onSubtitleLanguage); - mBinding.subtitleAssrtToken.setOnClickListener(this::onSubtitleSource); + mBinding.subtitleAssrtToken.setOnClickListener(this::onSubtitleAssrtToken); } private void setSubtitleAutoMatch(View view) { @@ -64,8 +63,11 @@ public class SettingSubtitleFragment extends BaseFragment { }); } - private void onSubtitleSource(View view) { - SubtitleSettingsDialog.showSourceSummary(requireActivity(), SubtitleProviderRegistry.get().providers(), () -> mBinding.subtitleAssrtTokenText.setText(getSubtitleSourceText())); + private void onSubtitleAssrtToken(View view) { + SubtitleSettingsDialog.showAssrtToken(requireActivity(), Setting.getSubtitleAssrtToken(), value -> { + Setting.putSubtitleAssrtToken(value); + mBinding.subtitleAssrtTokenText.setText(getSubtitleAssrtTokenText()); + }); } private String getSubtitleLanguageText() { @@ -79,8 +81,8 @@ public class SettingSubtitleFragment extends BaseFragment { return 0; } - private String getSubtitleSourceText() { - return getString(R.string.player_subtitle_source_count, SubtitleProviderRegistry.get().providerNames().size()); + private String getSubtitleAssrtTokenText() { + return getString(TextUtils.isEmpty(Setting.getSubtitleAssrtToken()) ? R.string.setting_unconfigured : R.string.setting_configured); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingTmdbFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingTmdbFragment.java index 20d6896132..672aac9e2e 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingTmdbFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingTmdbFragment.java @@ -13,10 +13,11 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.FragmentSettingTmdbBinding; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.LightDialog; import com.fongmi.android.tv.ui.dialog.TmdbSourceDialog; +import com.fongmi.android.tv.utils.Notify; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class SettingTmdbFragment extends BaseFragment { @@ -92,8 +93,17 @@ public class SettingTmdbFragment extends BaseFragment { } private void setDetailOpenMode(View view) { - AlertDialog alert = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog).setTitle(R.string.setting_detail_open_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailOpenModes(), getDetailOpenModeIndex(), (dialog, which) -> { + AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog).setTitle(R.string.setting_detail_open_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailOpenModes(), getDetailOpenModeIndex(), (dialog, which) -> { int mode = DETAIL_OPEN_MODES[which]; + if (Setting.isTmdbMode(mode) && !Setting.isTmdbReady()) { + dialog.dismiss(); + Notify.show(R.string.detail_tmdb_need_key); + TmdbSourceDialog.create(requireActivity()).onDismiss(() -> { + if (Setting.isTmdbReady()) Setting.putDetailOpenMode(mode); + setText(); + }).show(); + return; + } Setting.putDetailOpenMode(mode); setText(); dialog.dismiss(); @@ -109,7 +119,7 @@ public class SettingTmdbFragment extends BaseFragment { private void setDetailThemeMode(View view) { if (!Setting.isTmdbMode(Setting.getDetailOpenMode())) return; - AlertDialog alert = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog).setTitle(R.string.setting_detail_theme_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailThemeModes(), getDetailThemeModeIndex(), (dialog, which) -> { + AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog).setTitle(R.string.setting_detail_theme_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailThemeModes(), getDetailThemeModeIndex(), (dialog, which) -> { Setting.putTmdbDetailStyle(DETAIL_THEME_MODES[which]); setText(); dialog.dismiss(); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java index fa6a3418af..d8b7d561fe 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/TypeFragment.java @@ -215,7 +215,7 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac @Override public void onItemClick(Vod item) { if (item.isAction()) { - com.fongmi.android.tv.content.ActionCardHelper.handleAction(requireActivity(), getKey(), item.getAction()); + mViewModel.action(getKey(), item.getAction()); } else if (item.isFolder()) { getParent().openFolder(item.getId(), mExtends); } else { diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/VodFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/VodFragment.java index 57fd9b38cf..0955870755 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/VodFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/VodFragment.java @@ -1,7 +1,6 @@ package com.fongmi.android.tv.ui.fragment; import android.net.Uri; -import android.app.Activity; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -49,8 +48,6 @@ import com.fongmi.android.tv.ui.activity.SearchActivity; import com.fongmi.android.tv.ui.adapter.TypeAdapter; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.ApkPushDialog; -import com.fongmi.android.tv.ui.dialog.ApkPushMethodDialog; -import com.fongmi.android.tv.ui.dialog.ApkPushUrlDialog; import com.fongmi.android.tv.ui.dialog.FilterDialog; import com.fongmi.android.tv.ui.dialog.HistoryDialog; import com.fongmi.android.tv.ui.dialog.LinkDialog; @@ -308,23 +305,6 @@ public class VodFragment extends BaseFragment implements ConfigListener, SiteLis } private void onApkDeviceSelected(Device device) { - App.post(() -> { - if (!isAdded()) return; - ApkPushMethodDialog.create(device).listener(new ApkPushMethodDialog.Listener() { - @Override - public void onLocal(Device device) { - selectLocalApk(device); - } - - @Override - public void onLink(Device device) { - ApkPushUrlDialog.create(device).show(requireActivity()); - } - }).show(requireActivity()); - }); - } - - private void selectLocalApk(Device device) { pendingApkDevice = device; App.post(() -> { if (isAdded()) apkLauncher.launch(new String[]{"application/vnd.android.package-archive", "application/octet-stream"}); @@ -429,26 +409,10 @@ public class VodFragment extends BaseFragment implements ConfigListener, SiteLis if (event.type() == ConfigEvent.Type.VOD) setLogo(); } - /** - * 状态浮层只能被「又一次启动 / 点重试」收掉,而配置可能在别的路径上变得可用: - * 恢复备份、远程指令切换接口、设置页切换接口、接收推送接口。这些路径发的是 - * {@link RefreshEvent#home()} 而不是 StateEvent,于是用户在这些页面上操作完回到首页, - * 首页仍顶着那张状态卡,**必须杀进程冷启动才恢复**(用户反馈的"杀进程后重进才行")。 - * - *

这里把「配置真的可用了」当成收卡的唯一判据:HOME 刷新意味着本 Fragment 马上会 - * 重新加载首页内容,此时若还处在空态/错误态就直接收起,不依赖任何调用方记得收。 - */ - private void dismissStateIfConfigured() { - if (mBinding.state.getVisibility() != View.VISIBLE) return; - if (VodConfig.get().getSites().isEmpty()) return; - hideState(); - } - @Subscribe(threadMode = ThreadMode.MAIN) public void onRefreshEvent(RefreshEvent event) { switch (event.getType()) { case HOME: - dismissStateIfConfigured(); if (mWeb != null && mWeb.isVisible()) { Site home = getHome(); WebHomeChromeStartup.remember(getConfig(), home); @@ -477,93 +441,14 @@ public class VodFragment extends BaseFragment implements ConfigListener, SiteLis public void onStateEvent(StateEvent event) { switch (event.type()) { case EMPTY: - // 空态:确实没有内容(与"加载失败"是两件事)。 - // 改动前这里只 hideProgress(),于是首页同样是一片空白。 hideProgress(); - showEmpty(); break; case PROGRESS: - hideState(); showProgress(); break; - case CONTENT: - hideState(); - break; - case ERROR: - // 设计文档 §9.9:错误态要给出「错误色 + 原因 + 重试动作」。 - // 改动前这里只 hideProgress()——于是内容源加载失败后首页是**一片空白**, - // 用户只看到一个会消失的吐司,既不知道原因也没有重试入口。 - hideProgress(); - showError(event.message()); - break; - case NO_CONFIG: - // 还没配接口:不是失败,所以不给"重试"(它永远不会成功),也不接管首页—— - // 收起状态浮层回到原生内容态,底部导航照常可用;添加接口走既有的 - // 「设置 → 点播」管理面板。 - hideProgress(); - showNoConfig(); - break; } } - /** - * 显示空态:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 - * - *

与错误态的区别是**没有失败原因**、标题也不用错误色——它表达的是"确实没有内容"。 - * 主动作与错误态的重试一样复用 {@link #homeContent()}。 - */ - private void showEmpty() { - mBinding.state.setVisibility(View.VISIBLE); - mBinding.state.setEmptyContent( - R.string.home_empty_title, - R.string.home_empty_description, - R.string.home_empty_action, - view -> homeContent()); - mBinding.state.showEmpty(); - } - - /** - * 显示错误态:错误色标题 + 失败原因 + 重试。 - * - *

重试必须回到失败的那一步。首页的错误来自承载本 Fragment 的 Activity 的 - * **内容源配置加载**(`HomeActivity.initConfig()`),不是本 Fragment 的内容加载—— - * 实机验证时最初把重试接成 `homeContent()`,结果点了重试页面毫无变化,因为配置根本没被重新拉取。 - * 因此这里通过 {@link ConfigRetry} 把动作交回 Activity;Activity 未实现时退回内容重载, - * 至少不会变成一个死按钮。 - */ - private void showError(String message) { - mBinding.state.setVisibility(View.VISIBLE); - mBinding.state.showError(R.string.error_config_load, message, R.string.error_retry, view -> retryConfig()); - } - - /** - * 还没配接口:这不是失败,也不该由本 Fragment 独占整屏。 - * - *

配置为空时首页仍然是"正常首页":底部导航(点播/设置)照常可用,只是没有内容。 - * 状态浮层只留给真正的加载失败——用它接管首页会把顶栏和内容一起盖掉。 - */ - private void showNoConfig() { - hideState(); - showNativeContent(); - } - - /** 重新加载内容源配置:交给承载本 Fragment 的 Activity(失败的那一步在它那里)。 */ - private void retryConfig() { - Activity activity = getActivity(); - if (activity instanceof ConfigRetry) ((ConfigRetry) activity).retryConfig(); - else homeContent(); - } - - /** 由承载本 Fragment 的 Activity 实现:重新加载内容源配置。 */ - public interface ConfigRetry { - void retryConfig(); - } - - /** 收起状态浮层,回到正常内容。 */ - private void hideState() { - mBinding.state.setVisibility(View.GONE); - } - @Subscribe(threadMode = ThreadMode.MAIN) public void onCastEvent(CastEvent event) { ReceiveDialog.create().event(event).show(this); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java b/app/src/mobile/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java deleted file mode 100644 index 6159af0df5..0000000000 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.fongmi.android.tv.ui.helper; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.widget.ImageView; - -import com.google.android.material.R; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.color.ColorRoles; -import com.google.android.material.color.MaterialColors; - -/** One palette per site dialog, including devices without platform dynamic-color support. */ -public record SiteDialogTheme(int surface, int onSurface, int onSurfaceVariant, - ColorStateList accent, ColorStateList buttonBackground, - ColorStateList buttonText, ColorStateList buttonStroke) { - - public static SiteDialogTheme resolve(Context context, int seedColor) { - int surface = color(context, R.attr.colorSurfaceContainer); - int primary = color(context, androidx.appcompat.R.attr.colorPrimary); - int onPrimary = color(context, R.attr.colorOnPrimary); - int container = color(context, R.attr.colorPrimaryContainer); - int onContainer = color(context, R.attr.colorOnPrimaryContainer); - int onSurface = color(context, R.attr.colorOnSurface); - int onSurfaceVariant = color(context, R.attr.colorOnSurfaceVariant); - int outline = color(context, R.attr.colorOutline); - if (seedColor != 0) { - // Material 1.14.0 ColorRoles works without DynamicColors' Android 12 availability gate. - // Use paired tonal colors, never the raw seed as a surface behind unrelated text colors. - ColorRoles roles = MaterialColors.getColorRoles(context, seedColor); - primary = roles.getAccent(); - onPrimary = roles.getOnAccent(); - container = roles.getAccentContainer(); - onContainer = roles.getOnAccentContainer(); - surface = MaterialColors.getSurfaceContainerFromSeed(context, seedColor); - } - return new SiteDialogTheme(surface, onSurface, onSurfaceVariant, - ColorStateList.valueOf(primary), - states(primary, container, surface), - states(onPrimary, onContainer, onSurface), - states(primary, primary, outline)); - } - - private static int color(Context context, int attr) { - return MaterialColors.getColor(context, attr, SiteDialogTheme.class.getSimpleName()); - } - - private static ColorStateList states(int selected, int active, int normal) { - // Keep selected first and disabled labels fully opaque, matching all site dialog modes. - return new ColorStateList(new int[][]{ - {android.R.attr.state_selected}, - {android.R.attr.state_focused}, - {android.R.attr.state_pressed}, - {} - }, new int[]{selected, active, active, normal}); - } - - public void apply(MaterialButton button) { - button.setTextColor(buttonText); - button.setBackgroundTintList(buttonBackground); - button.setStrokeColor(buttonStroke); - button.setRippleColor(accent.withAlpha(31)); - } - - public void tint(ImageView image) { - image.setImageTintList(accent); - } -} diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/holder/EpisodeHoriHolder.java b/app/src/mobile/java/com/fongmi/android/tv/ui/holder/EpisodeHoriHolder.java index bea71f1630..6c0fdd1af4 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/holder/EpisodeHoriHolder.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/holder/EpisodeHoriHolder.java @@ -138,10 +138,7 @@ public class EpisodeHoriHolder extends BaseEpisodeHolder { binding.nativeFileSize.setText(fileSize); binding.nativeFileSize.setVisibility(visible ? View.VISIBLE : View.GONE); binding.nativeFileSize.setSelected(binding.text.isActivated() || binding.text.hasFocus()); - // 左右必须成对给:只改左边会让 shape_video_item 的 12dp 右内边距落单, - // 按钮里的文本就偏向一侧而不是居中。徽标占位时左边额外让出 92dp。 - int horizontal = ResUtil.dp2px(12); - binding.text.setPadding(visible ? ResUtil.dp2px(92) : horizontal, binding.text.getPaddingTop(), horizontal, binding.text.getPaddingBottom()); + binding.text.setPadding(visible ? ResUtil.dp2px(92) : 0, binding.text.getPaddingTop(), binding.text.getPaddingEnd(), binding.text.getPaddingBottom()); } private void bindFileSize(String fileSize) { diff --git a/app/src/mobile/res/color/selector_control_sheet_text.xml b/app/src/mobile/res/color/selector_control_sheet_text.xml index b1281ca549..98c94eb03d 100644 --- a/app/src/mobile/res/color/selector_control_sheet_text.xml +++ b/app/src/mobile/res/color/selector_control_sheet_text.xml @@ -1,7 +1,6 @@ - diff --git a/app/src/mobile/res/drawable/ic_home_following_shadow.xml b/app/src/mobile/res/drawable/ic_home_following_shadow.xml deleted file mode 100644 index b65a4c7867..0000000000 --- a/app/src/mobile/res/drawable/ic_home_following_shadow.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - diff --git a/app/src/mobile/res/drawable/selector_control_sheet_button.xml b/app/src/mobile/res/drawable/selector_control_sheet_button.xml index 618825ad1f..3c1543ae07 100644 --- a/app/src/mobile/res/drawable/selector_control_sheet_button.xml +++ b/app/src/mobile/res/drawable/selector_control_sheet_button.xml @@ -3,15 +3,6 @@ android:color="#33FFFFFF"> - - - - - - - diff --git a/app/src/mobile/res/drawable/selector_live_action_button.xml b/app/src/mobile/res/drawable/selector_live_action_button.xml index d825fe7ead..21a8fd3b44 100644 --- a/app/src/mobile/res/drawable/selector_live_action_button.xml +++ b/app/src/mobile/res/drawable/selector_live_action_button.xml @@ -5,7 +5,7 @@ - + - + @@ -35,14 +35,6 @@ - - - - - - - - diff --git a/app/src/mobile/res/drawable/selector_tmdb_flag_item.xml b/app/src/mobile/res/drawable/selector_tmdb_flag_item.xml index 0ff17ed2e5..42fbb46e77 100644 --- a/app/src/mobile/res/drawable/selector_tmdb_flag_item.xml +++ b/app/src/mobile/res/drawable/selector_tmdb_flag_item.xml @@ -2,29 +2,29 @@ - - + + - - + + - - + + - - + + diff --git a/app/src/mobile/res/drawable/selector_tmdb_flag_item_dark.xml b/app/src/mobile/res/drawable/selector_tmdb_flag_item_dark.xml index f0d62d2e53..c0208236d5 100644 --- a/app/src/mobile/res/drawable/selector_tmdb_flag_item_dark.xml +++ b/app/src/mobile/res/drawable/selector_tmdb_flag_item_dark.xml @@ -1,20 +1,4 @@ - diff --git a/app/src/mobile/res/drawable/shape_live_list.xml b/app/src/mobile/res/drawable/shape_live_list.xml index 303fdee3bb..4e1460f195 100644 --- a/app/src/mobile/res/drawable/shape_live_list.xml +++ b/app/src/mobile/res/drawable/shape_live_list.xml @@ -2,6 +2,11 @@ - + - + \ No newline at end of file diff --git a/app/src/mobile/res/drawable/shape_quick_search_icon_button.xml b/app/src/mobile/res/drawable/shape_quick_search_icon_button.xml index d20c5475db..7ecc9a97f6 100644 --- a/app/src/mobile/res/drawable/shape_quick_search_icon_button.xml +++ b/app/src/mobile/res/drawable/shape_quick_search_icon_button.xml @@ -1,8 +1,5 @@ - - + diff --git a/app/src/mobile/res/layout-land/activity_video.xml b/app/src/mobile/res/layout-land/activity_video.xml index d3d85f4f94..05bfe35d91 100644 --- a/app/src/mobile/res/layout-land/activity_video.xml +++ b/app/src/mobile/res/layout-land/activity_video.xml @@ -46,7 +46,6 @@ style="@style/Player.Vod" android:layout_width="match_parent" android:layout_height="match_parent" - app:shutter_background_color="@android:color/black" app:surface_type="none" /> - - - - - - - - - - - - - + android:gravity="center" + android:orientation="vertical"> - + + + android:lineSpacingExtra="4dp" + android:text="@string/crash_info" + android:textColor="@color/grey_200" + android:textSize="16sp" /> - + - + - - - - - - - - - - - - - - - - - - - + diff --git a/app/src/mobile/res/layout/activity_following.xml b/app/src/mobile/res/layout/activity_following.xml deleted file mode 100644 index 2f0462c982..0000000000 --- a/app/src/mobile/res/layout/activity_following.xml +++ /dev/null @@ -1,142 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/mobile/res/layout/activity_live.xml b/app/src/mobile/res/layout/activity_live.xml index c4da3c9c77..34fc8b9063 100644 --- a/app/src/mobile/res/layout/activity_live.xml +++ b/app/src/mobile/res/layout/activity_live.xml @@ -8,12 +8,6 @@ android:keepScreenOn="true" android:orientation="vertical"> - - @@ -166,7 +166,7 @@ android:layout_height="wrap_content" android:layout_marginTop="18dp" android:lineSpacingExtra="4dp" - android:textColor="?attr/colorOnSurface" + android:textColor="#DDEAF2F8" android:textSize="13sp" /> @@ -310,7 +310,7 @@ android:layout_height="wrap_content" android:layout_marginTop="18dp" android:text="@string/detail_person_empty" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#99FFFFFF" android:textSize="13sp" android:visibility="gone" /> diff --git a/app/src/mobile/res/layout/activity_video.xml b/app/src/mobile/res/layout/activity_video.xml index 135cad1a3b..20456c8bf8 100644 --- a/app/src/mobile/res/layout/activity_video.xml +++ b/app/src/mobile/res/layout/activity_video.xml @@ -41,7 +41,6 @@ style="@style/Player.Vod" android:layout_width="match_parent" android:layout_height="match_parent" - app:shutter_background_color="@android:color/black" app:surface_type="none" /> - - - - - - - - - - - - - - - - - - - diff --git a/app/src/mobile/res/layout/adapter_ad_stats_item.xml b/app/src/mobile/res/layout/adapter_ad_stats_item.xml index 8de087d95f..6dcd3a5646 100644 --- a/app/src/mobile/res/layout/adapter_ad_stats_item.xml +++ b/app/src/mobile/res/layout/adapter_ad_stats_item.xml @@ -20,7 +20,7 @@ android:ellipsize="end" android:maxLines="1" android:text="站点名称" - android:textColor="@color/dialog_outlined_button_text" + android:textColor="@color/white" android:textSize="14sp" /> @@ -42,7 +42,7 @@ android:minWidth="48dp" android:text="0" android:textAlignment="textEnd" - android:textColor="@color/dialog_outlined_button_text" + android:textColor="@color/white" android:textSize="14sp" android:textStyle="bold" /> diff --git a/app/src/mobile/res/layout/adapter_channel.xml b/app/src/mobile/res/layout/adapter_channel.xml index cc6d7d00cc..490aad0cb4 100644 --- a/app/src/mobile/res/layout/adapter_channel.xml +++ b/app/src/mobile/res/layout/adapter_channel.xml @@ -16,7 +16,7 @@ android:layout_marginEnd="12dp" android:duplicateParentState="true" android:fontFeatureSettings="tnum" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/selector_live_text" android:textSize="14sp" tools:text="01" /> @@ -35,7 +35,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/selector_live_text" android:textSize="14sp" tools:text="CNN" /> diff --git a/app/src/mobile/res/layout/adapter_config.xml b/app/src/mobile/res/layout/adapter_config.xml index 9802d17329..0f2071ed08 100644 --- a/app/src/mobile/res/layout/adapter_config.xml +++ b/app/src/mobile/res/layout/adapter_config.xml @@ -21,17 +21,6 @@ app:strokeColor="@color/dialog_outlined_button_stroke" tools:text="https://fongmi.github.io/cat.json" /> - - + android:background="#FF4A8DFF" /> @@ -33,7 +33,7 @@ android:layout_width="wrap_content" android:layout_height="match_parent" android:gravity="center_vertical" - android:textColor="@color/webhtv_color_on_surface_variant" + android:textColor="@color/white_60" android:textSize="12sp" tools:text="3" /> diff --git a/app/src/mobile/res/layout/adapter_device.xml b/app/src/mobile/res/layout/adapter_device.xml index a559ad7b5c..fe2dcdd5e1 100644 --- a/app/src/mobile/res/layout/adapter_device.xml +++ b/app/src/mobile/res/layout/adapter_device.xml @@ -12,7 +12,7 @@ android:id="@+id/type" android:layout_width="wrap_content" android:layout_height="wrap_content" - app:tint="?attr/colorOnSurfaceVariant" + app:tint="#5F6368" tools:src="@drawable/ic_cast_tv" /> @@ -36,7 +36,7 @@ android:layout_height="wrap_content" android:layout_marginTop="2dp" android:singleLine="true" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textSize="12sp" tools:text="127.0.0.1" /> diff --git a/app/src/mobile/res/layout/adapter_epg_data.xml b/app/src/mobile/res/layout/adapter_epg_data.xml index 9c0f873538..80bc682e63 100644 --- a/app/src/mobile/res/layout/adapter_epg_data.xml +++ b/app/src/mobile/res/layout/adapter_epg_data.xml @@ -15,7 +15,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/selector_live_text" android:textSize="14sp" tools:text="七龍珠" /> @@ -26,7 +26,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/selector_live_text" android:textSize="12sp" tools:text="18:00 ~ 19:00" /> diff --git a/app/src/mobile/res/layout/adapter_episode_grid.xml b/app/src/mobile/res/layout/adapter_episode_grid.xml index e778f2c45e..bdbe05feae 100644 --- a/app/src/mobile/res/layout/adapter_episode_grid.xml +++ b/app/src/mobile/res/layout/adapter_episode_grid.xml @@ -49,7 +49,7 @@ android:clickable="true" android:focusable="true" android:visibility="gone" - app:cardBackgroundColor="?attr/colorSurfaceContainerHigh" + app:cardBackgroundColor="#CC16202A" app:cardCornerRadius="8dp" app:cardElevation="0dp" app:strokeColor="@color/selector_episode_card_stroke" @@ -69,7 +69,7 @@ android:id="@+id/still" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?attr/colorSurfaceContainerHigh" + android:background="#16202A" android:contentDescription="@null" android:scaleType="centerCrop" /> @@ -188,7 +188,7 @@ android:ellipsize="end" android:lineSpacingExtra="1dp" android:maxLines="2" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="#E6FFFFFF" android:textSize="10sp" tools:text="Episode overview preview." /> diff --git a/app/src/mobile/res/layout/adapter_episode_hori.xml b/app/src/mobile/res/layout/adapter_episode_hori.xml index ade63c1eb9..4ba59e617f 100644 --- a/app/src/mobile/res/layout/adapter_episode_hori.xml +++ b/app/src/mobile/res/layout/adapter_episode_hori.xml @@ -52,7 +52,7 @@ android:clickable="true" android:focusable="true" android:visibility="gone" - app:cardBackgroundColor="?attr/colorSurfaceContainerHigh" + app:cardBackgroundColor="#CC16202A" app:cardCornerRadius="8dp" app:cardElevation="0dp" app:strokeColor="@color/selector_episode_card_stroke" @@ -66,7 +66,7 @@ android:id="@+id/still" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?attr/colorSurfaceContainerHigh" + android:background="#16202A" android:contentDescription="@null" android:scaleType="centerCrop" /> @@ -149,7 +149,7 @@ android:ellipsize="end" android:lineSpacingExtra="1dp" android:maxLines="2" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="#E6FFFFFF" android:textSize="10sp" tools:text="这是集数的简介内容..." /> diff --git a/app/src/mobile/res/layout/adapter_file.xml b/app/src/mobile/res/layout/adapter_file.xml index a0084ebd39..a5f6a0846a 100644 --- a/app/src/mobile/res/layout/adapter_file.xml +++ b/app/src/mobile/res/layout/adapter_file.xml @@ -22,7 +22,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="14sp" tools:text="Download" /> diff --git a/app/src/mobile/res/layout/adapter_github_proxy.xml b/app/src/mobile/res/layout/adapter_github_proxy.xml index 9d5023c9bd..00532b45cf 100644 --- a/app/src/mobile/res/layout/adapter_github_proxy.xml +++ b/app/src/mobile/res/layout/adapter_github_proxy.xml @@ -20,21 +20,10 @@ android:paddingStart="14dp" android:paddingEnd="14dp" android:singleLine="true" - android:textColor="@color/fixed_light_focus_text" + android:textColor="#202124" android:textSize="13sp" tools:text="https://gh-proxy.com/" /> - - + app:tint="#C5221F" /> diff --git a/app/src/mobile/res/layout/adapter_group.xml b/app/src/mobile/res/layout/adapter_group.xml index d18ef8ef26..8237fa5914 100644 --- a/app/src/mobile/res/layout/adapter_group.xml +++ b/app/src/mobile/res/layout/adapter_group.xml @@ -17,7 +17,7 @@ android:ellipsize="marquee" android:gravity="center" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/selector_live_text" android:textSize="14sp" tools:text="收藏" /> diff --git a/app/src/mobile/res/layout/adapter_player_osd.xml b/app/src/mobile/res/layout/adapter_player_osd.xml index f24f3d33cd..afdba9bcd8 100644 --- a/app/src/mobile/res/layout/adapter_player_osd.xml +++ b/app/src/mobile/res/layout/adapter_player_osd.xml @@ -7,7 +7,7 @@ android:layout_height="48dp" android:layout_marginBottom="8dp" android:addStatesFromChildren="true" - android:background="@drawable/selector_player_settings_row" + android:background="@drawable/selector_git_cloud_card" android:clickable="true" android:descendantFocusability="afterDescendants" android:gravity="center_vertical" @@ -22,7 +22,7 @@ android:layout_weight="1" android:ellipsize="end" android:singleLine="true" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="15sp" tools:text="Playback params" /> @@ -30,12 +30,12 @@ android:id="@+id/visible" android:layout_width="40dp" android:layout_height="36dp" - android:background="@drawable/selector_player_settings_switch" + android:background="@drawable/selector_dialog_switch" android:contentDescription="@string/setting_show" android:focusable="true" android:padding="8dp" android:scaleType="center" android:src="@drawable/ic_player_button_visible" - app:tint="@color/player_settings_icon_tint" /> + app:tint="@color/dialog_tonal_button_text" /> diff --git a/app/src/mobile/res/layout/adapter_quick.xml b/app/src/mobile/res/layout/adapter_quick.xml index c9f383c96a..7d4b72ae06 100644 --- a/app/src/mobile/res/layout/adapter_quick.xml +++ b/app/src/mobile/res/layout/adapter_quick.xml @@ -36,7 +36,7 @@ android:ellipsize="end" android:lineSpacingExtra="2dp" android:maxLines="2" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" tools:text="仙逆剧场版 神临之战" /> @@ -54,7 +54,7 @@ android:ellipsize="end" android:maxWidth="112dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white_60" android:textSize="12sp" tools:text="咕噜 | APP" /> @@ -66,7 +66,7 @@ android:layout_weight="1" android:ellipsize="end" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white_80" android:textSize="12sp" tools:text="更新至146集" /> diff --git a/app/src/mobile/res/layout/adapter_restore.xml b/app/src/mobile/res/layout/adapter_restore.xml index a5bb67cb03..11d7bed0a9 100644 --- a/app/src/mobile/res/layout/adapter_restore.xml +++ b/app/src/mobile/res/layout/adapter_restore.xml @@ -19,7 +19,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:singleLine="true" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="14sp" /> @@ -38,6 +38,6 @@ android:layout_height="wrap_content" android:background="?attr/selectableItemBackgroundBorderless" android:src="@drawable/ic_setting_delete" - app:tint="?attr/colorOnSurfaceVariant" /> + app:tint="#5F6368" /> diff --git a/app/src/mobile/res/layout/adapter_search.xml b/app/src/mobile/res/layout/adapter_search.xml index 85005181f4..9f1c665f29 100644 --- a/app/src/mobile/res/layout/adapter_search.xml +++ b/app/src/mobile/res/layout/adapter_search.xml @@ -23,7 +23,7 @@ android:ellipsize="end" android:maxWidth="90dp" android:singleLine="true" - android:textColor="?attr/colorPrimary" + android:textColor="@color/text_site" android:textSize="12sp" android:visibility="gone" tools:text="泥巴" @@ -43,7 +43,7 @@ android:layout_weight="1" android:ellipsize="end" android:maxLines="3" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="14sp" tools:text="蜘蛛人" /> @@ -56,7 +56,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="12sp" tools:text="1080p" /> diff --git a/app/src/mobile/res/layout/adapter_search_hot_word.xml b/app/src/mobile/res/layout/adapter_search_hot_word.xml index c2a84e69b2..f9f8a339cd 100644 --- a/app/src/mobile/res/layout/adapter_search_hot_word.xml +++ b/app/src/mobile/res/layout/adapter_search_hot_word.xml @@ -16,7 +16,7 @@ android:paddingStart="8dp" android:paddingEnd="8dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="13sp" tools:text="熱搜詞" /> diff --git a/app/src/mobile/res/layout/adapter_search_record.xml b/app/src/mobile/res/layout/adapter_search_record.xml index a1e6007a1e..be36a827ac 100644 --- a/app/src/mobile/res/layout/adapter_search_record.xml +++ b/app/src/mobile/res/layout/adapter_search_record.xml @@ -8,6 +8,6 @@ android:background="@drawable/shape_item_round" android:ellipsize="marquee" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="14sp" tools:text="歷史" /> \ No newline at end of file diff --git a/app/src/mobile/res/layout/adapter_search_word.xml b/app/src/mobile/res/layout/adapter_search_word.xml index cf6b415ce9..816ad5cde8 100644 --- a/app/src/mobile/res/layout/adapter_search_word.xml +++ b/app/src/mobile/res/layout/adapter_search_word.xml @@ -8,6 +8,6 @@ android:background="@drawable/shape_item_round" android:ellipsize="marquee" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="14sp" tools:text="推薦" /> \ No newline at end of file diff --git a/app/src/mobile/res/layout/adapter_site.xml b/app/src/mobile/res/layout/adapter_site.xml index 0b00ceb6b3..fdf709b328 100644 --- a/app/src/mobile/res/layout/adapter_site.xml +++ b/app/src/mobile/res/layout/adapter_site.xml @@ -26,10 +26,10 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/colorOnSurface" + android:textColor="@color/site_button_text" android:textSize="14sp" - app:backgroundTint="?attr/colorSurfaceContainer" - app:strokeColor="?attr/colorOutline" + app:backgroundTint="@color/site_button_bg" + app:strokeColor="@color/site_button_stroke" tools:text="泥巴" /> + app:tint="@color/dialog_outlined_button_text" /> + app:tint="@color/dialog_outlined_button_text" /> diff --git a/app/src/mobile/res/layout/adapter_vod.xml b/app/src/mobile/res/layout/adapter_vod.xml index 402f30bb5d..54fa314ccd 100644 --- a/app/src/mobile/res/layout/adapter_vod.xml +++ b/app/src/mobile/res/layout/adapter_vod.xml @@ -11,7 +11,7 @@ android:id="@+id/image" android:layout_width="match_parent" android:layout_height="80dp" - android:background="?attr/colorSurfaceContainer" + android:background="@color/black_20" android:scaleType="centerCrop" app:shapeAppearanceOverlay="@style/Vod.Grid" /> @@ -23,13 +23,29 @@ android:layout_alignTop="@+id/image" android:layout_alignEnd="@+id/image" android:layout_alignBottom="@+id/history_info" - android:background="@color/webhtv_color_player_scrim" + android:background="@color/black_50" android:scaleType="center" android:src="@drawable/ic_vod_delete" android:visibility="gone" app:shapeAppearanceOverlay="@style/Vod.Grid.Large" tools:visibility="visible" /> + + - - - - @@ -121,7 +113,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white_70" android:textSize="12sp" tools:text="第01集. 海边散步" /> diff --git a/app/src/mobile/res/layout/adapter_vod_list.xml b/app/src/mobile/res/layout/adapter_vod_list.xml index d9887295f6..c44f9688c7 100644 --- a/app/src/mobile/res/layout/adapter_vod_list.xml +++ b/app/src/mobile/res/layout/adapter_vod_list.xml @@ -32,7 +32,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="14sp" tools:text="蜘蛛人" /> @@ -45,7 +45,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="12sp" tools:text="1080p" /> diff --git a/app/src/mobile/res/layout/adapter_vod_oval.xml b/app/src/mobile/res/layout/adapter_vod_oval.xml index 4c683319df..9fd9b12842 100644 --- a/app/src/mobile/res/layout/adapter_vod_oval.xml +++ b/app/src/mobile/res/layout/adapter_vod_oval.xml @@ -11,7 +11,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="8dp" - android:background="?attr/colorSurfaceContainer" + android:background="@color/black_20" android:foreground="@drawable/shape_vod_oval" app:shapeAppearanceOverlay="@style/Vod.Circle" /> @@ -27,7 +27,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="14sp" tools:text="蜘蛛人" /> diff --git a/app/src/mobile/res/layout/adapter_vod_rect.xml b/app/src/mobile/res/layout/adapter_vod_rect.xml index 55585dc949..79f7c8eb76 100644 --- a/app/src/mobile/res/layout/adapter_vod_rect.xml +++ b/app/src/mobile/res/layout/adapter_vod_rect.xml @@ -11,7 +11,7 @@ android:id="@+id/image" android:layout_width="match_parent" android:layout_height="80dp" - android:background="?attr/colorSurfaceContainer" + android:background="@color/black_20" app:shapeAppearanceOverlay="@style/Vod.Grid" /> - + android:fillViewport="true"> - + android:orientation="vertical" + android:padding="32dp"> - - - - - + android:layout_height="wrap_content" + android:text="@string/ad_block_stats" + android:textColor="@color/white" + android:textSize="17sp" + android:textStyle="bold" /> + + + + android:orientation="vertical"> + + + android:textColor="@color/white_50" + android:textSize="12sp" /> + + android:orientation="vertical"> + + + android:textColor="@color/white_50" + android:textSize="12sp" /> + + android:orientation="vertical"> + + + android:textColor="@color/white_50" + android:textSize="12sp" /> + + - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:textStyle="bold" /> + + + + + + + + + + + + + + + + + + + + + - + + diff --git a/app/src/mobile/res/layout/dialog_ad_rule_edit.xml b/app/src/mobile/res/layout/dialog_ad_rule_edit.xml index cc226b6537..798256b0b8 100644 --- a/app/src/mobile/res/layout/dialog_ad_rule_edit.xml +++ b/app/src/mobile/res/layout/dialog_ad_rule_edit.xml @@ -114,7 +114,7 @@ android:text="@android:string/cancel" android:textColor="@color/dialog_outlined_button_text" app:backgroundTint="@color/dialog_outlined_button_bg" - app:strokeColor="?attr/colorOutline" /> + app:strokeColor="@color/dialog_outlined_button_stroke" /> + app:strokeColor="@color/dialog_outlined_button_stroke" /> + app:strokeColor="@color/dialog_outlined_button_stroke" /> - - - - - - - - @@ -122,7 +87,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="@color/white_60" android:textSize="12sp" tools:text="来自配置 · 共 12 条(只读)" /> diff --git a/app/src/mobile/res/layout/dialog_ad_rule_preview.xml b/app/src/mobile/res/layout/dialog_ad_rule_preview.xml index 04134eb48f..9edf75f3e4 100644 --- a/app/src/mobile/res/layout/dialog_ad_rule_preview.xml +++ b/app/src/mobile/res/layout/dialog_ad_rule_preview.xml @@ -35,7 +35,7 @@ android:background="@drawable/shape_warning_background" android:padding="10dp" android:text="@string/ad_rule_low_confidence_warning" - android:textColor="?attr/colorOnSurface" + android:textColor="@color/white" android:textSize="13sp" android:visibility="gone" tools:visibility="visible" /> diff --git a/app/src/mobile/res/layout/dialog_apk_push_method.xml b/app/src/mobile/res/layout/dialog_apk_push_method.xml deleted file mode 100644 index 6acf362e3b..0000000000 --- a/app/src/mobile/res/layout/dialog_apk_push_method.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/mobile/res/layout/dialog_buffer.xml b/app/src/mobile/res/layout/dialog_buffer.xml index 27638615aa..10dee6771e 100644 --- a/app/src/mobile/res/layout/dialog_buffer.xml +++ b/app/src/mobile/res/layout/dialog_buffer.xml @@ -15,9 +15,9 @@ android:stepSize="1" android:valueFrom="1" android:valueTo="15" - app:haloColor="?attr/colorPrimary" - app:labelStyle="@style/Widget.WebHTV.SliderLabel" - app:thumbColor="?attr/colorPrimary" + app:haloColor="#1F1A73E8" + app:labelStyle="@style/Widget.WebHTV.LightDialogSliderLabel" + app:thumbColor="#1A73E8" app:tickVisible="false" /> diff --git a/app/src/mobile/res/layout/dialog_cache_management.xml b/app/src/mobile/res/layout/dialog_cache_management.xml deleted file mode 100644 index 05f0750b33..0000000000 --- a/app/src/mobile/res/layout/dialog_cache_management.xml +++ /dev/null @@ -1,186 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/mobile/res/layout/dialog_config.xml b/app/src/mobile/res/layout/dialog_config.xml index b614a7c549..1bbcb3e8c5 100644 --- a/app/src/mobile/res/layout/dialog_config.xml +++ b/app/src/mobile/res/layout/dialog_config.xml @@ -17,7 +17,7 @@ android:layout_height="wrap_content" android:ellipsize="end" android:singleLine="true" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="20sp" android:textStyle="bold" tools:text="点播" /> @@ -28,9 +28,9 @@ android:layout_height="wrap_content" android:layout_marginTop="14dp" android:hint="@string/remote_trust_config_name" - app:boxBackgroundColor="?attr/colorSurfaceContainerHighest" - app:boxStrokeColor="?attr/colorOutline" - app:hintTextColor="?attr/colorOnSurfaceVariant"> + app:boxBackgroundColor="@color/white" + app:boxStrokeColor="@color/dialog_outlined_button_stroke" + app:hintTextColor="#5F6368"> + android:textColor="#202124" + android:textColorHint="#8A8F98" /> @@ -52,12 +52,12 @@ android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/dialog_config_hint" - app:boxBackgroundColor="?attr/colorSurfaceContainerHighest" - app:boxStrokeColor="?attr/colorOutline" + app:boxBackgroundColor="@color/white" + app:boxStrokeColor="@color/dialog_outlined_button_stroke" app:endIconDrawable="@drawable/ic_action_choose" app:endIconMode="custom" app:endIconTint="@color/dialog_text_button" - app:hintTextColor="?attr/colorOnSurfaceVariant" + app:hintTextColor="#5F6368" app:startIconContentDescription="@string/dialog_config_choose_dir" app:startIconDrawable="@drawable/ic_folder"> @@ -70,31 +70,8 @@ android:inputType="text" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="?attr/colorOnSurface" - android:textColorHint="?attr/colorOnSurfaceVariant" /> - - - - - - + android:textColor="#202124" + android:textColorHint="#8A8F98" /> @@ -119,7 +96,7 @@ android:text="@string/dialog_negative" android:textColor="@color/dialog_outlined_button_text" app:backgroundTint="@color/dialog_outlined_button_bg" - app:strokeColor="?attr/colorOutline" /> + app:strokeColor="@color/dialog_outlined_button_stroke" /> + app:backgroundTint="@color/dialog_primary_button_bg" /> diff --git a/app/src/mobile/res/layout/dialog_control.xml b/app/src/mobile/res/layout/dialog_control.xml index 3a7f085a90..4096ec4a42 100644 --- a/app/src/mobile/res/layout/dialog_control.xml +++ b/app/src/mobile/res/layout/dialog_control.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/shape_dialog_control_glass_panel" - tools:background="?attr/colorSurfaceContainerLow"> + tools:background="#1F1F1F"> - - + app:tint="@color/white_90" /> + app:tint="@color/white_90" /> diff --git a/app/src/mobile/res/layout/dialog_danmaku_search.xml b/app/src/mobile/res/layout/dialog_danmaku_search.xml index e574c2a8d6..c289f1fd16 100644 --- a/app/src/mobile/res/layout/dialog_danmaku_search.xml +++ b/app/src/mobile/res/layout/dialog_danmaku_search.xml @@ -27,8 +27,8 @@ android:inputType="textCapWords|textAutoCorrect|textAutoComplete" android:maxLength="255" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" - android:textColorHint="@color/webhtv_color_player_control_muted" + android:textColor="@color/white" + android:textColorHint="@color/white_60" android:textSize="14sp" /> @@ -41,7 +41,7 @@ android:layout_marginBottom="16dp" android:indeterminate="true" android:visibility="gone" - app:indicatorColor="?attr/colorPrimary" + app:indicatorColor="@color/white_90" app:indicatorSize="32dp" app:trackThickness="2dp" tools:visibility="visible" /> diff --git a/app/src/mobile/res/layout/dialog_danmaku_setting.xml b/app/src/mobile/res/layout/dialog_danmaku_setting.xml index d7fb187a8e..69b511dccf 100644 --- a/app/src/mobile/res/layout/dialog_danmaku_setting.xml +++ b/app/src/mobile/res/layout/dialog_danmaku_setting.xml @@ -21,7 +21,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/danmaku_setting" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="20sp" android:textStyle="bold" /> diff --git a/app/src/mobile/res/layout/dialog_device.xml b/app/src/mobile/res/layout/dialog_device.xml index 5c48c5d864..866d924311 100644 --- a/app/src/mobile/res/layout/dialog_device.xml +++ b/app/src/mobile/res/layout/dialog_device.xml @@ -21,7 +21,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/device_select" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="16sp" /> + app:tint="#5F6368" /> + app:tint="#5F6368" /> diff --git a/app/src/mobile/res/layout/dialog_episode_list.xml b/app/src/mobile/res/layout/dialog_episode_list.xml index 00dda1a5fa..83d5fb6433 100644 --- a/app/src/mobile/res/layout/dialog_episode_list.xml +++ b/app/src/mobile/res/layout/dialog_episode_list.xml @@ -16,7 +16,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/detail_flag" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="15sp" android:textStyle="bold" /> @@ -39,7 +39,7 @@ android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/detail_season" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="15sp" android:textStyle="bold" android:visibility="gone" @@ -65,7 +65,7 @@ android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/detail_episode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="15sp" android:textStyle="bold" /> diff --git a/app/src/mobile/res/layout/dialog_history.xml b/app/src/mobile/res/layout/dialog_history.xml index 3770411373..72ea868a00 100644 --- a/app/src/mobile/res/layout/dialog_history.xml +++ b/app/src/mobile/res/layout/dialog_history.xml @@ -1,33 +1,13 @@ - - - - - - - + android:clipChildren="false" + android:clipToPadding="false" + android:overScrollMode="never" + android:padding="24dp" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + app:maxHeight="376dp" /> diff --git a/app/src/mobile/res/layout/dialog_info.xml b/app/src/mobile/res/layout/dialog_info.xml index f244a7cefb..58ffb9221e 100644 --- a/app/src/mobile/res/layout/dialog_info.xml +++ b/app/src/mobile/res/layout/dialog_info.xml @@ -16,7 +16,7 @@ android:layout_height="wrap_content" android:ellipsize="middle" android:linksClickable="false" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="20sp" /> diff --git a/app/src/mobile/res/layout/dialog_live.xml b/app/src/mobile/res/layout/dialog_live.xml index 582e8fd183..aa5419252e 100644 --- a/app/src/mobile/res/layout/dialog_live.xml +++ b/app/src/mobile/res/layout/dialog_live.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/shape_dialog_control_glass_panel" - tools:background="?attr/colorSurfaceContainerLow"> + tools:background="#1F1F1F"> diff --git a/app/src/mobile/res/layout/dialog_live_control.xml b/app/src/mobile/res/layout/dialog_live_control.xml index 5daa990d22..9dbf9e7fad 100644 --- a/app/src/mobile/res/layout/dialog_live_control.xml +++ b/app/src/mobile/res/layout/dialog_live_control.xml @@ -4,7 +4,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/shape_dialog_control_glass_panel" - tools:background="?attr/colorSurfaceContainerLow"> + tools:background="#1F1F1F"> - + + tools:background="#1F1F1F"> @@ -58,7 +58,7 @@ android:background="@null" android:gravity="center_vertical|end" android:text="@string/live_epg_apply" - android:textColor="@color/webhtv_color_player_control_muted" + android:textColor="@color/white_70" android:textSize="14sp" /> @@ -77,8 +77,8 @@ android:paddingStart="14dp" android:paddingEnd="14dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" - android:textColorHint="@color/webhtv_color_player_control_muted" + android:textColor="@color/white" + android:textColorHint="@color/white_60" android:textSize="13sp" /> + tools:background="#1F1F1F"> + android:textColor="@color/white_70" /> + app:rippleColor="#33FFFFFF" + app:strokeColor="#4DFFFFFF" /> @@ -60,14 +60,14 @@ android:layout_weight="1" android:text="@string/offset_audio" android:textAppearance="?attr/textAppearanceBodyLarge" - android:textColor="?attr/webhtvColorOnWallpaper" /> + android:textColor="@color/white_90" /> + android:textColor="@color/white_70" /> @@ -105,14 +105,14 @@ android:layout_weight="1" android:text="@string/offset_text" android:textAppearance="?attr/textAppearanceBodyLarge" - android:textColor="?attr/webhtvColorOnWallpaper" /> + android:textColor="@color/white_90" /> + android:textColor="@color/white_70" /> diff --git a/app/src/mobile/res/layout/dialog_player_osd.xml b/app/src/mobile/res/layout/dialog_player_osd.xml index 11c618c08d..6e259a2290 100644 --- a/app/src/mobile/res/layout/dialog_player_osd.xml +++ b/app/src/mobile/res/layout/dialog_player_osd.xml @@ -19,7 +19,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/player_osd" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="18sp" android:textStyle="bold" /> diff --git a/app/src/mobile/res/layout/dialog_quick_search.xml b/app/src/mobile/res/layout/dialog_quick_search.xml index 2d50fea356..3a279a5349 100644 --- a/app/src/mobile/res/layout/dialog_quick_search.xml +++ b/app/src/mobile/res/layout/dialog_quick_search.xml @@ -25,7 +25,7 @@ android:ellipsize="end" android:gravity="start|center_vertical" android:maxLines="1" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="17sp" android:textStyle="bold" tools:text="快搜 “仙逆”" /> @@ -56,8 +56,8 @@ android:paddingStart="12dp" android:paddingEnd="12dp" android:singleLine="true" - android:textColor="?attr/webhtvColorOnWallpaper" - android:textColorHint="@color/webhtv_color_player_control_muted" + android:textColor="@color/white" + android:textColorHint="@color/white_50" android:textSize="15sp" /> + app:tint="@color/dialog_outlined_button_text" /> + android:background="#12FFFFFF" /> diff --git a/app/src/mobile/res/layout/dialog_receive.xml b/app/src/mobile/res/layout/dialog_receive.xml index a86ad9b89c..a55b32b570 100644 --- a/app/src/mobile/res/layout/dialog_receive.xml +++ b/app/src/mobile/res/layout/dialog_receive.xml @@ -16,7 +16,7 @@ android:id="@+id/image" android:layout_width="90dp" android:layout_height="120dp" - android:background="@color/webhtv_color_scrim" + android:background="@color/black_10" app:shapeAppearanceOverlay="@style/Vod.Grid.Large" /> @@ -59,7 +59,7 @@ android:layout_alignStart="@+id/name" android:layout_marginTop="2dp" android:singleLine="true" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textSize="14sp" tools:text="Xiaomi 13" /> diff --git a/app/src/mobile/res/layout/dialog_restore.xml b/app/src/mobile/res/layout/dialog_restore.xml index e9e8d39959..a6b0112342 100644 --- a/app/src/mobile/res/layout/dialog_restore.xml +++ b/app/src/mobile/res/layout/dialog_restore.xml @@ -19,7 +19,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/restore_select" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/dialog_site.xml b/app/src/mobile/res/layout/dialog_site.xml index c698ea7a02..371e1cf28f 100644 --- a/app/src/mobile/res/layout/dialog_site.xml +++ b/app/src/mobile/res/layout/dialog_site.xml @@ -3,7 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/shape_site_dialog" + android:background="@drawable/shape_shell_proxy_dialog" android:orientation="vertical" android:padding="24dp"> @@ -33,11 +33,11 @@ android:paddingStart="16dp" android:paddingEnd="16dp" android:singleLine="true" - android:textColor="?attr/colorOnSurface" - android:textColorHint="?attr/colorOnSurfaceVariant" + android:textColor="#202124" + android:textColorHint="#8A8F98" android:textSize="14sp" app:drawableStartCompat="@drawable/ic_site_search" - app:drawableTint="?attr/colorPrimary" /> + app:drawableTint="@color/dialog_outlined_button_text" /> + app:tint="@color/dialog_outlined_button_text" /> + app:tint="@color/dialog_outlined_button_text" /> diff --git a/app/src/mobile/res/layout/dialog_speed.xml b/app/src/mobile/res/layout/dialog_speed.xml index f9fd3709c8..da4793347e 100644 --- a/app/src/mobile/res/layout/dialog_speed.xml +++ b/app/src/mobile/res/layout/dialog_speed.xml @@ -15,9 +15,9 @@ android:stepSize="0.5" android:valueFrom="2" android:valueTo="5" - app:haloColor="?attr/colorPrimary" - app:labelStyle="@style/Widget.WebHTV.SliderLabel" - app:thumbColor="?attr/colorPrimary" + app:haloColor="#1F1A73E8" + app:labelStyle="@style/Widget.WebHTV.LightDialogSliderLabel" + app:thumbColor="#1A73E8" app:tickVisible="false" /> diff --git a/app/src/mobile/res/layout/dialog_timer.xml b/app/src/mobile/res/layout/dialog_timer.xml index aaba9baa29..48750c4eed 100644 --- a/app/src/mobile/res/layout/dialog_timer.xml +++ b/app/src/mobile/res/layout/dialog_timer.xml @@ -20,7 +20,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/play_timer" - android:textColor="@color/webhtv_color_player_control_muted" + android:textColor="@color/white_70" android:textSize="16sp" /> @@ -98,7 +98,7 @@ android:layout_height="wrap_content" android:layout_marginTop="24dp" android:layout_marginBottom="24dp" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="48sp" tools:text="5:00" /> @@ -110,9 +110,9 @@ android:layout_marginBottom="8dp" android:singleLine="true" android:text="@string/timer_delay" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" app:backgroundTint="@color/selector_dialog_glass_button_tint" - app:rippleColor="@color/webhtv_color_overlay_light" /> + app:rippleColor="#33FFFFFF" /> + android:textColor="@color/white_90" + app:strokeColor="#66FFFFFF" /> diff --git a/app/src/mobile/res/layout/dialog_title.xml b/app/src/mobile/res/layout/dialog_title.xml index 36acb945d6..0393cd2af7 100644 --- a/app/src/mobile/res/layout/dialog_title.xml +++ b/app/src/mobile/res/layout/dialog_title.xml @@ -22,7 +22,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/dialog_select_title" - android:textColor="@color/webhtv_color_player_control_muted" + android:textColor="@color/white_70" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/dialog_track.xml b/app/src/mobile/res/layout/dialog_track.xml index 7b9360b764..128895bc7e 100644 --- a/app/src/mobile/res/layout/dialog_track.xml +++ b/app/src/mobile/res/layout/dialog_track.xml @@ -21,7 +21,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:textColor="@color/webhtv_color_player_control_muted" + android:textColor="@color/white_70" android:textSize="16sp" tools:text="選擇字幕" /> @@ -33,7 +33,7 @@ android:gravity="center" android:paddingHorizontal="12dp" android:text="@string/play_track_secondary_subtitle" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white_90" android:textSize="13sp" android:visibility="gone" tools:visibility="visible" /> @@ -44,7 +44,7 @@ android:layout_height="wrap_content" android:background="?attr/selectableItemBackgroundBorderless" android:src="@drawable/ic_action_choose" - app:tint="?attr/webhtvColorOnWallpaper" + app:tint="@color/white_90" android:visibility="gone" tools:visibility="visible" /> @@ -56,7 +56,7 @@ android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="@string/subtitle_manual_search" android:src="@drawable/ic_action_search" - app:tint="?attr/webhtvColorOnWallpaper" + app:tint="@color/white_90" android:visibility="gone" tools:visibility="visible" /> @@ -69,7 +69,7 @@ android:contentDescription="@string/subtitle_ai_translate_action" android:src="@drawable/ic_action_ai" android:visibility="gone" - app:tint="?attr/webhtvColorOnWallpaper" + app:tint="@color/white_90" tools:visibility="visible" /> @@ -115,47 +115,12 @@ android:layout_marginStart="16dp" android:background="?attr/selectableItemBackgroundBorderless" android:src="@drawable/ic_action_offset" - app:tint="?attr/webhtvColorOnWallpaper" + app:tint="@color/white_90" android:visibility="gone" tools:visibility="visible" /> - - - - - - - - + app:boxStrokeColor="@color/dialog_outlined_button_stroke" + app:hintTextColor="#5F6368"> + android:textColor="#202124" + android:textColorHint="#8A8F98" /> diff --git a/app/src/mobile/res/layout/dialog_update.xml b/app/src/mobile/res/layout/dialog_update.xml index 1d42ba6bbb..4e2eb24ccb 100644 --- a/app/src/mobile/res/layout/dialog_update.xml +++ b/app/src/mobile/res/layout/dialog_update.xml @@ -23,7 +23,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/update_title" - android:textColor="?attr/colorOnSurface" + android:textColor="#202124" android:textSize="20sp" android:textStyle="bold" /> @@ -35,7 +35,7 @@ android:contentDescription="@string/lut_close" android:padding="8dp" android:src="@drawable/ic_dialog_close" - app:tint="?attr/colorOnSurfaceVariant" /> + app:tint="#5F6368" /> @@ -45,7 +45,7 @@ android:layout_height="wrap_content" android:layout_marginTop="6dp" android:text="@string/update_hint" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textSize="13sp" /> @@ -84,9 +83,8 @@ android:id="@+id/stableName" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:duplicateParentState="true" android:text="@string/update_channel_stable" - android:textColor="@color/update_version_item_title" + android:textColor="#202124" android:textSize="16sp" android:textStyle="bold" /> @@ -101,7 +99,7 @@ android:paddingTop="4dp" android:paddingEnd="8dp" android:paddingBottom="4dp" - android:textColor="?attr/colorPrimary" + android:textColor="#174EA6" android:textSize="12sp" tools:text="可更新" /> @@ -111,9 +109,8 @@ android:layout_height="wrap_content" android:layout_marginStart="10dp" android:layout_weight="1" - android:duplicateParentState="true" android:gravity="end" - android:textColor="@color/update_version_item_accent" + android:textColor="#174EA6" android:textSize="13sp" tools:text="收起" /> @@ -124,9 +121,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="7dp" - android:duplicateParentState="true" android:maxLines="2" - android:textColor="@color/update_version_item_meta" + android:textColor="#5F6368" android:textSize="13sp" tools:text="5.6.0-202608101200" /> @@ -135,7 +131,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" - android:duplicateParentState="true" android:orientation="vertical" tools:visibility="visible"> @@ -143,9 +138,8 @@ android:id="@+id/stableDesc" android:layout_width="match_parent" android:layout_height="wrap_content" - android:duplicateParentState="true" android:lineSpacingExtra="2dp" - android:textColor="@color/update_version_item_title" + android:textColor="#303134" android:textSize="14sp" tools:text="### 更新内容 - 稳定版更新说明" /> @@ -160,9 +154,7 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="14sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" - app:strokeColor="@color/focus_ring_primary_container" - app:strokeWidth="@dimen/webhtv_focus_ring_width" /> + app:cornerRadius="8dp" /> @@ -182,7 +174,6 @@ @@ -190,9 +181,8 @@ android:id="@+id/betaName" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:duplicateParentState="true" android:text="@string/update_channel_beta" - android:textColor="@color/update_version_item_title" + android:textColor="#202124" android:textSize="16sp" android:textStyle="bold" /> @@ -207,7 +197,7 @@ android:paddingTop="4dp" android:paddingEnd="8dp" android:paddingBottom="4dp" - android:textColor="?attr/colorPrimary" + android:textColor="#174EA6" android:textSize="12sp" tools:text="可更新" /> @@ -217,9 +207,8 @@ android:layout_height="wrap_content" android:layout_marginStart="10dp" android:layout_weight="1" - android:duplicateParentState="true" android:gravity="end" - android:textColor="@color/update_version_item_accent" + android:textColor="#174EA6" android:textSize="13sp" tools:text="展开" /> @@ -230,9 +219,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="7dp" - android:duplicateParentState="true" android:maxLines="2" - android:textColor="@color/update_version_item_meta" + android:textColor="#5F6368" android:textSize="13sp" tools:text="5.6.0-beta-202608101200" /> @@ -241,7 +229,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" - android:duplicateParentState="true" android:orientation="vertical" tools:visibility="gone"> @@ -249,9 +236,8 @@ android:id="@+id/betaDesc" android:layout_width="match_parent" android:layout_height="wrap_content" - android:duplicateParentState="true" android:lineSpacingExtra="2dp" - android:textColor="@color/update_version_item_title" + android:textColor="#303134" android:textSize="14sp" tools:text="### 更新内容 - 测试版更新说明" /> @@ -266,9 +252,7 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="14sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" - app:strokeColor="@color/focus_ring_primary_container" - app:strokeWidth="@dimen/webhtv_focus_ring_width" /> + app:cornerRadius="8dp" /> @@ -294,7 +278,7 @@ android:ellipsize="end" android:maxLines="2" android:minLines="2" - android:textColor="?attr/colorOnSurfaceVariant" + android:textColor="#5F6368" android:textSize="13sp" tools:text="正在下载 46% · 3.2 MB/s · 已用 00:12" /> @@ -321,8 +305,6 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="14sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" - app:strokeColor="@color/focus_ring_primary_container" - app:strokeWidth="@dimen/webhtv_focus_ring_width" /> + app:cornerRadius="8dp" /> diff --git a/app/src/mobile/res/layout/dialog_video_content.xml b/app/src/mobile/res/layout/dialog_video_content.xml index 1837b4d409..9e64e16095 100644 --- a/app/src/mobile/res/layout/dialog_video_content.xml +++ b/app/src/mobile/res/layout/dialog_video_content.xml @@ -16,7 +16,7 @@ android:maxLines="1" android:paddingHorizontal="24dp" android:text="@string/detail_content" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" /> @@ -38,7 +38,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:lineSpacingExtra="5dp" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white_90" android:textSize="15sp" tools:text="一個身世神秘的少年,因對自身身世的好奇而離開故鄉,前赴京都。" /> diff --git a/app/src/mobile/res/layout/fragment_setting.xml b/app/src/mobile/res/layout/fragment_setting.xml index 1523dd131d..50f542e022 100644 --- a/app/src/mobile/res/layout/fragment_setting.xml +++ b/app/src/mobile/res/layout/fragment_setting.xml @@ -47,7 +47,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_vod" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -103,7 +103,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_live" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -159,7 +159,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_wall" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -206,7 +206,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_enhance" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -222,7 +222,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_tmdb" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -238,7 +238,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_ai" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -255,7 +255,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_personal" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -272,7 +272,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_player" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -290,7 +290,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_ad" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -317,7 +317,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_danmaku" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -334,13 +334,13 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_subtitle" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> + + + + + + + + + + + + + + + + + + + + + + + + @@ -433,7 +514,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_cache" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -466,7 +547,7 @@ android:paddingEnd="12dp" android:paddingBottom="8dp" android:text="@string/setting_backup" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -499,7 +580,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_about" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_ad.xml b/app/src/mobile/res/layout/fragment_setting_ad.xml index 549b4ba3ff..a9ed90f5ed 100644 --- a/app/src/mobile/res/layout/fragment_setting_ad.xml +++ b/app/src/mobile/res/layout/fragment_setting_ad.xml @@ -29,7 +29,7 @@ android:paddingEnd="16dp" android:paddingBottom="16dp"> - + @@ -75,7 +75,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_ai_ad_detection" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> - + @@ -134,12 +134,246 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/ad_block_stats" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - diff --git a/app/src/mobile/res/layout/fragment_setting_ai.xml b/app/src/mobile/res/layout/fragment_setting_ai.xml index 2147038359..c1c5d9eb65 100644 --- a/app/src/mobile/res/layout/fragment_setting_ai.xml +++ b/app/src/mobile/res/layout/fragment_setting_ai.xml @@ -33,7 +33,7 @@ - + @@ -73,7 +73,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_personal_recommendation" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -99,7 +99,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_recommendation_feedback" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -135,7 +135,7 @@ - + @@ -185,7 +185,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_ai_max_concurrency" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -212,7 +212,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_ai_chunk_count" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_danmaku.xml b/app/src/mobile/res/layout/fragment_setting_danmaku.xml index 0531ed42d2..cbcd60c967 100644 --- a/app/src/mobile/res/layout/fragment_setting_danmaku.xml +++ b/app/src/mobile/res/layout/fragment_setting_danmaku.xml @@ -40,7 +40,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_load" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -69,7 +69,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_api" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -100,7 +100,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_auto_load" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -129,7 +129,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_spider_first" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_enhance.xml b/app/src/mobile/res/layout/fragment_setting_enhance.xml index a516bc88cf..1e40dd2527 100644 --- a/app/src/mobile/res/layout/fragment_setting_enhance.xml +++ b/app/src/mobile/res/layout/fragment_setting_enhance.xml @@ -43,7 +43,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_lab" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -70,7 +70,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_drive_check" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -97,7 +97,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_site_name" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -124,7 +124,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_local_reader" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -151,7 +151,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_novel_source" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -178,7 +178,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_comic_source" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -205,7 +205,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_audio_source" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -232,7 +232,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_short_drama_source" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -249,32 +249,6 @@ - - - - - - - - @@ -315,7 +289,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_site_health_sort" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -342,7 +316,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_manage_page" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -369,7 +343,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_remote_trust" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -398,7 +372,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_git_cloud" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -427,7 +401,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_shell_proxy" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -455,7 +429,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_shell_proxy_config" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -484,7 +458,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_custom_csp" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -513,7 +487,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_extension" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -540,7 +514,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_theme" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -569,7 +543,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_fullscreen" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -596,7 +570,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_csp_warmup" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -623,7 +597,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_playback_artwork_wall" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -650,7 +624,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_viewing_record_sync" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -677,7 +651,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_login_state" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -706,7 +680,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_one_key_sync" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_personal.xml b/app/src/mobile/res/layout/fragment_setting_personal.xml index e1a4ffc136..08d8e11a24 100644 --- a/app/src/mobile/res/layout/fragment_setting_personal.xml +++ b/app/src/mobile/res/layout/fragment_setting_personal.xml @@ -39,7 +39,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_thread" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> - - - - - - - - - - - - - - - - @@ -174,7 +120,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_episode_history" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -201,7 +147,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_global_history" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> - - - - - - - - @@ -256,7 +175,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_play_speed" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -286,7 +205,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_group_rule" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -314,7 +233,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_ui" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -341,7 +260,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_column" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -368,7 +287,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_site_column" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -395,7 +314,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_result_sort" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -422,7 +341,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_reset_app" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -439,7 +358,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_app_branding" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> + diff --git a/app/src/mobile/res/layout/fragment_setting_player.xml b/app/src/mobile/res/layout/fragment_setting_player.xml index 3e92839d87..3fb7c6afe5 100644 --- a/app/src/mobile/res/layout/fragment_setting_player.xml +++ b/app/src/mobile/res/layout/fragment_setting_player.xml @@ -40,7 +40,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_render" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -67,7 +67,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_button_config" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -96,7 +96,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_pad_live" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -125,7 +125,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_exo_4k_compat" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -154,7 +154,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_kernel" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -182,7 +182,7 @@ android:layout_marginEnd="16dp" android:singleLine="true" android:text="@string/player_mpv_config" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -212,7 +212,7 @@ android:layout_marginEnd="16dp" android:singleLine="true" android:text="@string/player_mpv_render" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> - - - - - - - - - @@ -295,7 +268,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_lut" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -324,7 +297,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_caption" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -353,7 +326,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_osd" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> - - - - - - - - @@ -429,7 +373,7 @@ android:layout_height="wrap_content" android:layout_marginStart="4dp" android:text="@string/times" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -447,7 +391,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_buffer_bytes" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -476,7 +420,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_back_buffer" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -505,7 +449,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_cache" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -534,7 +478,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -563,7 +507,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_threads" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -592,7 +536,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_size" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -621,7 +565,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_time" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -650,7 +594,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_ahead" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -679,7 +623,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_pause" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -708,7 +652,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_auto_play" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -737,7 +681,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_auto_change" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> - - - - - - - - - - - - - - - - @@ -882,7 +768,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_speed" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -900,7 +786,7 @@ android:layout_height="wrap_content" android:layout_marginStart="4dp" android:text="@string/times" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -918,7 +804,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_tunnel" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -945,7 +831,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_music_notification" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -972,7 +858,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_book_notification" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -999,7 +885,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_decode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -1026,7 +912,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_passthrough" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -1053,7 +939,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_video_decode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> + + + + + + + + @@ -1107,7 +1020,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_background" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -1134,7 +1047,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_ua" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_subtitle.xml b/app/src/mobile/res/layout/fragment_setting_subtitle.xml index 76e98d7e94..4b4941a6ef 100644 --- a/app/src/mobile/res/layout/fragment_setting_subtitle.xml +++ b/app/src/mobile/res/layout/fragment_setting_subtitle.xml @@ -39,7 +39,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_auto_match" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -66,7 +66,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_language" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -94,8 +94,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" - android:text="@string/player_subtitle_source" - android:textColor="?attr/webhtvColorOnWallpaper" + android:text="@string/player_subtitle_assrt_token" + android:textColor="@color/white" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_tmdb.xml b/app/src/mobile/res/layout/fragment_setting_tmdb.xml index 94a07191d6..1185149443 100644 --- a/app/src/mobile/res/layout/fragment_setting_tmdb.xml +++ b/app/src/mobile/res/layout/fragment_setting_tmdb.xml @@ -45,7 +45,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_source" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -75,7 +75,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_detail_open_mode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -103,7 +103,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_detail_theme_mode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -132,7 +132,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_match_mode" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -159,7 +159,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_episode_file_size" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> @@ -187,7 +187,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_history_aggregation" - android:textColor="?attr/webhtvColorOnWallpaper" + android:textColor="@color/white" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_vod.xml b/app/src/mobile/res/layout/fragment_vod.xml index 820f239cdb..2fede0e64c 100644 --- a/app/src/mobile/res/layout/fragment_vod.xml +++ b/app/src/mobile/res/layout/fragment_vod.xml @@ -104,23 +104,6 @@ layout="@layout/view_progress" android:visibility="gone" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/mobile/res/layout/view_control_live.xml b/app/src/mobile/res/layout/view_control_live.xml index a2f5de9bdb..6838c6469b 100644 --- a/app/src/mobile/res/layout/view_control_live.xml +++ b/app/src/mobile/res/layout/view_control_live.xml @@ -3,7 +3,6 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:id="@+id/playerControlRoot" android:background="@color/black_20"> - - diff --git a/app/src/mobile/res/layout/view_control_live_action.xml b/app/src/mobile/res/layout/view_control_live_action.xml index 9af2379731..5fe9dbd75d 100644 --- a/app/src/mobile/res/layout/view_control_live_action.xml +++ b/app/src/mobile/res/layout/view_control_live_action.xml @@ -8,9 +8,7 @@ android:paddingEnd="8dp" android:scrollbars="none"> - @@ -23,9 +21,6 @@ - - @@ -119,44 +118,6 @@ android:scaleType="center" android:src="@drawable/ic_control_setting" /> - - - - - - - - - - - - - + android:textColor="@color/white" + android:textSize="16sp" /> \ No newline at end of file diff --git a/app/src/mobile/res/layout/view_progress.xml b/app/src/mobile/res/layout/view_progress.xml index 7c0ccf11da..b8a49513ba 100644 --- a/app/src/mobile/res/layout/view_progress.xml +++ b/app/src/mobile/res/layout/view_progress.xml @@ -5,17 +5,12 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + app:indicatorColor="@color/white" /> - - - - - - - - + tools:text="@string/error_play_url" + tools:visibility="visible" /> + android:visible="false" /> - - + android:visible="false" /> - + \ No newline at end of file diff --git a/app/src/mobile/res/values-zh-rCN/strings.xml b/app/src/mobile/res/values-zh-rCN/strings.xml index d7f26a9e85..d6cfc2ad0c 100644 --- a/app/src/mobile/res/values-zh-rCN/strings.xml +++ b/app/src/mobile/res/values-zh-rCN/strings.xml @@ -28,19 +28,6 @@ 无法读取所选APK 存储空间不足,无法准备APK 接收设备不支持APK推送,请先更新WebHTV - 推送 APK - 接收设备:%1$s - 选择本地 APK - 通过链接推送 - 链接推送 APK · %1$s - HTTPS APK 链接 - 发送链接 - 请输入 HTTPS APK 链接 - 请输入不含账号密码的有效 HTTPS APK 链接 - 链接已发送,电视正在下载 APK - 接收设备不支持链接推送 APK,请先更新 WebHTV - APK 链接推送失败 - APK 链接推送失败:%1$s 推送播放 · 选择接收设备 推送播放 · %1$s 请输入播放地址 diff --git a/app/src/mobile/res/values-zh-rTW/strings.xml b/app/src/mobile/res/values-zh-rTW/strings.xml index a11a8089fb..2f201ac33e 100644 --- a/app/src/mobile/res/values-zh-rTW/strings.xml +++ b/app/src/mobile/res/values-zh-rTW/strings.xml @@ -28,19 +28,6 @@ 無法讀取所選APK 儲存空間不足,無法準備APK 接收裝置不支援APK推送,請先更新WebHTV - 推送 APK - 接收裝置:%1$s - 選擇本機 APK - 透過連結推送 - 連結推送 APK · %1$s - HTTPS APK 連結 - 傳送連結 - 請輸入 HTTPS APK 連結 - 請輸入不含帳號密碼的有效 HTTPS APK 連結 - 連結已傳送,電視正在下載 APK - 接收裝置不支援連結推送 APK,請先更新 WebHTV - APK 連結推送失敗 - APK 連結推送失敗:%1$s 推送播放 · 選擇接收裝置 推送播放 · %1$s 請輸入播放地址 diff --git a/app/src/mobile/res/values/following_fractions.xml b/app/src/mobile/res/values/integers.xml similarity index 52% rename from app/src/mobile/res/values/following_fractions.xml rename to app/src/mobile/res/values/integers.xml index c10e263500..96a75d0ef5 100644 --- a/app/src/mobile/res/values/following_fractions.xml +++ b/app/src/mobile/res/values/integers.xml @@ -1,4 +1,4 @@ - 30% + 0 diff --git a/app/src/mobile/res/values/strings.xml b/app/src/mobile/res/values/strings.xml index 084550a3e3..93cbfc5858 100644 --- a/app/src/mobile/res/values/strings.xml +++ b/app/src/mobile/res/values/strings.xml @@ -28,19 +28,6 @@ Unable to read the selected APK Not enough storage to prepare the APK The receiving device does not support APK push. Update WebHTV first. - Push APK - Target: %1$s - Choose local APK - Push by link - Push APK link · %1$s - HTTPS APK link - Send link - Enter an HTTPS APK link - Enter a valid HTTPS APK link without credentials - Link sent. The TV is downloading the APK. - The receiving device does not support APK link push. Update WebHTV first. - APK link push failed - APK link push failed: %1$s Push playback · Select receiving device Push playback · %1$s Enter a playback URL diff --git a/app/src/mobile/res/values/styles.xml b/app/src/mobile/res/values/styles.xml index 857003980d..51e0354007 100644 --- a/app/src/mobile/res/values/styles.xml +++ b/app/src/mobile/res/values/styles.xml @@ -1,18 +1,6 @@ - @@ -43,7 +31,7 @@ wrap_content 13dp 7dp - @color/webhtv_color_player_control_muted + @color/white_70 13sp bold @@ -74,7 +62,7 @@ 0 2 4 - @color/webhtv_color_player_control + @color/white 12sp @@ -87,7 +75,7 @@ center 3dp 3dp - @color/webhtv_color_player_control_muted + @color/white_80 11sp diff --git a/app/src/test/java/androidx/media3/extractor/Avs3ExtractionTest.java b/app/src/test/java/androidx/media3/extractor/Avs3ExtractionTest.java deleted file mode 100644 index 21b8151cdb..0000000000 --- a/app/src/test/java/androidx/media3/extractor/Avs3ExtractionTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package androidx.media3.extractor; - -import static org.junit.Assert.*; - -import androidx.media3.common.C; -import androidx.media3.common.DataReader; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.ParserException; -import androidx.media3.common.util.ParsableByteArray; -import androidx.media3.extractor.ts.Avs3Reader; -import androidx.media3.extractor.ts.TsPayloadReader; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import org.junit.Test; - -/** Runs against the shipped extractor classes and independent encoder fixtures. */ -public class Avs3ExtractionTest { - private static byte[] fixture(int depth) throws IOException { - Path name = Path.of("baseline-" + depth + ".avs3"); - String configured = System.getProperty("avs3.fixtures"); - if (configured != null && !configured.isBlank()) { - Path path = Path.of(configured).resolve(name); - if (Files.exists(path)) return Files.readAllBytes(path); - } - Path appRelative = Path.of("app/src/test/java/androidx/media3/extractor/fixtures").resolve(name); - Path moduleRelative = Path.of("src/test/java/androidx/media3/extractor/fixtures").resolve(name); - return Files.readAllBytes(Files.exists(appRelative) ? appRelative : moduleRelative); - } - - private static byte[] sequence(byte[] stream) { - for (int i = 4; i + 3 < stream.length; i++) { - if (stream[i] == 0 && stream[i + 1] == 0 && stream[i + 2] == 1 - && (stream[i + 3] & 0xff) == 0xb3) return Arrays.copyOf(stream, i); - } - throw new AssertionError("Fixture has no I-picture"); - } - - @Test public void baselineDepthsAndMp4Configuration() throws Exception { - for (int depth : new int[] {8, 10}) { - byte[] raw = sequence(fixture(depth)); - byte[] av3c = new byte[raw.length + 4]; - av3c[0] = 1; - av3c[1] = (byte) (raw.length >>> 8); - av3c[2] = (byte) raw.length; - System.arraycopy(raw, 0, av3c, 3, raw.length); - for (byte[] input : new byte[][] {raw, av3c}) { - Avs3Config config = Avs3Config.parse(input); - assertEquals(256, config.width); - assertEquals(144, config.height); - assertEquals(depth, config.bitdepth); - assertEquals(25, config.frameRate, 0.0001); - assertArrayEquals(raw, config.initializationData.get(0)); - assertEquals(MimeTypes.VIDEO_AVS3, MimeTypes.getMediaMimeType(config.codecs)); - } - } - assertEquals(MimeTypes.AUDIO_AV3A, MimeTypes.getMediaMimeType("av3a.1")); - assertEquals(MimeTypes.VIDEO_H264, MimeTypes.getMediaMimeType("avc1.640028")); - } - - @Test public void rejectsTruncatedAndMalformedConfiguration() throws Exception { - byte[] header = sequence(fixture(10)); - for (int size = 0; size < 13; size++) { - byte[] truncated = Arrays.copyOf(header, size); - assertThrows(ParserException.class, () -> Avs3Config.parse(truncated)); - } - assertThrows(ParserException.class, () -> Avs3Config.parse(new byte[] {1, 127, -1, 0})); - header[3] = (byte) 0xb3; - assertThrows(ParserException.class, () -> Avs3Config.parse(header)); - } - - @Test public void streamFramingSurvivesEveryStartCodeSplitAndSeek() throws Exception { - byte[] bytes = fixture(10); - for (int chunk : new int[] {1, 2, 3, 5, 188, 4096}) { - Avs3Reader reader = new Avs3Reader(); - Capture capture = new Capture(); - reader.createTracks(capture, new TsPayloadReader.TrackIdGenerator(0, 1)); - for (int pass = 0; pass < 2; pass++) { - capture.clearSamples(); - reader.seek(); - reader.packetStarted(1_000_000, 0); - for (int pos = 0; pos < bytes.length; pos += chunk) { - reader.consume(new ParsableByteArray(Arrays.copyOfRange(bytes, pos, Math.min(bytes.length, pos + chunk)))); - } - reader.endOfInputReached(); - reader.endOfInputReached(); // EOS must not duplicate the delayed final sample. - assertNotNull(capture.format); - assertEquals(MimeTypes.VIDEO_AVS3, capture.format.sampleMimeType); - assertEquals(16, capture.samples.size()); - assertArrayEquals(bytes, capture.joinSamples()); - assertEquals(1_000_000L, (long) capture.times.get(0)); - assertEquals(1_600_000L, (long) capture.times.get(15)); - assertTrue((capture.flags.get(0) & C.BUFFER_FLAG_KEY_FRAME) != 0); - } - } - } - - private static final class Capture implements ExtractorOutput, TrackOutput { - Format format; - ByteArrayOutputStream written = new ByteArrayOutputStream(); - final List samples = new ArrayList<>(); - final List times = new ArrayList<>(); - final List flags = new ArrayList<>(); - public TrackOutput track(int id, int type) { assertEquals(C.TRACK_TYPE_VIDEO, type); return this; } - public void endTracks() {} - public void seekMap(SeekMap map) {} - public void format(Format value) { format = value; } - public int sampleData(DataReader input, int length, boolean allowEndOfInput, int part) throws IOException { - byte[] bytes = new byte[length]; - int count = input.read(bytes, 0, length); - if (count > 0) written.write(bytes, 0, count); - return count; - } - public void sampleData(ParsableByteArray data, int length, int part) { - written.write(data.getData(), data.getPosition(), length); - data.skipBytes(length); - } - public void sampleMetadata(long timeUs, int sampleFlags, int size, int offset, CryptoData crypto) { - byte[] bytes = written.toByteArray(); - int end = bytes.length - offset; - samples.add(Arrays.copyOfRange(bytes, end - size, end)); - times.add(timeUs); - flags.add(sampleFlags); - } - void clearSamples() { written.reset(); samples.clear(); times.clear(); flags.clear(); } - byte[] joinSamples() throws IOException { - ByteArrayOutputStream all = new ByteArrayOutputStream(); - for (byte[] bytes : samples) all.write(bytes); - return all.toByteArray(); - } - } -} diff --git a/app/src/test/java/androidx/media3/extractor/fixtures/baseline-10.avs3 b/app/src/test/java/androidx/media3/extractor/fixtures/baseline-10.avs3 deleted file mode 100644 index 2ab1ea2847..0000000000 Binary files a/app/src/test/java/androidx/media3/extractor/fixtures/baseline-10.avs3 and /dev/null differ diff --git a/app/src/test/java/androidx/media3/extractor/fixtures/baseline-8.avs3 b/app/src/test/java/androidx/media3/extractor/fixtures/baseline-8.avs3 deleted file mode 100644 index 3dc19a2217..0000000000 Binary files a/app/src/test/java/androidx/media3/extractor/fixtures/baseline-8.avs3 and /dev/null differ diff --git a/app/src/test/java/androidx/media3/extractor/fixtures/manifest.json b/app/src/test/java/androidx/media3/extractor/fixtures/manifest.json deleted file mode 100644 index 8a8ea4d111..0000000000 --- a/app/src/test/java/androidx/media3/extractor/fixtures/manifest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "source": "uavs3/uavs3e", - "revision": "e1ff0f37a8d67814e1d650f6b49d495c49dde946", - "description": "Synthetic gradients; macOS COMPILE_10BIT guard fixed; one encoder thread. Decoder uses two threads.", - "width": 256, - "height": 144, - "frames": 16, - "fps": 25, - "streams": { - "8": { - "sha256": "d0dbd57f983ac27cae2ff70376e22bcc8275a747fd6bc0452b404a124af71cb4", - "decoded_sha256": "4af75530b1dc15dd41f1ec2e47f6ad64deb0fddf9f6b46bd772dba6f65589ecb", - "decoded_bytes": 884736 - }, - "10": { - "sha256": "6fb9cfddb8645942b9883304cc5f783da8368d139715dc1e09cd88068f994df7", - "decoded_sha256": "745e6cefa14496131199293c118bbf664ac0a1f07dd8333ffd6d36d9e2e39cc4", - "decoded_bytes": 1769472 - } - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/HlsAdTimelineTest.java b/app/src/test/java/androidx/media3/mpvplayer/HlsAdTimelineTest.java deleted file mode 100644 index 8c212a7638..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/HlsAdTimelineTest.java +++ /dev/null @@ -1,183 +0,0 @@ -package androidx.media3.mpvplayer; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.util.List; - -public class HlsAdTimelineTest { - - static String playlist(String... entries) { - return "#EXTM3U\n#EXT-X-TARGETDURATION:10\n" - + String.join("", entries) + "#EXT-X-ENDLIST\n"; - } - - static String segment(String uri, String duration) { - return "#EXTINF:" + duration + ",\n" + uri + "\n"; - } - - static HlsAdTimeline middleAd(String duration) { - return HlsAdTimeline.from( - playlist(segment("main/0.ts", "4"), segment("ad/0.ts", duration), - segment("main/1.ts", "6")), - playlist(segment("main/0.ts", "4"), segment("main/1.ts", "6"))); - } - - @Test - public void removedSegmentsUseSourceTimeAndAdjacentAdsMerge() { - HlsAdTimeline timeline = HlsAdTimeline.from( - playlist(segment("main/0.ts", "4"), segment("ads/0.ts", "1.2"), - segment("ads/1.ts", "2.3"), segment("main/1.ts", "6")), - playlist(segment("main/0.ts", "4"), segment("main/1.ts", "6"))); - assertEquals(List.of(new HlsAdTimeline.Range(4000, 7500)), timeline.ranges()); - assertEquals(3999, timeline.skipTargetMs(3999)); - assertEquals(7500, timeline.skipTargetMs(4000)); - assertEquals(7500, timeline.skipTargetMs(7499)); - assertEquals(7500, timeline.skipTargetMs(7500)); - assertEquals(12000, timeline.skipTargetMs(12000)); - } - - @Test - public void leadingAndTrailingAdsKeepDistinctSourceRanges() { - HlsAdTimeline timeline = HlsAdTimeline.from( - playlist(segment("ad/start.ts", "2"), segment("main.ts", "9"), - segment("ad/end.ts", "3")), - playlist(segment("main.ts", "9"))); - assertEquals(List.of(new HlsAdTimeline.Range(0, 2000), - new HlsAdTimeline.Range(11000, 14000)), timeline.ranges()); - assertEquals(2000, timeline.skipTargetMs(0)); - assertEquals(14000, timeline.skipTargetMs(13000)); - } - - @Test - public void roundingDoesNotSkipProgrammeAtSubMillisecondBoundaries() { - HlsAdTimeline timeline = HlsAdTimeline.from( - playlist(segment("main/0", "1.000001"), segment("ad", "0.003998"), - segment("main/1", "1")), - playlist(segment("main/0", "1.000001"), segment("main/1", "1"))); - assertEquals(List.of(new HlsAdTimeline.Range(1001, 1003)), timeline.ranges()); - assertEquals(1000, timeline.skipTargetMs(1000)); - assertEquals(1003, timeline.skipTargetMs(1001)); - } - - @Test - public void rejectsAmbiguousDuplicateOccurrences() { - HlsAdTimeline timeline = HlsAdTimeline.from( - playlist(segment("same.ts", "2"), segment("same.ts", "2"), - segment("tail.ts", "5")), - playlist(segment("same.ts", "2"), segment("tail.ts", "5"))); - assertTrue(timeline.ranges().isEmpty()); - assertEquals("ambiguous-segments", timeline.reason()); - } - - @Test - public void repeatedRetainedSegmentsCanStillHaveAnUnambiguousMapping() { - HlsAdTimeline timeline = HlsAdTimeline.from( - playlist(segment("same.ts", "2"), segment("same.ts", "2"), - segment("ads.ts", "1"), segment("tail.ts", "5")), - playlist(segment("same.ts", "2"), segment("same.ts", "2"), - segment("tail.ts", "5"))); - assertEquals(List.of(new HlsAdTimeline.Range(4000, 5000)), timeline.ranges()); - } - - @Test - public void byteRangeIdentitySeparatesSegmentsUsingTheSameUrl() { - String first = "#EXTINF:2,\n#EXT-X-BYTERANGE:100@0\nvideo.mp4\n"; - String ad = "#EXTINF:2,\n#EXT-X-BYTERANGE:100@100\nvideo.mp4\n"; - String last = "#EXTINF:2,\n#EXT-X-BYTERANGE:100@200\nvideo.mp4\n"; - HlsAdTimeline timeline = HlsAdTimeline.from(playlist(first, ad, last), - playlist(first, last)); - assertEquals(List.of(new HlsAdTimeline.Range(2000, 4000)), timeline.ranges()); - } - - @Test - public void keyRotationAndDiscontinuityMetadataDoNotChangeSourcePositions() { - String first = segment("body/0.ts", "4"); - String last = segment("body/1.ts", "6"); - String key = "#EXT-X-MEDIA-SEQUENCE:37\n#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\"\n"; - HlsAdTimeline timeline = HlsAdTimeline.from( - playlist(key, first, "#EXT-X-DISCONTINUITY\n", - segment("ad/0.ts", "2"), "#EXT-X-KEY:METHOD=NONE\n", last), - playlist(key, first, "#EXT-X-KEY:METHOD=NONE\n", last)); - assertEquals(List.of(new HlsAdTimeline.Range(4000, 6000)), timeline.ranges()); - } - - @Test - public void longFalsePositiveBeforeAndAfterAnAdRemainsSeekable() { - String tail = segment("retained.ts", "900"); - HlsAdTimeline timeline = HlsAdTimeline.from( - playlist(segment("programme-before.ts", "496.32"), - "#EXT-X-DISCONTINUITY\n", segment("ad.ts", "16.466666"), - "#EXT-X-DISCONTINUITY\n", segment("programme-after.ts", "800"), - tail), playlist(tail)); - assertEquals(List.of(new HlsAdTimeline.Range(496320, 512786)), timeline.ranges()); - assertEquals(0, timeline.skipTargetMs(0)); - assertEquals(490000, timeline.skipTargetMs(490000)); - assertEquals(512786, timeline.skipTargetMs(500000)); - assertEquals(700000, timeline.skipTargetMs(700000)); - } - - @Test - public void uninterruptedLongCandidateIsPreservedAndLimitIsInclusive() { - String kept = segment("body.ts", "1000"); - assertTrue(HlsAdTimeline.from( - playlist(segment("uncertain.ts", "120.000001"), kept), - playlist(kept)).ranges().isEmpty()); - assertEquals(List.of(new HlsAdTimeline.Range(0, 120000)), HlsAdTimeline.from( - playlist(segment("ad.ts", "120"), kept), playlist(kept)).ranges()); - } - - @Test - public void acceptedAdjacentDiscontinuityBlocksStillMerge() { - String kept = segment("body.ts", "1000"); - HlsAdTimeline timeline = HlsAdTimeline.from( - playlist(segment("ad-1.ts", "10"), "#EXT-X-DISCONTINUITY\n", - segment("ad-2.ts", "15"), kept), playlist(kept)); - assertEquals(List.of(new HlsAdTimeline.Range(0, 25000)), timeline.ranges()); - } - - @Test - public void rejectsLiveMalformedNonFiniteAndOverflowingDurations() { - String filtered = playlist(segment("body.ts", "5")); - for (String duration : List.of("NaN", "Infinity", "-1", "0", "1e100", - "1e1000000000", "1e-1000000000")) { - assertTrue(HlsAdTimeline.from( - playlist(segment("bad.ts", duration), segment("body.ts", "5")), - filtered).ranges().isEmpty()); - } - String original = playlist(segment("ad.ts", "2"), segment("body.ts", "5")); - assertTrue(HlsAdTimeline.from(original.replace("#EXT-X-ENDLIST", ""), filtered) - .ranges().isEmpty()); - assertTrue(HlsAdTimeline.from(original, "#EXTM3U\n#EXT-X-ENDLIST\n") - .ranges().isEmpty()); - assertTrue(HlsAdTimeline.from(original, null).ranges().isEmpty()); - assertTrue(HlsAdTimeline.from(null, filtered).ranges().isEmpty()); - } - - @Test - public void rejectsReorderingOrChangingRetainedMedia() { - String a = segment("a.ts", "3"); - String b = segment("b.ts", "4"); - String original = playlist(a, segment("ad.ts", "1"), b); - assertTrue(HlsAdTimeline.from(original, playlist(b, a)).ranges().isEmpty()); - assertTrue(HlsAdTimeline.from(original, playlist(segment("a.ts", "2"), b)) - .ranges().isEmpty()); - assertTrue(HlsAdTimeline.from(original, original).ranges().isEmpty()); - } - - @Test - public void stalePositionsCannotRepeatSeekAndManualOrMediaResetAllowsReplay() { - HlsAdTimeline timeline = middleAd("2"); - HlsAdTimeline.SkipState state = new HlsAdTimeline.SkipState(); - assertEquals(-1, state.nextTargetMs(timeline, 3999)); - assertEquals(6000, state.nextTargetMs(timeline, 4000)); - assertEquals(-1, state.nextTargetMs(timeline, 4200)); - assertEquals(-1, state.nextTargetMs(middleAd("2"), 4500)); - assertEquals(-1, state.nextTargetMs(timeline, 6000)); - state.clear(); - assertEquals(6000, state.nextTargetMs(timeline, 5000)); - assertEquals(-1, state.nextTargetMs(HlsAdTimeline.NONE, 5000)); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/HlsPlaylistRewriterTest.java b/app/src/test/java/androidx/media3/mpvplayer/HlsPlaylistRewriterTest.java index 8569123552..56d2417d08 100644 --- a/app/src/test/java/androidx/media3/mpvplayer/HlsPlaylistRewriterTest.java +++ b/app/src/test/java/androidx/media3/mpvplayer/HlsPlaylistRewriterTest.java @@ -7,8 +7,6 @@ import java.util.List; 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; public class HlsPlaylistRewriterTest { @@ -215,88 +213,6 @@ public class HlsPlaylistRewriterTest { assertTrue(result.mediaUnits().isEmpty()); } - @Test - public void stripsEncryptionKeyAndNumbersSegmentsFromMediaSequence() { - String playlist = """ - #EXTM3U - #EXT-X-VERSION:3 - #EXT-X-TARGETDURATION:6 - #EXT-X-MEDIA-SEQUENCE:5 - #EXT-X-KEY:METHOD=AES-128,URI="key.bin" - #EXTINF:4.5, - first.ts - #EXTINF:6, - second.ts - #EXT-X-ENDLIST - """; - List mappings = new ArrayList<>(); - - HlsPlaylistRewriter.Result result = HlsPlaylistRewriter.rewrite(playlist, null, (uri, cacheable, context) -> { - mappings.add(new Mapping(uri, cacheable, context)); - return new HlsPlaylistRewriter.MappedUri("source:" + uri, "proxy:" + uri); - }, true); - - assertFalse(result.text().contains("#EXT-X-KEY")); - assertTrue(result.text().contains("#EXT-X-MEDIA-SEQUENCE:5")); - assertEquals(2, result.segments().size()); - HlsPlaylistRewriter.UriContext first = mappings.stream() - .filter(m -> m.context().role() == HlsPlaylistRewriter.UriRole.MEDIA_SEGMENT) - .findFirst().orElseThrow().context(); - assertEquals(5, first.mediaSequence()); - } - - @Test - public void keepsEncryptionKeyRewrittenWhenNotStripping() { - String playlist = """ - #EXTM3U - #EXT-X-KEY:METHOD=AES-128,URI="key.bin" - #EXTINF:4.5, - first.ts - #EXT-X-ENDLIST - """; - List mappings = new ArrayList<>(); - - HlsPlaylistRewriter.Result result = rewrite(playlist, null, mappings); - - assertTrue(result.text().contains("#EXT-X-KEY")); - assertTrue(result.text().contains("URI=\"proxy:key.bin\"")); - } - - @Test - public void findsAes128KeyAndKeepsCommaInsideQuotedUri() { - String playlist = """ - #EXTM3U - #EXT-X-KEY:METHOD=AES-128,URI="data:text/plain;base64,c2VjcmV0",IV=0x000102030405060708090a0b0c0d0e0f - #EXTINF:4.5, - first.ts - """; - - HlsPlaylistRewriter.EncryptionKey key = HlsPlaylistRewriter.findAes128Key(playlist); - - assertNotNull(key); - assertEquals("AES-128", key.method()); - assertEquals("data:text/plain;base64,c2VjcmV0", key.uri()); - assertNotNull(key.explicitIv()); - assertEquals(16, key.explicitIv().length); - assertEquals(0, key.explicitIv()[0]); - assertEquals(0x0f, key.explicitIv()[15]); - } - - @Test - public void ignoresSampleAesAndKeylessPlaylists() { - assertNull(HlsPlaylistRewriter.findAes128Key(""" - #EXTM3U - #EXT-X-KEY:METHOD=SAMPLE-AES,URI="key.bin" - #EXTINF:4.5, - first.ts - """)); - assertNull(HlsPlaylistRewriter.findAes128Key(""" - #EXTM3U - #EXTINF:4.5, - first.ts - """)); - } - private static HlsPlaylistRewriter.Result rewrite(String playlist, HlsPlaylistRewriter.Variant inherited, List mappings) { return HlsPlaylistRewriter.rewrite(playlist, inherited, (uri, cacheable, context) -> { mappings.add(new Mapping(uri, cacheable, context)); diff --git a/app/src/test/java/androidx/media3/mpvplayer/HlsSegmentDecryptorTest.java b/app/src/test/java/androidx/media3/mpvplayer/HlsSegmentDecryptorTest.java deleted file mode 100644 index 9ce6bc73af..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/HlsSegmentDecryptorTest.java +++ /dev/null @@ -1,99 +0,0 @@ -package androidx.media3.mpvplayer; - -import org.junit.Test; - -import javax.crypto.Cipher; -import javax.crypto.spec.IvParameterSpec; -import javax.crypto.spec.SecretKeySpec; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; - -public class HlsSegmentDecryptorTest { - - private static final byte[] KEY = { - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; - - @Test - public void decryptsAes128CbcWithPkcs7Padding() throws Exception { - byte[] iv = HlsSegmentDecryptor.sequenceIv(7); - byte[] plain = "0123456789abcdef0123456789abcdef-payload".getBytes("UTF-8"); - byte[] ciphertext = encrypt(KEY, iv, plain); - - byte[] decrypted = HlsSegmentDecryptor.decrypt(KEY, iv, ciphertext); - - assertNotNull(decrypted); - assertArrayEquals(plain, decrypted); - } - - @Test - public void sequenceIvIsSixteenByteBigEndian() { - byte[] iv = HlsSegmentDecryptor.sequenceIv(1); - - assertEquals(16, iv.length); - for (int i = 0; i < 15; i++) assertEquals(0, iv[i]); - assertEquals(1, iv[15]); - } - - @Test - public void sequenceIvUsesLowBytesBeyondOneByte() { - byte[] iv = HlsSegmentDecryptor.sequenceIv(0x0102L); - - assertEquals(0, iv[13]); - assertEquals(0x01, iv[14]); - assertEquals(0x02, iv[15]); - } - - @Test - public void rejectsCiphertextThatIsNotWholeBlocks() { - byte[] iv = HlsSegmentDecryptor.sequenceIv(0); - - assertNull(HlsSegmentDecryptor.decrypt(KEY, iv, new byte[]{1, 2, 3})); - assertNull(HlsSegmentDecryptor.decrypt(KEY, iv, new byte[0])); - } - - @Test - public void rejectsWrongKeyLength() { - byte[] iv = HlsSegmentDecryptor.sequenceIv(0); - - assertNull(HlsSegmentDecryptor.decrypt(new byte[8], iv, new byte[16])); - } - - @Test - public void stripsFullBlockOfPadding() { - byte[] plain = new byte[16]; - java.util.Arrays.fill(plain, (byte) 16); - - byte[] stripped = HlsSegmentDecryptor.stripPkcs7(plain); - - assertNotNull(stripped); - assertEquals(0, stripped.length); - } - - @Test - public void keepsUnpaddedDataWhenTrailingByteIsNotValidPadding() { - byte[] data = {0x01, 0x02, 0x03, 0x00}; - - byte[] stripped = HlsSegmentDecryptor.stripPkcs7(data); - - assertArrayEquals(data, stripped); - } - - @Test - public void keepsDataWhenPaddingIsInvalid() { - byte[] data = {0x01, 0x02, 0x03, 0x04}; - - byte[] stripped = HlsSegmentDecryptor.stripPkcs7(data); - - assertArrayEquals(data, stripped); - } - - private static byte[] encrypt(byte[] key, byte[] iv, byte[] plain) throws Exception { - Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); - cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv)); - return cipher.doFinal(plain); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvAudioDecoderPolicyTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvAudioDecoderPolicyTest.java deleted file mode 100644 index 8f2132f32e..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvAudioDecoderPolicyTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package androidx.media3.mpvplayer; - -import org.junit.Test; - -import java.util.LinkedHashMap; -import java.util.Map; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class MpvAudioDecoderPolicyTest { - - @Test - public void noCompressedOutputRouteUsesStableSoftwareAudioDecoders() { - assertEquals( - "aac,mp3,amrnb,amrwb", - MpvAudioDecoderPolicy.decoderList("")); - assertFalse(MpvAudioDecoderPolicy.decoderList("").contains("mediacodec")); - } - - @Test - public void compressedOutputRouteKeepsHardwareAudioPriority() { - assertEquals( - "aac_mediacodec,mp3_mediacodec,amrnb_mediacodec,amrwb_mediacodec", - MpvAudioDecoderPolicy.decoderList("aac,mp3")); - assertEquals(MpvAudioDecoderPolicy.hardwareFirstDecoderList(), - MpvAudioDecoderPolicy.decoderList("aac,mp3")); - } - - @Test - public void performancePriorityManagesAudioDecoderOrder() { - Map candidates = new LinkedHashMap<>(); - candidates.put("ad", MpvAudioDecoderPolicy.decoderList("")); - - assertTrue(MpvOptionPriorityPolicy.isPerformanceManaged("ad")); - assertEquals(candidates, - MpvOptionPriorityPolicy.selectPerformanceOverlay(true, candidates)); - assertTrue(MpvOptionPriorityPolicy.selectPerformanceOverlay( - false, candidates).isEmpty()); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvAudioMimeTypesTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvAudioMimeTypesTest.java deleted file mode 100644 index c7069c1182..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvAudioMimeTypesTest.java +++ /dev/null @@ -1,21 +0,0 @@ -package androidx.media3.mpvplayer; - -import static org.junit.Assert.assertEquals; - -import androidx.media3.common.MimeTypes; - -import org.junit.Test; - -public class MpvAudioMimeTypesTest { - - @Test - public void mapsAv3aCodecToAudioMime() { - assertEquals(MimeTypes.AUDIO_AV3A, MpvAudioMimeTypes.fromCodec("av3a.02")); - assertEquals(MimeTypes.AUDIO_AV3A, MpvAudioMimeTypes.fromCodec("AVS3A")); - } - - @Test - public void keepsMp3CodecOnMpegMime() { - assertEquals(MimeTypes.AUDIO_MPEG, MpvAudioMimeTypes.fromCodec("mp3float")); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvDiagnosticsPolicyTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvDiagnosticsPolicyTest.java index d1857d4128..ebd55e349b 100644 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvDiagnosticsPolicyTest.java +++ b/app/src/test/java/androidx/media3/mpvplayer/MpvDiagnosticsPolicyTest.java @@ -3,132 +3,9 @@ package androidx.media3.mpvplayer; import org.junit.Test; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class MpvDiagnosticsPolicyTest { - @Test - public void onlyNativeInfoMeasurementsBypassPlaybackProcessing() { - String[][] measurements = { - {"vo/gpu-next/aimagereader", "WebHTV FEL perf:"}, - {"vo/gpu-next/aimagereader", "WebHTV FEL perf stages:"}, - {"vo/gpu-next", "WebHTV FEL render perf:"}, - {"vo/gpu-next/aimagereader", "WebHTV FEL GPU init:"}, - {"vd", "WebHTV FEL decoder threads:"}, - {"vd", "WebHTV FEL decoder cost:"}, - {"vd", "WebHTV FEL producer handoff:"}, - {"vo/gpu-next/aimagereader", "WebHTV FEL map cost:"}, - {"vo/gpu-next/aimagereader", "WebHTV FEL GPU pool:"}, - {"enhancement_pair", "WebHTV FEL stats:"}, - {"vd", "WebHTV FEL decoder queue:"}, - {"vo/gpu-next/aimagereader", "WebHTV FEL reuse:"}, - {"vo/gpu-next/aimagereader", "WebHTV FEL api perf:"}, - {"vo/gpu-next/aimagereader", "WebHTV FEL frame order:"}, - {"vo/gpu-next/aimagereader", "WebHTV FEL api slow:"}, - {"vo/gpu-next/aimagereader", "WebHTV FEL descriptors:"}, - {"vo/gpu-next", "WebHTV FEL renderer init:"}, - {"vo/gpu-next/aimagereader", "WebHTV FEL wait sample:"}, - {"vo/gpu-next/aimagereader", "WebHTV FEL bind probe:"}, - }; - assertEquals(MpvDiagnosticsPolicy.FEL_PERFORMANCE_KINDS, measurements.length); - for (int kind = 0; kind < measurements.length; kind++) { - String prefix = measurements[kind][0], text = measurements[kind][1] + " count=12\n"; - assertEquals(kind, MpvDiagnosticsPolicy.felPerformanceKind(prefix, 40, text)); - for (int severity : new int[]{0, 10, 20, 30, 50, 60, 70}) - assertEquals(-1, MpvDiagnosticsPolicy.felPerformanceKind(prefix, severity, text)); - assertEquals(-1, MpvDiagnosticsPolicy.felPerformanceKind("metadata", 40, text)); - assertEquals(-1, MpvDiagnosticsPolicy.felPerformanceKind(prefix, 40, "title=" + text)); - assertEquals(-1, MpvDiagnosticsPolicy.felPerformanceKind(prefix, 40, - text + "WebHTV FEL fatal: driver failure")); - } - assertEquals(-1, MpvDiagnosticsPolicy.felPerformanceKind("vd", 20, "WebHTV FEL fatal: no progress")); - assertEquals(-1, MpvDiagnosticsPolicy.felPerformanceKind("vd", 40, "WebHTV FEL unknown: failed")); - assertEquals(-1, MpvDiagnosticsPolicy.felPerformanceKind("vo/gpu-next", 40, "WebHTV FEL GPU input: NLQ active")); - assertEquals(-1, MpvDiagnosticsPolicy.felPerformanceKind(null, 40, "WebHTV FEL perf:")); - assertEquals(-1, MpvDiagnosticsPolicy.felPerformanceKind("vd", 40, null)); - } - - @Test - public void warningFloodCannotHidePerformanceOrDecoderThreadEvidence() { - MpvDiagnosticsPolicy.NativeLogWindow window = new MpvDiagnosticsPolicy.NativeLogWindow(); - for (int i = 0; i < 100; i++) window.allow(100, "warning"); - for (int i = 0; i < 8; i++) assertTrue(window.allowPerformance(101, 0)); - assertFalse(window.allowPerformance(102, 0)); - assertTrue(window.allowPerformance(103, 3)); // GPU init has its own budget. - assertTrue(window.allowPerformance(103, 4)); // Actual decoder threads too. - assertTrue(window.allowPerformance(103, 11)); // Cache evidence has its own budget. - for (int i = 0; i < 8; i++) assertTrue(window.allowPerformance(103, 12)); - assertFalse(window.allowPerformance(104, 12)); - assertTrue(window.allowPerformance(104, 13)); // Frame identity survives API statistics. - assertTrue(window.allowPerformance(104, 14)); // Slow-call evidence has its own budget. - assertTrue(window.allowPerformance(104, 17)); // Wait samples survive the same flood. - assertTrue(window.allowPerformance(104, 18)); // Explicit bind groups have a separate budget. - assertTrue(window.allow(104, "vd: WebHTV FEL fatal: no progress")); - assertEquals(2, window.takePerformanceSuppressed()); - assertEquals(0, window.takePerformanceSuppressed()); - assertTrue(window.allowPerformance(5101, 0)); - assertTrue(window.allowPerformance(50, 0)); // Clock reset starts a new window. - assertFalse(window.allowPerformance(50, -1)); - assertFalse(window.allowPerformance(50, MpvDiagnosticsPolicy.FEL_PERFORMANCE_KINDS)); - } - - @Test - public void fatalFelRequiresActualErrorRecordNotQuotedMetadata() { - assertTrue(MpvDiagnosticsPolicy.isFatalFelLog(20, "WebHTV FEL fatal: decoder stalled\n")); - assertFalse(MpvDiagnosticsPolicy.isFatalFelLog(40, "WebHTV FEL fatal: a file title")); - assertFalse(MpvDiagnosticsPolicy.isFatalFelLog(20, "title=WebHTV FEL fatal: movie")); - assertFalse(MpvDiagnosticsPolicy.isFatalFelLog(20, null)); - } - - @Test - public void warningSeverityPreservesStarvationAndUnknownWarnings() { - assertTrue(MpvDiagnosticsPolicy.shouldLogNativeImmediately(30, - "MediaCodec input and output ports remained unavailable; failing hardware decode")); - assertTrue(MpvDiagnosticsPolicy.shouldLogNativeImmediately(20, "opaque driver diagnostic")); - assertFalse(MpvDiagnosticsPolicy.shouldLogNativeImmediately(60, "ordinary per-frame detail")); - assertFalse(MpvDiagnosticsPolicy.shouldLogNativeImmediately(30, null)); - } - - @Test - public void repeatedWarningsAreBoundedButFatalDiagnosisSurvives() { - MpvDiagnosticsPolicy.NativeLogWindow window = new MpvDiagnosticsPolicy.NativeLogWindow(); - for (int i = 0; i < 32; i++) assertTrue(window.allow(100, "warning")); - assertFalse(window.allow(101, "warning")); - assertFalse(window.allow(102, "warning")); - assertTrue(window.allow(103, "vd: WebHTV FEL fatal: no progress")); - assertEquals(2, window.takeSuppressed()); - assertEquals(0, window.takeSuppressed()); - assertTrue(window.allow(5100, "warning")); - } - - @Test - public void felReconstructionEvidenceIsPersistedBeforeMainQueue() { - assertTrue(MpvDiagnosticsPolicy.shouldLogNativeImmediately( - "enhancement_pair: WebHTV Android FEL: software enhancement-layer decoder enabled")); - assertTrue(MpvDiagnosticsPolicy.shouldLogNativeImmediately( - "enhancement_pair: WebHTV FEL pair: BL=1.0 EL=1.0 NLQ=1 software-EL=1")); - assertTrue(MpvDiagnosticsPolicy.shouldLogNativeImmediately( - "vo/gpu-next: WebHTV FEL GPU input: matched EL uploaded with active NLQ.")); - assertTrue(MpvDiagnosticsPolicy.shouldLogNativeImmediately( - "ffmpeg/video: Native Dolby Vision output is unavailable, using the base-layer decoder for profile 7")); - assertTrue(MpvDiagnosticsPolicy.shouldLogNativeImmediately( - "ffmpeg/video: MediaCodec started successfully: codec = c2.mtk.hevc.decoder, ret = 0")); - } - - @Test - public void failureEvidenceDoesNotWaitForTheUi() { - assertTrue(MpvDiagnosticsPolicy.shouldLogNativeImmediately("vd: Decoder init failed for hevc")); - assertTrue(MpvDiagnosticsPolicy.shouldLogNativeImmediately("vo/gpu-next: Vulkan error")); - assertTrue(MpvDiagnosticsPolicy.shouldLogNativeImmediately("lavf: Invalid data found when processing input")); - } - - @Test - public void ordinaryPerFrameLogsStayOutOfImmediateDiagnostics() { - assertFalse(MpvDiagnosticsPolicy.shouldLogNativeImmediately("cplayer: playing frame pts=12.34")); - assertFalse(MpvDiagnosticsPolicy.shouldLogNativeImmediately("vd: sending packet pts=12.34")); - assertFalse(MpvDiagnosticsPolicy.shouldLogNativeImmediately(null)); - assertFalse(MpvDiagnosticsPolicy.shouldLogNativeImmediately("")); - } @Test public void normalPlaybackAndMinimalErrorsNeverQueryDetailedProperties() { diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvDiscMenuPolicyTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvDiscMenuPolicyTest.java deleted file mode 100644 index 053637aa0e..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvDiscMenuPolicyTest.java +++ /dev/null @@ -1,133 +0,0 @@ -package androidx.media3.mpvplayer; - -import android.view.KeyEvent; - -import org.junit.Test; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -public class MpvDiscMenuPolicyTest { - - @Test - public void touchCarriesCurrentWindowPositionInOneCommand() { - assertArrayEquals(new String[]{"discnav", "mouse-click", "1200", "640", "window"}, - MpvDiscMenuPolicy.pointerCommand(1200, 640, true)); - assertArrayEquals(new String[]{"discnav", "mouse-move", "75", "300", "window"}, - MpvDiscMenuPolicy.pointerCommand(75, 300, false)); - } - - @Test - public void pointerCoordinatesAreNotNormalizedOrClampedBeforeNativeTransform() { - assertArrayEquals(new String[]{"discnav", "mouse-click", "0", "0", "window"}, - MpvDiscMenuPolicy.pointerCommand(0, 0, true)); - assertArrayEquals(new String[]{"discnav", "mouse-click", "-1", "2160", "window"}, - MpvDiscMenuPolicy.pointerCommand(-1, 2160, true)); - } - - @Test - public void startedNavigationSavesVisitWithoutInventingMovieProgress() { - assertTrue(MpvDiscMenuPolicy.canSaveHistory(false, true)); - assertTrue(MpvDiscMenuPolicy.canSaveHistory(true, true)); - assertTrue(MpvDiscMenuPolicy.canSaveHistory(true, false)); - assertFalse(MpvDiscMenuPolicy.canSaveHistory(false, false)); - assertFalse(MpvDiscMenuPolicy.hasSinglePlaybackTimeline( - "webhtv-dvdiso://1005/raw", true, true)); - } - - @Test - public void rawIsoDefersHistoryUntilNativeModeIsKnown() { - assertTrue(MpvDiscMenuPolicy.usesRawIso("webhtv-dvdiso://1005/raw")); - assertFalse(MpvDiscMenuPolicy.hasSinglePlaybackTimeline( - "webhtv-dvdiso://1005/raw", false, false)); - } - - @Test - public void hdmvSessionDoesNotUseMovieHistoryEvenBetweenMenus() { - assertFalse(MpvDiscMenuPolicy.hasSinglePlaybackTimeline( - "webhtv-dvdiso://1005/raw", true, true)); - } - - @Test - public void menuBackgroundEofDoesNotEndNavigationSession() { - assertFalse(MpvDiscMenuPolicy.isTerminalEof(true, true)); - } - - @Test - public void navigatingAcrossClipsKeepsPlaybackActive() { - // The native EOF property can toggle several times without an end-file - // event, including while the menu overlay is temporarily invisible. - for (boolean eof : new boolean[]{false, true, false, true, false}) { - assertFalse(MpvDiscMenuPolicy.isTerminalEof(eof, true)); - } - } - - @Test - public void ordinaryTitlesAndBdjFallbackKeepTerminalEof() { - assertFalse(MpvDiscMenuPolicy.isTerminalEof(false, false)); - assertTrue(MpvDiscMenuPolicy.isTerminalEof(true, false)); - } - - @Test - public void bdjAndNoMenuFallbackKeepNormalHistoryAfterOpening() { - assertTrue(MpvDiscMenuPolicy.hasSinglePlaybackTimeline( - "webhtv-dvdiso://1005/raw", true, false)); - } - - @Test - public void disabledMenuAndOrdinaryMediaKeepImmediateHistory() { - for (String uri : new String[]{null, "webhtv-dvdiso://1005/longest", - "https://example.test/movie.iso", "https://example.test/movie.mkv"}) { - assertFalse(MpvDiscMenuPolicy.usesRawIso(uri)); - assertTrue(MpvDiscMenuPolicy.hasSinglePlaybackTimeline(uri, false, false)); - } - } - - @Test - public void disabledKeepsLongestTitle() { - assertEquals("webhtv-dvdiso://1005/longest", - MpvDiscMenuPolicy.isoUri("webhtv-dvdiso://1005/longest", false)); - } - - @Test - public void enabledUsesRawIsoWithoutChangingSession() { - assertEquals("webhtv-dvdiso://1005/raw", - MpvDiscMenuPolicy.isoUri("webhtv-dvdiso://1005/longest", true)); - } - - @Test - public void negativeOpaqueIsoProbeRemainsOrdinaryMedia() { - assertNull(MpvDiscMenuPolicy.isoUri(null, true)); - assertNull(MpvDiscMenuPolicy.isoUri(null, false)); - } - - @Test - public void unrelatedSourcesAndRawUrisAreNotRewritten() { - assertEquals("https://example.test/longest/movie.mkv", - MpvDiscMenuPolicy.isoUri("https://example.test/longest/movie.mkv", true)); - assertEquals("webhtv-dvdiso://1005/raw", - MpvDiscMenuPolicy.isoUri("webhtv-dvdiso://1005/raw", true)); - } - - @Test - public void menuKeysMatchMpvActions() { - assertEquals("up", MpvDiscMenuPolicy.keyAction(KeyEvent.KEYCODE_DPAD_UP)); - assertEquals("down", MpvDiscMenuPolicy.keyAction(KeyEvent.KEYCODE_DPAD_DOWN)); - assertEquals("left", MpvDiscMenuPolicy.keyAction(KeyEvent.KEYCODE_DPAD_LEFT)); - assertEquals("right", MpvDiscMenuPolicy.keyAction(KeyEvent.KEYCODE_DPAD_RIGHT)); - assertEquals("select", MpvDiscMenuPolicy.keyAction(KeyEvent.KEYCODE_DPAD_CENTER)); - assertEquals("select", MpvDiscMenuPolicy.keyAction(KeyEvent.KEYCODE_ENTER)); - assertEquals("prev", MpvDiscMenuPolicy.keyAction(KeyEvent.KEYCODE_BACK)); - assertEquals("popup", MpvDiscMenuPolicy.keyAction(KeyEvent.KEYCODE_MENU)); - } - - @Test - public void volumeAndMediaKeysRemainOwnedByAndroid() { - assertNull(MpvDiscMenuPolicy.keyAction(KeyEvent.KEYCODE_VOLUME_UP)); - assertNull(MpvDiscMenuPolicy.keyAction(KeyEvent.KEYCODE_VOLUME_DOWN)); - assertNull(MpvDiscMenuPolicy.keyAction(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvDiscRebufferTrackerTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvDiscRebufferTrackerTest.java deleted file mode 100644 index 35693862cf..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvDiscRebufferTrackerTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package androidx.media3.mpvplayer; - -import org.junit.Test; - -import static org.junit.Assert.*; - -public class MpvDiscRebufferTrackerTest { - @Test - public void prolongedDemandStarvationCountsOnceAndIncludesActiveDuration() { - MpvDiscRebufferTracker tracker = new MpvDiscRebufferTracker(); - tracker.update(1000, true, 4000, 0, 0); - tracker.update(1200, true, 4000, 0, 200); - assertEquals(0, tracker.count()); - tracker.update(1300, true, 4000, 0, 300); - tracker.update(1600, true, 4000, 0, 600); - assertEquals(1, tracker.count()); - assertEquals(600, tracker.totalMs(1600)); - tracker.update(1700, true, 4050, 150, 0); - assertFalse(tracker.active()); - assertEquals(700, tracker.totalMs(3000)); - } - - @Test - public void stillMenusWithoutPendingDemandAreNotRebuffers() { - MpvDiscRebufferTracker tracker = new MpvDiscRebufferTracker(); - tracker.update(0, true, 1000, 0, 0); - tracker.update(60_000, true, 1000, 0, 0); - assertEquals(0, tracker.count()); - } - - @Test - public void LowBufferAndBackgroundPrefetchWhilePlayingAreNotStalls() { - MpvDiscRebufferTracker tracker = new MpvDiscRebufferTracker(); - for (int time = 0; time < 10_000; time += 100) { - tracker.update(time, true, time, 0, 1500); - } - assertEquals(0, tracker.count()); - } - - @Test - public void bufferedFramesPreventCountingSourceWaitAsRebuffer() { - MpvDiscRebufferTracker tracker = new MpvDiscRebufferTracker(); - tracker.update(0, true, 100, 1000, 0); - tracker.update(1000, true, 100, 1000, 1000); - assertEquals(0, tracker.count()); - } - - @Test - public void startupPauseSeekAndInactiveSourcesAreExcluded() { - MpvDiscRebufferTracker tracker = new MpvDiscRebufferTracker(); - tracker.update(0, false, 0, 0, 0); - tracker.update(4000, false, 0, 0, 4000); - tracker.update(5000, true, 0, 0, 5000); - assertEquals(0, tracker.count()); - tracker.update(5100, true, 20, 0, 0); - assertEquals(0, tracker.count()); - } - - @Test - public void recoveryAndAnotherStallCountSeparatelyAndResetClearsSession() { - MpvDiscRebufferTracker tracker = new MpvDiscRebufferTracker(); - tracker.update(0, true, 10, 0, 0); - tracker.update(300, true, 10, 0, 300); - tracker.update(400, true, 10, 0, 0); - tracker.update(500, true, 20, 0, 0); - tracker.update(800, true, 20, 0, 300); - assertEquals(2, tracker.count()); - tracker.interrupt(900); - assertEquals(800, tracker.totalMs(2000)); - tracker.reset(); - assertEquals(0, tracker.count()); - assertEquals(0, tracker.totalMs(3000)); - } - - @Test - public void playlistPositionDiscontinuityDoesNotAccumulateOldStallTime() { - MpvDiscRebufferTracker tracker = new MpvDiscRebufferTracker(); - tracker.update(0, true, 20_000, 0, 0); - tracker.update(5000, true, 0, 0, 5000); - assertEquals(0, tracker.count()); - tracker.update(5100, true, 100, 0, 0); - assertEquals(0, tracker.count()); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvDtsHdFallbackPolicyTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvDtsHdFallbackPolicyTest.java deleted file mode 100644 index 38d4b2c9a5..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvDtsHdFallbackPolicyTest.java +++ /dev/null @@ -1,115 +0,0 @@ -package androidx.media3.mpvplayer; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class MpvDtsHdFallbackPolicyTest { - - @Test - public void startupLogBurstNeverNeedsNativeAudioQueries() { - String[] startupLogs = { - "vo/gpu-next/libplacebo: Probing for vulkan devices:", - "vo/gpu-next/libplacebo: Vulkan device properties:", - "vd: Opening decoder hevc", - "enhancement_pair: WebHTV Android FEL: software enhancement-layer decoder enabled", - "cplayer: Failed sending hook command auto_profiles/on_load. Removing hook.", - "ffmpeg/video: hevc_mediacodec: MediaCodec 0x0 failed to start", - "ao/audiotrack: AudioTrack.write failed with -6" - }; - for (int i = 0; i < 100; i++) { - for (String line : startupLogs) { - assertFalse(MpvDtsHdFallbackPolicy.shouldInspectAudioState("", line, false)); - assertFalse(MpvDtsHdFallbackPolicy.shouldInspectAudioState("dts,dts-hd", line, false)); - } - } - } - - @Test - public void pcmOrCoreOnlyOutputNeverNeedsFallbackAudioQueries() { - for (String codecs : new String[]{null, "", "ac3,eac3,dts,truehd"}) { - assertFalse(MpvDtsHdFallbackPolicy.shouldInspectAudioState( - codecs, "ao/audiotrack: AudioTrack Init failed", false)); - } - } - - @Test - public void onlyUnattemptedDtsHdInitFailureCanInspectAudioState() { - assertTrue(MpvDtsHdFallbackPolicy.shouldInspectAudioState( - " ac3, DTS-HD ,dts", "ao/audiotrack: AudioTrack Init failed", false)); - assertTrue(MpvDtsHdFallbackPolicy.shouldInspectAudioState( - "dts-hd", "ao/audiotrack: AudioTrack.getState failed", false)); - assertFalse(MpvDtsHdFallbackPolicy.shouldInspectAudioState( - "dts-hd", "ao/audiotrack: AudioTrack Init failed", true)); - assertFalse(MpvDtsHdFallbackPolicy.shouldInspectAudioState("dts-hd", null, false)); - } - - @Test - public void retriesDtsHdAudioTrackConstructorFailureAsCore() { - MpvDtsHdFallbackPolicy.Decision decision = - MpvDtsHdFallbackPolicy.evaluate( - "ac3,eac3,dts,dts-hd,truehd", "spdif-dtshd", - "DTS-HD MA", "ao/audiotrack: AudioTrack Init failed", - false); - - assertTrue(decision.retry()); - assertEquals("dts", decision.codecs()); - } - - @Test - public void retriesDtsHdAudioTrackStateFailureAsCore() { - MpvDtsHdFallbackPolicy.Decision decision = - MpvDtsHdFallbackPolicy.evaluate( - "dts-hd", "spdif-dtshd", - "DTS-HD HRA", - "ao/audiotrack: AudioTrack.getState failed", false); - - assertTrue(decision.retry()); - assertEquals("dts", decision.codecs()); - } - - @Test - public void ignoresRepeatedFailure() { - assertFalse(MpvDtsHdFallbackPolicy.evaluate( - "dts,dts-hd", "spdif-dtshd", - "DTS-HD MA", "AudioTrack Init failed", true).retry()); - } - - @Test - public void ignoresUnrelatedAudioTrackFailure() { - assertFalse(MpvDtsHdFallbackPolicy.evaluate( - "dts,dts-hd", "spdif-dtshd", - "DTS-HD MA", "AudioTrack.write failed with -6", - false).retry()); - } - - @Test - public void ignoresDtsCoreAndPcmFormats() { - assertFalse(MpvDtsHdFallbackPolicy.evaluate( - "dts,dts-hd", "spdif-dts", - "DTS", "AudioTrack Init failed", false).retry()); - assertFalse(MpvDtsHdFallbackPolicy.evaluate( - "dts,dts-hd", "s16", - "DTS", "AudioTrack Init failed", false).retry()); - } - - @Test - public void usesDtsHdProfileWhenMpvHasAlreadyMovedToPcmFallback() { - MpvDtsHdFallbackPolicy.Decision decision = - MpvDtsHdFallbackPolicy.evaluate( - "dts,dts-hd", "s16", "DTS-HD MA", - "AudioTrack Init failed", false); - - assertTrue(decision.retry()); - assertEquals("dts", decision.codecs()); - } - - @Test - public void ignoresConfigurationWithoutDtsHd() { - assertFalse(MpvDtsHdFallbackPolicy.evaluate( - "ac3,dts,truehd", "spdif-dtshd", - "DTS-HD MA", "AudioTrack Init failed", false).retry()); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvHlsAdBoundaryStateTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvHlsAdBoundaryStateTest.java deleted file mode 100644 index 64d7f91f82..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvHlsAdBoundaryStateTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package androidx.media3.mpvplayer; - -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; - -public class MpvHlsAdBoundaryStateTest { - private static final HlsAdTimeline.Range AD = new HlsAdTimeline.Range(496320, 512786); - - @Test - public void nativeLastProgrammeFrameCanEndBeforeTheBoundary() { - MpvHlsAdBoundaryState state = new MpvHlsAdBoundaryState(); - state.arm(AD); - assertEquals(512786, state.consumeEof(496280, false)); - assertTrue(state.pending()); - assertNull(state.range()); - } - - @Test - public void earlyNetworkEofAndUnrelatedPositionsAreNotAds() { - MpvHlsAdBoundaryState state = new MpvHlsAdBoundaryState(); - state.arm(AD); - assertEquals(-1, state.consumeEof(420000, false)); - assertEquals(-1, state.consumeEof(700000, false)); - assertFalse(state.pending()); - } - - @Test - public void pendingManualSeekCannotBeMistakenForTheClipEnd() { - MpvHlsAdBoundaryState state = new MpvHlsAdBoundaryState(); - state.arm(AD); - assertEquals(-1, state.consumeEof(496320, true)); - assertFalse(state.pending()); - } - - @Test - public void duplicateEofCannotConsumeTheNextBoundaryBeforeRestart() { - MpvHlsAdBoundaryState state = new MpvHlsAdBoundaryState(); - state.arm(AD); - assertEquals(512786, state.consumeEof(496280, false)); - state.arm(new HlsAdTimeline.Range(1000000, 1010000)); - assertEquals(-1, state.consumeEof(1000000, false)); - state.playbackRestarted(496280); - assertTrue(state.pending()); - state.playbackRestarted(512800); - assertFalse(state.pending()); - assertEquals(1010000, state.consumeEof(999960, false)); - } - - @Test - public void mediaAndManualResetForgetOldBoundaryAndEof() { - MpvHlsAdBoundaryState state = new MpvHlsAdBoundaryState(); - state.arm(AD); - state.consumeEof(496280, false); - state.clear(); - assertNull(state.range()); - assertFalse(state.pending()); - assertEquals(-1, state.consumeEof(496320, false)); - } - - @Test - public void nextBoundaryFollowsSourceTimeInBothSeekDirections() { - HlsAdTimeline timeline = HlsAdTimelineTest.middleAd("2"); - assertEquals(new HlsAdTimeline.Range(4000, 6000), timeline.nextRange(0)); - assertEquals(new HlsAdTimeline.Range(4000, 6000), timeline.nextRange(4000)); - assertNull(timeline.nextRange(6000)); - assertEquals(new HlsAdTimeline.Range(4000, 6000), timeline.nextRange(1000)); - assertNull(HlsAdTimeline.NONE.nextRange(1000)); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvHlsAdblockGateTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvHlsAdblockGateTest.java deleted file mode 100644 index 008ae3d4c7..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvHlsAdblockGateTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package androidx.media3.mpvplayer; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -/** Protects the shared MPV/IJK HLS proxy from false positives and false statistics. */ -public class MpvHlsAdblockGateTest { - - @Test - public void legacyFallbackRequiresItsToggleWithoutStructuredRules() throws Exception { - String source = readSource(); - - assertTrue(source.contains("legacyFallback = HlsRuleConfig.isLegacyFallbackEnabled()")); - assertFalse(source.contains("legacyFallback = !rules.isEmpty()")); - assertTrue(source.contains("HlsAdblockPipeline.apply(url, text, rules, legacyFallback)")); - assertFalse(source.contains("HlsAdblockPipeline.apply(url, text, HlsRuleConfig.getRules(), HlsRuleConfig.isLegacyFallbackEnabled())")); - } - - @Test - public void mpvCandidateNotifiesBeforeReturningAndIjkUsesItsOwnPipelineName() throws Exception { - String method = methodBody(readSource(), "private String applyAdblock", "private void recordAndNotifyAdblock"); - int mpv = method.indexOf("if (kernel == PlayerSetting.MPV)"); - int notify = method.indexOf("recordAndNotifyAdblock(url, outcome);", mpv); - int mpvReturn = method.indexOf("return text;", mpv); - - assertTrue("MPV must notify for a valid removal plan before preserving the source manifest", - mpv >= 0 && notify > mpv && mpvReturn > notify); - assertTrue(readSource().contains("kernel == PlayerSetting.IJK ? \"IJK\" : \"MPV\"")); - } - - private static String readSource() throws Exception { - return Files.readString(Path.of("src/main/java/androidx/media3/mpvplayer/MpvHlsProxy.java")); - } - - private static String methodBody(String source, String startToken, String endToken) { - int start = source.indexOf(startToken); - int end = source.indexOf(endToken, start); - assertTrue("Missing source token: " + startToken, start >= 0); - assertTrue("Missing source token after " + startToken + ": " + endToken, end > start); - return source.substring(start, end); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvHlsAdblockTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvHlsAdblockTest.java deleted file mode 100644 index 9962d3c118..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvHlsAdblockTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package androidx.media3.mpvplayer; - -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.util.List; -import java.util.Map; - -public class MpvHlsAdblockTest { - - private static final HlsPlaylistRewriter.Variant LOW = - new HlsPlaylistRewriter.Variant(1_000_000, 900_000, 640, 360, - HlsPlaylistRewriter.VariantKind.STREAM); - private static final HlsPlaylistRewriter.Variant HIGH = - new HlsPlaylistRewriter.Variant(4_000_000, 3_500_000, 1920, 1080, - HlsPlaylistRewriter.VariantKind.STREAM); - - @Test - public void directMediaPlaylistDoesNotNeedNativeVariantMetadata() { - HlsAdTimeline direct = HlsAdTimelineTest.middleAd("2"); - assertSame(direct, MpvHlsProxy.resolveAdTimeline(direct, Map.of(), 0, 0)); - } - - @Test - public void selectedPeakOrAverageBitrateChoosesItsOwnPlan() { - HlsAdTimeline low = HlsAdTimelineTest.middleAd("2"); - HlsAdTimeline high = HlsAdTimelineTest.middleAd("4"); - Map plans = Map.of(LOW, low, HIGH, high); - assertSame(low, MpvHlsProxy.resolveAdTimeline(null, plans, 1_000_000, 2)); - assertSame(high, MpvHlsProxy.resolveAdTimeline(null, plans, 3_500_000, 2)); - assertTrue(MpvHlsProxy.resolveAdTimeline(null, plans, 2_000_000, 2).ranges().isEmpty()); - } - - @Test - public void unknownSelectionRequiresEveryDeclaredVariantToAgree() { - HlsAdTimeline plan = HlsAdTimelineTest.middleAd("2"); - assertTrue(MpvHlsProxy.resolveAdTimeline(null, Map.of(LOW, plan), 0, 2) - .ranges().isEmpty()); - assertSame(plan, MpvHlsProxy.resolveAdTimeline(null, - Map.of(LOW, plan, HIGH, plan), 0, 2)); - assertTrue(MpvHlsProxy.resolveAdTimeline(null, - Map.of(LOW, plan, HIGH, HlsAdTimeline.NONE), 0, 2).ranges().isEmpty()); - } - - @Test - public void matchingBitratesWithConflictingPlansNeverGuess() { - HlsPlaylistRewriter.Variant alternate = - new HlsPlaylistRewriter.Variant(1_000_000, 900_000, 960, 540, - HlsPlaylistRewriter.VariantKind.STREAM); - assertTrue(MpvHlsProxy.resolveAdTimeline(null, - Map.of(LOW, HlsAdTimelineTest.middleAd("2"), - alternate, HlsAdTimelineTest.middleAd("4")), - 1_000_000, 2).ranges().isEmpty()); - } - - @Test - public void imageOrIframePlaylistCannotSupplyTheVideoAdPlan() { - HlsPlaylistRewriter.Variant iframe = - new HlsPlaylistRewriter.Variant(1_000_000, 900_000, 640, 360, - HlsPlaylistRewriter.VariantKind.I_FRAME); - assertTrue(MpvHlsProxy.resolveAdTimeline(null, - Map.of(iframe, HlsAdTimelineTest.middleAd("2")), 1_000_000, 1) - .ranges().isEmpty()); - } - - @Test - public void unequalProgrammeBlocksDoNotBecomeUnseekable() { - // Same discontinuity structure and exact block durations as the reported - // mixed.m3u8; opaque local names keep this regression fully offline. - StringBuilder source = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:8\n"); - int next = appendBlock(source, 0, 124, "4", "4.32"); - next = appendAd(source, next, "6.633333", "3.333333", "4.8", "1.7"); - int retainedStart = source.length(); - next = appendBlock(source, next, 375, "4", "4.72"); - int retainedEnd = source.length(); - next = appendAd(source, next, "5.933333", "3.333333", "2.8", "5.3", "0.3"); - next = appendBlock(source, next, 200, "4", "4.24"); - int finalBlockStart = source.length(); - appendAd(source, next, "6.633333", "3.333333", "4.8", "1.7"); - source.append("#EXT-X-ENDLIST\n"); - String original = source.toString(); - // Freeze the detector's observed omissions: it keeps only the largest - // programme block and the final block. The mapping must defend against - // that output, without requiring Android TextUtils/Log in this JVM test. - String filtered = "#EXTM3U\n" + source.substring(retainedStart, retainedEnd) - + source.substring(finalBlockStart); - HlsAdTimeline timeline = HlsAdTimeline.from(original, filtered); - assertEquals(List.of(new HlsAdTimeline.Range(496320, 512786), - new HlsAdTimeline.Range(2013507, 2031173)), timeline.ranges()); - for (long position : new long[]{0, 9000, 490000, 600000, 2300000}) { - assertEquals(position, timeline.skipTargetMs(position)); - } - assertEquals(512786, timeline.skipTargetMs(500000)); - assertEquals(2031173, timeline.skipTargetMs(2020000)); - } - - private static int appendBlock(StringBuilder out, int start, int count, - String duration, String firstDuration) { - out.append("#EXT-X-DISCONTINUITY\n"); - for (int i = 0; i < count; i++) { - out.append(HlsAdTimelineTest.segment("segment" + (start + i) + ".ts", - i == 0 ? firstDuration : duration)); - } - return start + count; - } - - private static int appendAd(StringBuilder out, int start, String... durations) { - out.append("#EXT-X-DISCONTINUITY\n"); - for (String duration : durations) { - out.append(HlsAdTimelineTest.segment("segment" + start++ + ".ts", duration)); - } - return start; - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvHlsSegmentContentPolicyTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvHlsSegmentContentPolicyTest.java index 8604efdc82..ca5228868e 100644 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvHlsSegmentContentPolicyTest.java +++ b/app/src/test/java/androidx/media3/mpvplayer/MpvHlsSegmentContentPolicyTest.java @@ -14,86 +14,6 @@ import static org.junit.Assert.assertTrue; public class MpvHlsSegmentContentPolicyTest { - @Test - public void liveSegmentsAtPlaylistEndpointStayMediaBeforeAndAfterResponse() { - String playlist = """ - #EXTM3U - #EXT-X-MEDIA-SEQUENCE:1785796058 - #EXT-X-TARGETDURATION:6 - #EXTINF:6.000, - cctv5.m3u8?ts=segment-1 - #EXTINF:4.000, - cctv5.m3u8?ts=segment-2 - """; - - HlsPlaylistRewriter.Result result = HlsPlaylistRewriter.rewrite( - playlist, null, (uri, cacheable, context) -> { - assertEquals(HlsPlaylistRewriter.UriRole.MEDIA_SEGMENT, context.role()); - assertFalse(MpvHlsSegmentContentPolicy.isPlaylist(context.role(), uri, null)); - assertFalse(MpvHlsSegmentContentPolicy.isPlaylist(context.role(), uri, "video/mp2t")); - assertFalse(MpvHlsSegmentContentPolicy.isPlaylist( - context.role(), uri, "application/vnd.apple.mpegurl")); - return new HlsPlaylistRewriter.MappedUri(uri, uri); - }); - - assertEquals(2, result.segments().size()); - assertEquals(playlist, result.text()); - } - - @Test - public void byteRangeAndPartialSegmentsRetainTheirMediaRole() { - String playlist = """ - #EXTM3U - #EXT-X-PART:DURATION=0.5,URI="live.m3u8?part=1" - #EXT-X-PRELOAD-HINT:TYPE=PART,URI="live.m3u8?part=2" - #EXTINF:4, - #EXT-X-BYTERANGE:1024@188 - live.m3u8?ts=1 - """; - int[] ranges = {0}; - int[] items = {0}; - - HlsPlaylistRewriter.Result result = HlsPlaylistRewriter.rewrite( - playlist, null, (uri, cacheable, context) -> { - items[0]++; - if (context.byteRange()) ranges[0]++; - // serveItem preserves Range whenever this request is media. - assertFalse(MpvHlsSegmentContentPolicy.isPlaylist(context.role(), uri, null)); - return new HlsPlaylistRewriter.MappedUri(uri, uri); - }); - - assertEquals(3, items[0]); - assertEquals(1, ranges[0]); - assertEquals(playlist, result.text()); - } - - @Test - public void declaredVariantsRemainPlaylistsWithoutUrlOrMimeHints() { - HlsPlaylistRewriter.Result result = HlsPlaylistRewriter.rewrite(""" - #EXTM3U - #EXT-X-STREAM-INF:BANDWIDTH=4500000 - stream?quality=hd - """, null, (uri, cacheable, context) -> { - assertTrue(MpvHlsSegmentContentPolicy.isPlaylist(context.role(), uri, null)); - assertTrue(MpvHlsSegmentContentPolicy.isPlaylist( - context.role(), uri, "application/octet-stream")); - return new HlsPlaylistRewriter.MappedUri(uri, uri); - }); - - assertEquals(1, result.variants().size()); - } - - @Test - public void unclassifiedUrisKeepExistingPlaylistHints() { - HlsPlaylistRewriter.UriRole role = HlsPlaylistRewriter.UriRole.OTHER; - assertTrue(MpvHlsSegmentContentPolicy.isPlaylist(role, "audio.M3U8?lang=zh", null)); - assertTrue(MpvHlsSegmentContentPolicy.isPlaylist(role, "subtitles.m3u", null)); - assertTrue(MpvHlsSegmentContentPolicy.isPlaylist(role, "rendition", "application/x-mpegURL")); - assertFalse(MpvHlsSegmentContentPolicy.isPlaylist(role, "key?name=secret.m3u8", null)); - assertFalse(MpvHlsSegmentContentPolicy.isPlaylist(role, "init.mp4", "video/mp4")); - assertFalse(MpvHlsSegmentContentPolicy.isPlaylist(role, null, null)); - } - @Test public void jpgMediaSegmentIsProbedForPngWrapper() { assertTrue(MpvHlsSegmentContentPolicy.shouldProbePngPrefix("image/jpg", true)); diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvInitialTrackSelectionPolicyTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvInitialTrackSelectionPolicyTest.java deleted file mode 100644 index 68c98ecbfc..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvInitialTrackSelectionPolicyTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package androidx.media3.mpvplayer; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class MpvInitialTrackSelectionPolicyTest { - - @Test - public void pendingTrackSelectionKeepsAutoplayPausedNatively() { - assertTrue(MpvInitialTrackSelectionPolicy.shouldPauseNativePlayback( - true, true)); - } - - @Test - public void releasingGatePreservesOriginalPlaybackIntent() { - assertFalse(MpvInitialTrackSelectionPolicy.shouldPauseNativePlayback( - true, false)); - assertTrue(MpvInitialTrackSelectionPolicy.shouldPauseNativePlayback( - false, false)); - } - - @Test - public void equivalentTrackIdsDoNotTriggerAnotherSelection() { - assertTrue(MpvInitialTrackSelectionPolicy.isSameTrackSelection( - "02", "2")); - assertFalse(MpvInitialTrackSelectionPolicy.isSameTrackSelection( - "1", "2")); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvOptionPriorityPolicyTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvOptionPriorityPolicyTest.java index 11bb6645e5..7f5c542f34 100644 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvOptionPriorityPolicyTest.java +++ b/app/src/test/java/androidx/media3/mpvplayer/MpvOptionPriorityPolicyTest.java @@ -42,42 +42,6 @@ public class MpvOptionPriorityPolicyTest { assertEquals("mpv.conf", MpvOptionPriorityPolicy.priorityName(false)); } - @Test - public void felRequestPreservesOrdinaryVideoAndConfigPriority() { - Map candidates = new LinkedHashMap<>(); - candidates.put("android-dovi-fel", "yes"); - candidates.put("android-dovi-fel-vulkan", "yes"); - candidates.put("vo", "gpu-next"); - candidates.put("demuxer-dovi-profile7", "preserve"); - candidates.put("android-dolby-vision-output", "configured"); - candidates.put("vd-lavc-skipframe", "default"); - candidates.put("vd-lavc-skipidct", "default"); - candidates.put("audio-spdif", "ac3,eac3"); - candidates.put("cache-secs", "30"); - candidates.put("glsl-shaders", "user.glsl"); - candidates.put("android-vulkan-aimagereader-backend", "stable"); - - Map overlay = MpvOptionPriorityPolicy.selectPerformanceOverlay(false, candidates); - assertEquals(3, overlay.size()); - assertEquals("yes", overlay.get("android-dovi-fel")); - assertEquals("yes", overlay.get("android-dovi-fel-vulkan")); - assertEquals("preserve", overlay.get("demuxer-dovi-profile7")); - assertFalse(overlay.containsKey("vo")); - assertFalse(overlay.containsKey("android-dolby-vision-output")); - assertFalse(overlay.containsKey("vd-lavc-skipframe")); - assertFalse(overlay.containsKey("vd-lavc-skipidct")); - assertFalse(overlay.containsKey("audio-spdif")); - assertFalse(overlay.containsKey("cache-secs")); - assertFalse(overlay.containsKey("glsl-shaders")); - assertFalse(overlay.containsKey("android-vulkan-aimagereader-backend")); - } - - @Test - public void disablingFelRestoresNormalConfigPriority() { - assertTrue(MpvOptionPriorityPolicy.selectPerformanceOverlay(false, Map.of( - "android-dovi-fel", "no", "vo", "gpu", "demuxer-dovi-profile7", "p81")).isEmpty()); - } - @Test public void currentPerformanceCatalogIsExplicitlyManaged() { assertTrue(MpvOptionPriorityPolicy.isPerformanceManaged("vo")); diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvOsdSurfacePolicyTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvOsdSurfacePolicyTest.java index e872cda177..035199a2ee 100644 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvOsdSurfacePolicyTest.java +++ b/app/src/test/java/androidx/media3/mpvplayer/MpvOsdSurfacePolicyTest.java @@ -34,41 +34,4 @@ public class MpvOsdSurfacePolicyTest { assertFalse(MpvOsdSurfacePolicy.requiresSurface( false, "2", "2", "4", "4")); } - - @Test - public void disabledSubtitleSelectionsDoNotQueryCurrentTracks() { - assertFalse(MpvOsdSurfacePolicy.needsCurrentTrackQuery( - true, "no", "no")); - assertFalse(MpvOsdSurfacePolicy.needsCurrentTrackQuery( - false, "auto", "auto")); - } - - @Test - public void automaticOrUnknownSelectionStillQueriesCurrentTracks() { - assertTrue(MpvOsdSurfacePolicy.needsCurrentTrackQuery( - true, "auto", "no")); - assertTrue(MpvOsdSurfacePolicy.needsCurrentTrackQuery( - true, "", "no")); - } - - @Test - public void unusedSurfaceStaysDetached() { - assertFalse(MpvOsdSurfacePolicy.shouldKeepSurface(false, false)); - } - - @Test - public void surfaceStaysAttachedAfterFirstSubtitleUse() { - assertTrue(MpvOsdSurfacePolicy.shouldKeepSurface(false, true)); - assertTrue(MpvOsdSurfacePolicy.shouldKeepSurface(true, false)); - } - - @Test - public void requestedOsdWaitsForVideoDetachDuringWindowLoss() { - assertTrue(MpvOsdSurfacePolicy.shouldDeferDestroyedSurfaceDetach( - true, true)); - assertFalse(MpvOsdSurfacePolicy.shouldDeferDestroyedSurfaceDetach( - true, false)); - assertFalse(MpvOsdSurfacePolicy.shouldDeferDestroyedSurfaceDetach( - false, true)); - } } diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvPauseIntentPolicyTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvPauseIntentPolicyTest.java deleted file mode 100644 index bb86bf661b..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvPauseIntentPolicyTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package androidx.media3.mpvplayer; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -public class MpvPauseIntentPolicyTest { - - @Test - public void matchingPlayingStateNeedsNoAction() { - assertEquals(MpvPauseIntentPolicy.Action.NONE, - MpvPauseIntentPolicy.resolve(true, false, true)); - } - - @Test - public void matchingPausedStateNeedsNoAction() { - assertEquals(MpvPauseIntentPolicy.Action.NONE, - MpvPauseIntentPolicy.resolve(false, true, true)); - } - - @Test - public void delayedStartupPauseReassertsAutoplayIntent() { - assertEquals(MpvPauseIntentPolicy.Action.REASSERT_REQUESTED_STATE, - MpvPauseIntentPolicy.resolve(true, true, true)); - } - - @Test - public void delayedUnpauseReassertsUserPauseIntent() { - assertEquals(MpvPauseIntentPolicy.Action.REASSERT_REQUESTED_STATE, - MpvPauseIntentPolicy.resolve(false, false, true)); - } - - @Test - public void mismatchBeforeFileIsActiveWaitsForLoadBoundary() { - assertEquals(MpvPauseIntentPolicy.Action.WAIT_FOR_ACTIVE_MEDIA, - MpvPauseIntentPolicy.resolve(true, true, false)); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvPropertyCacheTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvPropertyCacheTest.java index 5e49674721..d282e3252a 100644 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvPropertyCacheTest.java +++ b/app/src/test/java/androidx/media3/mpvplayer/MpvPropertyCacheTest.java @@ -2,9 +2,6 @@ package androidx.media3.mpvplayer; import org.junit.Test; -import java.util.List; -import java.util.Map; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -24,8 +21,6 @@ public class MpvPropertyCacheTest { assertTrue(cache.getBoolean("selected", false)); assertEquals("hevc", cache.getString("codec", "")); assertEquals(7, cache.getInt("missing", 7)); - assertEquals(7, cache.getInt(null, 7)); - assertFalse(cache.contains(null)); } @Test @@ -38,23 +33,6 @@ public class MpvPropertyCacheTest { assertEquals("fallback", cache.getString("track-list/0/title", "fallback")); } - @Test - public void derivesTrackListChildrenFromNodeSnapshot() { - MpvPropertyCache cache = new MpvPropertyCache(); - cache.put("track-list", new MpvPropertySnapshot.TrackList(List.of( - Map.of("id", 2, "type", "audio", "codec", "aac"), - Map.of("id", 3, "type", "sub", "codec", "subrip")), true)); - - assertEquals(2, cache.getInt("track-list/count", 0)); - assertEquals("sub", cache.getString("track-list/1/type", "")); - assertEquals(3, cache.getInt("track-list/1/id", 0)); - assertTrue(cache.contains("track-list/1/codec")); - assertFalse(cache.contains("track-list/2/type")); - - cache.put("track-list", null); - assertEquals(0, cache.getInt("track-list/count", 0)); - } - @Test public void clearDropsValuesFromPreviousMedia() { MpvPropertyCache cache = new MpvPropertyCache(); @@ -63,4 +41,4 @@ public class MpvPropertyCacheTest { assertFalse(cache.contains("duration")); } -} +} \ No newline at end of file diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvPropertySnapshotTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvPropertySnapshotTest.java deleted file mode 100644 index 4f97d40704..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvPropertySnapshotTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package androidx.media3.mpvplayer; - -import org.junit.Test; -import static org.junit.Assert.*; - -public class MpvPropertySnapshotTest { - @Test public void doesNotInterceptExistingCacheOrAudioFallbackQueries() { - MpvPropertySnapshot state = new MpvPropertySnapshot(); - for (String property : new String[]{"cache-speed", "time-pos", "demuxer-cache-state/reader-pts", - "audio-params/format", "current-tracks/audio/codec-profile"}) { - state.register(property); - assertFalse(state.contains(property)); - } - state.register("video-params/gamma"); - assertTrue(state.contains("video-params/gamma")); - } - - @Test public void trackFieldsRetainTypesAndUnicode() { - MpvPropertySnapshot state = new MpvPropertySnapshot(); - state.update(0, "track-list", MpvPropertySnapshot.TrackList.parse( - "[{\"id\":2,\"type\":\"audio\",\"title\":\"国语 🎵\",\"lang\":\"zho\"," - + "\"selected\":true,\"demux-channel-count\":6,\"hls-bitrate\":8000000}]")); - assertEquals(Integer.valueOf(1), state.integer("track-list/count")); - assertEquals("2", state.string("track-list/0/id")); - assertEquals("国语 🎵", state.string("track-list/0/title")); - assertEquals("zho", state.string("track-list/0/lang")); - assertEquals(Boolean.TRUE, state.flag("track-list/0/selected")); - assertEquals(Integer.valueOf(6), state.integer("track-list/0/demux-channel-count")); - } - - @Test public void missingTrackFieldsAreAuthoritativeAbsence() { - MpvPropertySnapshot state = new MpvPropertySnapshot(); - assertTrue(state.contains("track-list/0/decoder")); - assertNull(state.string("track-list/0/decoder")); - assertNull(state.integer("track-list/-1/id")); - assertNull(state.integer("track-list/bad/id")); - assertEquals(Integer.valueOf(0), state.integer("track-list/count")); - } - - @Test public void snapshotsAreReplacedNotMerged() { - MpvPropertySnapshot state = new MpvPropertySnapshot(); - state.update(0, "track-list", MpvPropertySnapshot.TrackList.parse("[{\"id\":1},{\"id\":2}]")); - state.update(0, "track-list", MpvPropertySnapshot.TrackList.parse("[{\"id\":3}]")); - assertEquals("3", state.string("track-list/0/id")); - assertNull(state.string("track-list/1/id")); - } - - @Test public void lateOldFileEventsCannotRepopulateNewFile() { - MpvPropertySnapshot state = new MpvPropertySnapshot(); - state.update(0, "width", 3840L); - state.beginFile(1); - assertFalse(state.update(0, "width", 1920L)); - assertFalse(state.update(0, "track-list", MpvPropertySnapshot.TrackList.parse("[{\"id\":9}]"))); - assertNull(state.integer("width")); - assertEquals(Integer.valueOf(0), state.integer("track-list/count")); - assertTrue(state.update(1, "width", 1280L)); - assertEquals(Integer.valueOf(1280), state.integer("width")); - } - - @Test public void unchangedOptionsSurviveFileBoundaryButFileDataDoesNot() { - MpvPropertySnapshot state = new MpvPropertySnapshot(); - state.register("sid"); - state.update(0, "sid", "no"); - state.update(0, "audio-device", "audiotrack/auto"); - state.update(0, "demuxer-readahead-secs", 60L); - state.update(0, "current-tracks/sub/id", "3"); - state.update(0, "chapter-list", "[{\"time\":0}]"); - state.beginFile(1); - assertEquals("no", state.string("sid")); - assertEquals("audiotrack/auto", state.string("audio-device")); - assertEquals(Integer.valueOf(60), state.integer("demuxer-readahead-secs")); - assertNull(state.string("current-tracks/sub/id")); - assertNull(state.string("chapter-list")); - } - - @Test public void unavailableObserverAndAcceptedSelectionNeverNeedReadback() { - MpvPropertySnapshot state = new MpvPropertySnapshot(); - state.register("sub-visibility"); - assertTrue(state.contains("sub-visibility")); - assertNull(state.flag("sub-visibility")); - state.acceptedWrite("sub-visibility", true); - assertEquals(Boolean.TRUE, state.flag("sub-visibility")); - state.update(0, "sub-visibility", null); - assertNull(state.flag("sub-visibility")); - assertTrue(state.contains("sub-visibility")); - assertFalse(state.contains("unobserved-option")); - } - - @Test public void rejectsMalformedOversizedAndDeepJson() { - assertFalse(MpvPropertySnapshot.TrackList.parse("[{broken]").valid()); - assertFalse(MpvPropertySnapshot.TrackList.parse("{}").valid()); - assertFalse(MpvPropertySnapshot.TrackList.parse("[null]").valid()); - assertFalse(MpvPropertySnapshot.TrackList.parse(" ".repeat(MpvPropertySnapshot.MAX_JSON_CHARS + 1)).valid()); - assertFalse(MpvPropertySnapshot.TrackList.parse("[".repeat(13) + "]".repeat(13)).valid()); - assertTrue(MpvPropertySnapshot.TrackList.parse("[{\"title\":\"{[\\\"🎬\\\"]}\"}]").valid()); - } - - @Test public void invalidAndUnavailableSnapshotsClearOldTracks() { - MpvPropertySnapshot state = new MpvPropertySnapshot(); - state.update(0, "track-list", MpvPropertySnapshot.TrackList.parse("[{\"id\":1}]")); - state.update(0, "track-list", MpvPropertySnapshot.TrackList.parse("bad")); - assertEquals(Integer.valueOf(0), state.integer("track-list/count")); - assertTrue(MpvPropertySnapshot.TrackList.parse(null).valid()); - } - - @Test public void numericConversionsDoNotWrapOrInventValues() { - MpvPropertySnapshot state = new MpvPropertySnapshot(); - state.update(0, "large", 4_000_000_000L); - assertNull(state.integer("large")); - assertEquals(Double.valueOf(4_000_000_000d), state.decimal("large")); - state.update(0, "nan", Double.NaN); - assertNull(state.decimal("nan")); - state.update(0, "choice", "auto"); - assertNull(state.integer("choice")); - assertNull(state.flag("choice")); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvSurfaceLoadPolicyTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvSurfaceLoadPolicyTest.java deleted file mode 100644 index 9dac54d182..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvSurfaceLoadPolicyTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package androidx.media3.mpvplayer; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -public class MpvSurfaceLoadPolicyTest { - - @Test - public void audioOnly_doesNotWaitForVideoSurface() { - assertFalse(MpvSurfaceLoadPolicy.shouldWaitForVideoSurface( - true, false, true, false)); - } - - @Test - public void audioOnlyStillWaitsWhenOsdSurfaceIsRequired() { - assertTrue(MpvSurfaceLoadPolicy.shouldWaitForVideoSurface( - true, true, true, false)); - } - - @Test - public void videoStillWaitsForVisibleSurfaceView() { - assertTrue(MpvSurfaceLoadPolicy.shouldWaitForVideoSurface( - false, false, true, false)); - assertFalse(MpvSurfaceLoadPolicy.shouldWaitForVideoSurface( - false, false, true, true)); - } -} diff --git a/app/src/test/java/androidx/media3/mpvplayer/MpvSurfaceTeardownPolicyTest.java b/app/src/test/java/androidx/media3/mpvplayer/MpvSurfaceTeardownPolicyTest.java deleted file mode 100644 index 58e69a767e..0000000000 --- a/app/src/test/java/androidx/media3/mpvplayer/MpvSurfaceTeardownPolicyTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package androidx.media3.mpvplayer; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class MpvSurfaceTeardownPolicyTest { - - @Test - public void transientSurfaceLossKeepsAttachAndDetachEnabled() { - MpvSurfaceTeardownPolicy policy = new MpvSurfaceTeardownPolicy(); - - assertTrue(policy.shouldBindSurface()); - assertTrue(policy.shouldDetachSurface()); - } - - @Test - public void terminalReleaseBlocksAttachAndDetach() { - MpvSurfaceTeardownPolicy policy = new MpvSurfaceTeardownPolicy(); - - assertTrue(policy.requestTerminalRelease()); - assertFalse(policy.shouldBindSurface()); - assertFalse(policy.shouldDetachSurface()); - } - - @Test - public void repeatedTerminalReleaseIsIdempotent() { - MpvSurfaceTeardownPolicy policy = new MpvSurfaceTeardownPolicy(); - - assertTrue(policy.requestTerminalRelease()); - assertFalse(policy.requestTerminalRelease()); - assertFalse(policy.shouldBindSurface()); - assertFalse(policy.shouldDetachSurface()); - } - - @Test - public void newMediaReopensBindingsAfterServiceRetainsPlayer() { - MpvSurfaceTeardownPolicy policy = new MpvSurfaceTeardownPolicy(); - - policy.requestTerminalRelease(); - assertFalse(policy.shouldBindSurface()); - assertFalse(policy.shouldDetachSurface()); - - policy.beginNewMedia(); - assertTrue(policy.shouldBindSurface()); - assertTrue(policy.shouldDetachSurface()); - - assertTrue(policy.requestTerminalRelease()); - assertFalse(policy.shouldBindSurface()); - assertFalse(policy.shouldDetachSurface()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/AppResourcesWebViewCompatibilityTest.java b/app/src/test/java/com/fongmi/android/tv/AppResourcesWebViewCompatibilityTest.java index 2c4b09f2c5..2161907b5c 100644 --- a/app/src/test/java/com/fongmi/android/tv/AppResourcesWebViewCompatibilityTest.java +++ b/app/src/test/java/com/fongmi/android/tv/AppResourcesWebViewCompatibilityTest.java @@ -25,30 +25,6 @@ public class AppResourcesWebViewCompatibilityTest { source.contains("resources = Setting.wrapLanguage(getBaseContext()).getResources();")); } - @Test - public void applicationResourcesUpdateIsSerializedAndFailSafe() throws Exception { - String source = read(findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "App.java"))); - - assertTrue("Application resources lookups are reachable from background threads, so the language update must run under a lock", - source.contains("synchronized (resourcesLock) {")); - assertTrue("A failed language update must not propagate out of getResources into every resource consumer", - source.contains("} catch (RuntimeException e) {")); - assertTrue("Application must still return the framework-owned Resources when the language update fails", - source.contains("return resources;")); - assertTrue("A failed language update must not be cached as applied, otherwise the degradation becomes permanent until the next language switch", - source.contains("resourcesLanguage = Integer.MIN_VALUE;")); - assertTrue("A failing language update must be retried, not cached forever as a silent degrade", - source.contains("private boolean shouldRetryResourcesApply(int language) {")); - assertTrue("Retries must be bounded so the getResources hot path cannot recompute a failing configuration on every call", - source.contains("MAX_RESOURCES_APPLY_ATTEMPTS")); - int invalidate = source.indexOf("public void invalidateResources() {"); - assertTrue("Resource cache invalidation must remain the single place that drops the cache", invalidate > 0); - assertTrue("Resource cache invalidation must take the same lock as the update path", - source.indexOf("synchronized (resourcesLock) {", invalidate) > invalidate); - assertTrue("Explicit invalidation must also clear the failure budget, otherwise a language that once failed can never recover", - source.indexOf("resourcesFailures = 0;", invalidate) > invalidate); - } - private static String read(Path path) throws Exception { return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); } diff --git a/app/src/test/java/com/fongmi/android/tv/AppStartupWarmupSourceTest.java b/app/src/test/java/com/fongmi/android/tv/AppStartupWarmupSourceTest.java deleted file mode 100644 index 2740cbe55e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/AppStartupWarmupSourceTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.fongmi.android.tv; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * 冷启动播放页时,主线程曾花约 0.9s 同步启动 CPython - * (SubtitleProviderRegistry -> SubtitleSourceManager -> PythonSubtitleScriptRuntime -> Loader), - * 因为这条链挂在 VideoActivity / TmdbDetailActivity 的构造期。 - * 现在注册表改在 App 启动时由后台线程预热,用户点进播放页时成本已经付完。 - * 这个测试锁住预热的存在、它的后台线程下放、以及 App.onCreate 的主线程顺序没有被破坏。 - */ -public class AppStartupWarmupSourceTest { - - @Test - public void theApplicationWarmsUpSubtitleProvidersInTheBackground() throws Exception { - String source = readSource(); - String warmup = methodBody(source, "private void warmSubtitleProviders()"); - assertTrue("the registry must still be resolved by the warmup", - warmup.contains("SubtitleProviderRegistry::get")); - assertFalse("the warmup must not run the registry bootstrap on the caller thread", - warmup.contains("SubtitleProviderRegistry.get();")); - assertTrue("the warmup must be handed to the shared background executor", - warmup.contains("Task.execute(")); - } - - @Test - public void onCreateStillCallsTheWarmupBeforeTheDeferredStarters() throws Exception { - String body = methodBody(readSource(), "public void onCreate()"); - int warmup = body.indexOf("warmSubtitleProviders();"); - int deferred = body.indexOf("post(() -> CacheScheduler.get().start()"); - assertTrue("onCreate must warm the subtitle providers up", warmup >= 0); - assertTrue("the warmup must not displace the existing deferred cache starter", deferred > warmup); - assertTrue("the existing startup contract must stay intact", - body.contains("ThemeController.applyNightModeToApp();")); - assertTrue("the existing startup contract must stay intact", - body.contains("resumeBackgroundServices();")); - } - - private static String readSource() throws Exception { - Path moduleRelative = Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "App.java"); - Path path = Files.exists(moduleRelative) ? moduleRelative : Path.of("app").resolve(moduleRelative); - return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("source is missing " + signature, start >= 0); - int open = source.indexOf('{', start + signature.length()); - assertTrue("source is missing the body of " + signature, open > start); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char current = source.charAt(i); - if (current == '{') depth++; - else if (current == '}' && --depth == 0) return source.substring(start, i + 1); - } - throw new AssertionError("unclosed body for " + signature); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/UpdaterFutureTest.java b/app/src/test/java/com/fongmi/android/tv/UpdaterFutureTest.java index 2d00c10a1b..c106efd38a 100644 --- a/app/src/test/java/com/fongmi/android/tv/UpdaterFutureTest.java +++ b/app/src/test/java/com/fongmi/android/tv/UpdaterFutureTest.java @@ -10,7 +10,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class UpdaterFutureTest { @@ -40,31 +39,4 @@ public class UpdaterFutureTest { assertTrue("Fixed manifest, mirror, release API, asset API and release notes must fit in the channel deadline", total >= perRequest * 5); } - - @Test - public void unreadableArchiveSignatureRequiresVerifiedChecksum() { - assertFalse(Updater.canAcceptUnreadableArchiveSignature(false)); - assertTrue(Updater.canAcceptUnreadableArchiveSignature(true)); - } - - @Test - public void updateManifestRequiresStrictVersionFields() { - Update missingCode = Update.empty(Update.CHANNEL_BETA); - missingCode.name = "5.7.0"; - missingCode.versionName = "5.7.0"; - missingCode.githubUrl = "https://example.invalid/app.apk"; - assertFalse(missingCode.hasManifest()); - - Update missingName = Update.empty(Update.CHANNEL_BETA); - missingName.code = 561; - missingName.githubUrl = "https://example.invalid/app.apk"; - assertFalse(missingName.hasManifest()); - - Update valid = Update.empty(Update.CHANNEL_BETA); - valid.name = "5.7.0"; - valid.versionName = "5.7.0"; - valid.code = 561; - valid.githubUrl = "https://example.invalid/app.apk"; - assertTrue(valid.hasManifest()); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioConsumerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioConsumerTest.java new file mode 100644 index 0000000000..ebb5a06f99 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioConsumerTest.java @@ -0,0 +1,164 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +public class AdAudioConsumerTest { + + @Test + public void resetFinishesOldMatcherAndDropsLateResult() { + FakeMatcherFactory factory = new FakeMatcherFactory(); + List candidates = new ArrayList<>(); + AdAudioConsumer consumer = new AdAudioConsumer(factory, candidates::add, 4); + consumer.start(11L, 3L, AudioFingerprintRuleSet.empty()); + consumer.onPcm(new PlaybackMediaSignalHub.PcmFrame(11L, 3L, new float[]{0.1f}, 16_000, 0L)); + consumer.onLifecycle(new PlaybackMediaSignalHub.Lifecycle( + 11L, 4L, PlaybackMediaSignalHub.ResetReason.SEEK, 20_000L)); + factory.old().finishEvents = List.of(event(AudioFingerprintMatcher.Type.FULL_MATCHED, "old")); + + consumer.drainForTest(); + + assertTrue(candidates.isEmpty()); + assertEquals(1, factory.old().finishedCount); + assertEquals(4L, consumer.generation()); + } + + @Test + public void pcmIsConvertedToSaturatedPcm16AndEventsBecomeCandidates() { + FakeMatcherFactory factory = new FakeMatcherFactory(); + List candidates = new ArrayList<>(); + AdAudioConsumer consumer = new AdAudioConsumer(factory, candidates::add, 4); + consumer.start(1L, 2L, AudioFingerprintRuleSet.empty()); + + consumer.onPcm(new PlaybackMediaSignalHub.PcmFrame( + 1L, 2L, new float[]{-1.0f, 0.5f, 1.2f}, 16_000, 123L)); + consumer.drainForTest(); + + assertEquals(List.of((short) -32768, (short) 16384, (short) 32767), factory.current().lastSamples); + assertEquals(1, candidates.size()); + assertEquals(123L, candidates.get(0).event().startTimeMs()); + } + + @Test + public void fullQueueDropsOldestFrameAndReportsOverflow() { + FakeMatcherFactory factory = new FakeMatcherFactory(); + AdAudioConsumer consumer = new AdAudioConsumer(factory, candidate -> { }, 2); + consumer.start(1L, 1L, AudioFingerprintRuleSet.empty()); + + consumer.onPcm(frame(1L)); + consumer.onPcm(frame(2L)); + consumer.onPcm(frame(3L)); + consumer.drainForTest(); + + assertEquals(1, consumer.queueOverflowCount()); + assertEquals(3L, factory.current().lastCaptureStartMs); + } + + @Test + public void matcherFailureIsIsolatedAndConsumerContinues() { + FakeMatcherFactory factory = new FakeMatcherFactory(); + AdAudioConsumer consumer = new AdAudioConsumer(factory, candidate -> { }, 2); + consumer.start(1L, 1L, AudioFingerprintRuleSet.empty()); + factory.current().throwOnFeed = true; + + consumer.onPcm(frame(1L)); + consumer.drainForTest(); + factory.current().throwOnFeed = false; + consumer.onPcm(frame(2L)); + consumer.drainForTest(); + + assertEquals(1, consumer.matcherErrorCount()); + assertEquals(2L, factory.current().lastCaptureStartMs); + } + + @Test + public void sourceChangeStartsMatcherForTheNewSession() { + FakeMatcherFactory factory = new FakeMatcherFactory(); + List candidates = new ArrayList<>(); + AdAudioConsumer consumer = new AdAudioConsumer(factory, candidates::add, 2); + consumer.start(1L, 3L, AudioFingerprintRuleSet.empty()); + + consumer.onLifecycle(new PlaybackMediaSignalHub.Lifecycle( + 2L, 0L, PlaybackMediaSignalHub.ResetReason.SOURCE_CHANGED, 0L)); + consumer.onPcm(new PlaybackMediaSignalHub.PcmFrame( + 2L, 0L, new float[]{0.1f}, 16_000, 0L)); + consumer.drainForTest(); + + assertEquals(1, candidates.size()); + assertEquals(2L, candidates.get(0).sessionId()); + assertEquals(0L, candidates.get(0).generation()); + assertEquals(2, factory.created.size()); + } + + @Test + public void rejectedWorkerIsReportedWithoutEscapingAudioCallback() { + AdAudioDiagnostics diagnostics = new AdAudioDiagnostics(); + AdAudioConsumer consumer = new AdAudioConsumer( + ruleSet -> new FakeMatcher(), candidate -> { }, 2, + command -> { throw new IllegalStateException("worker rejected"); }, diagnostics); + consumer.start(1L, 1L, AudioFingerprintRuleSet.empty()); + + consumer.onPcm(frame(1L)); + + assertEquals(1L, diagnostics.count(AdAudioDiagnostics.Code.MATCHER_ERROR)); + } + + private static PlaybackMediaSignalHub.PcmFrame frame(long startMs) { + return new PlaybackMediaSignalHub.PcmFrame(1L, 1L, new float[]{0.1f}, 16_000, startMs); + } + + private static AudioFingerprintMatcher.MatchEvent event(AudioFingerprintMatcher.Type type, String id) { + return new AudioFingerprintMatcher.MatchEvent(type, id, 123L, 456L, 0.9f, 6); + } + + private static final class FakeMatcherFactory implements AdAudioConsumer.MatcherFactory { + private final List created = new ArrayList<>(); + + @Override + public AdAudioConsumer.Matcher create(AudioFingerprintRuleSet ruleSet) { + FakeMatcher matcher = new FakeMatcher(); + created.add(matcher); + return matcher; + } + + FakeMatcher current() { + return created.get(created.size() - 1); + } + + FakeMatcher old() { + return created.get(0); + } + } + + private static final class FakeMatcher implements AdAudioConsumer.Matcher { + private List feedEvents = List.of(event(AudioFingerprintMatcher.Type.START_MATCHED, "ad")); + private List finishEvents = List.of(); + private boolean throwOnFeed; + private int finishedCount; + private long lastCaptureStartMs = -1L; + private List lastSamples = List.of(); + + @Override + public List feed(short[] samples, int sampleRate, long captureStartMs) { + if (throwOnFeed) throw new IllegalStateException("test matcher failure"); + lastCaptureStartMs = captureStartMs; + List values = new ArrayList<>(samples.length); + for (short sample : samples) values.add(sample); + lastSamples = values; + return feedEvents; + } + + @Override + public List finish() { + finishedCount++; + return finishEvents; + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDetectionMultiplexerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDetectionMultiplexerTest.java new file mode 100644 index 0000000000..853f0e786f --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDetectionMultiplexerTest.java @@ -0,0 +1,200 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class AdAudioDetectionMultiplexerTest { + + @Test + public void duplicateCandidatesFromTwoProvidersAreEmittedOnce() { + RecordingListener output = new RecordingListener(); + AdAudioDetectionMultiplexer mux = multiplexer(8, output); + + mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 3_000L, true, 0.91d)); + mux.onCandidate(candidate("probe", "rule-1", 1_000L, 3_000L, true, 0.98d)); + + assertEquals(1, output.candidates.size()); + assertEquals(1L, mux.diagnostics().emitted()); + assertEquals(1L, mux.diagnostics().duplicates()); + } + + @Test + public void fullMatchUpgradesAnAlreadyEmittedStartExactlyOnce() { + RecordingListener output = new RecordingListener(); + AdAudioDetectionMultiplexer mux = multiplexer(8, output); + + mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 3_000L, false, 0.85d)); + mux.onCandidate(candidate("probe", "rule-1", 1_000L, 3_000L, true, 0.96d)); + mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 3_000L, true, 0.97d)); + + assertEquals(2, output.candidates.size()); + assertTrue(output.candidates.get(1).fullMatch()); + assertEquals(1L, mux.diagnostics().upgrades()); + assertEquals(1L, mux.diagnostics().duplicates()); + } + + @Test + public void staleGenerationAndWrongRuleVersionAreDropped() { + RecordingListener output = new RecordingListener(); + AdAudioDetectionMultiplexer mux = multiplexer(8, output); + + mux.onCandidate(new AdAudioSignalProvider.AdAudioCandidate( + 7L, 1L, "rule-1", "rules-v1", 1_000L, 3_000L, + true, 0.9d, "pcm")); + mux.onCandidate(new AdAudioSignalProvider.AdAudioCandidate( + 7L, 2L, "rule-1", "rules-v0", 1_000L, 3_000L, + true, 0.9d, "probe")); + + assertTrue(output.candidates.isEmpty()); + assertEquals(2L, mux.diagnostics().stale()); + } + + @Test + public void timelineResetClearsDedupAndRejectsOldCallbacks() { + RecordingListener output = new RecordingListener(); + AdAudioDetectionMultiplexer mux = multiplexer(8, output); + mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 3_000L, true, 0.9d)); + + mux.onTimelineReset(new AdAudioSignalProvider.TimelineReset( + 7L, 3L, AdAudioSignalProvider.ResetReason.SEEK, 5_000L)); + mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 3_000L, true, 0.9d)); + mux.onCandidate(new AdAudioSignalProvider.AdAudioCandidate( + 7L, 3L, "rule-1", "rules-v1", 1_000L, 3_000L, + true, 0.9d, "pcm")); + + assertEquals(2, output.candidates.size()); + assertEquals(1, output.resets.size()); + assertEquals(1L, mux.diagnostics().resets()); + assertEquals(1L, mux.diagnostics().stale()); + } + + @Test + public void oversizedIntervalsAreRejectedBeforeStateAllocation() { + RecordingListener output = new RecordingListener(); + AdAudioDetectionMultiplexer mux = multiplexer(8, output); + + mux.onCandidate(candidate( + "probe", "rule-1", 1_000L, 1_000L + 600_001L, true, 0.9d)); + + assertTrue(output.candidates.isEmpty()); + assertEquals(0, mux.trackedCandidateCount()); + assertEquals(1L, mux.diagnostics().invalid()); + } + + @Test + public void unknownRuleIdsAreRejectedBeforeStateAllocation() { + RecordingListener output = new RecordingListener(); + AdAudioDetectionMultiplexer mux = multiplexer(8, output); + + mux.onCandidate(candidate( + "probe", "untrusted-rule", 1_000L, 3_000L, true, 0.9d)); + + assertTrue(output.candidates.isEmpty()); + assertEquals(0, mux.trackedCandidateCount()); + assertEquals(1L, mux.diagnostics().invalid()); + } + + @Test + public void speechRulePassesOnlyWhenExplicitlyAllowed() { + RecordingListener output = new RecordingListener(); + AdAudioDetectionMultiplexer mux = new AdAudioDetectionMultiplexer( + new AdAudioSignalProvider.SessionContext( + 7L, 2L, "media", "https://media.example/video.m3u8", Map.of()), + "rules-v1", Set.of(SpeechAdSignalProvider.RULE_ID), 8, output); + + mux.onCandidate(candidate( + SpeechAdSignalProvider.ID, SpeechAdSignalProvider.RULE_ID, + 1_000L, 3_000L, true, 1.0d)); + mux.onCandidate(candidate( + SpeechAdSignalProvider.ID, "untrusted-speech-rule", + 4_000L, 6_000L, true, 1.0d)); + + assertEquals(1, output.candidates.size()); + assertEquals(SpeechAdSignalProvider.RULE_ID, + output.candidates.get(0).ruleId()); + assertEquals(1L, mux.diagnostics().invalid()); + } + @Test + public void boundedStateEvictsOldestCandidates() { + RecordingListener output = new RecordingListener(); + AdAudioDetectionMultiplexer mux = multiplexer(2, output); + + mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 2_000L, true, 0.9d)); + mux.onCandidate(candidate("pcm", "rule-2", 2_000L, 3_000L, true, 0.9d)); + mux.onCandidate(candidate("pcm", "rule-3", 3_000L, 4_000L, true, 0.9d)); + mux.onCandidate(candidate("probe", "rule-1", 1_000L, 2_000L, true, 0.9d)); + + assertEquals(4, output.candidates.size()); + assertEquals(2, mux.trackedCandidateCount()); + assertEquals(2L, mux.diagnostics().evicted()); + } + + @Test + public void downstreamFailuresAndProviderErrorsAreIsolatedAndCounted() { + AdAudioSignalProvider.Listener throwing = new AdAudioSignalProvider.Listener() { + @Override + public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { + throw new IllegalStateException("candidate sink failed"); + } + + @Override + public void onProviderError(AdAudioSignalProvider.ProviderError error) { + throw new IllegalStateException("error sink failed"); + } + }; + AdAudioDetectionMultiplexer mux = multiplexer(8, throwing); + + mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 2_000L, true, 0.9d)); + mux.onProviderError(new AdAudioSignalProvider.ProviderError( + "probe", AdAudioSignalProvider.ErrorCode.ANALYSIS_FAILED, "decode failed")); + + assertEquals(1L, mux.diagnostics().emitted()); + assertEquals(1L, mux.diagnostics().providerErrors()); + assertEquals(2L, mux.diagnostics().listenerErrors()); + } + + private static AdAudioDetectionMultiplexer multiplexer( + int capacity, AdAudioSignalProvider.Listener listener) { + return new AdAudioDetectionMultiplexer( + new AdAudioSignalProvider.SessionContext( + 7L, 2L, "media", "https://media.example/video.m3u8", Map.of()), + "rules-v1", Set.of("rule-1", "rule-2", "rule-3"), + capacity, listener); + } + + private static AdAudioSignalProvider.AdAudioCandidate candidate( + String providerId, String ruleId, long startMs, long endMs, + boolean fullMatch, double similarity) { + return new AdAudioSignalProvider.AdAudioCandidate( + 7L, 2L, ruleId, "rules-v1", startMs, endMs, + fullMatch, similarity, providerId); + } + + private static final class RecordingListener implements AdAudioSignalProvider.Listener { + private final List candidates = new ArrayList<>(); + private final List errors = new ArrayList<>(); + private final List resets = new ArrayList<>(); + + @Override + public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { + candidates.add(candidate); + } + + @Override + public void onProviderError(AdAudioSignalProvider.ProviderError error) { + errors.add(error); + } + + @Override + public void onTimelineReset(AdAudioSignalProvider.TimelineReset reset) { + resets.add(reset); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDiagnosticsTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDiagnosticsTest.java new file mode 100644 index 0000000000..bf9fab8449 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDiagnosticsTest.java @@ -0,0 +1,50 @@ +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 org.junit.Test; + +public class AdAudioDiagnosticsTest { + + @Test + public void snapshotContainsOnlyFixedCodeCounters() { + AdAudioDiagnostics diagnostics = new AdAudioDiagnostics(); + diagnostics.record(AdAudioDiagnostics.Code.QUEUE_OVERFLOW); + diagnostics.record(AdAudioDiagnostics.Code.QUEUE_OVERFLOW); + diagnostics.record(AdAudioDiagnostics.Code.SEEK_REJECTED); + + AdAudioDiagnostics.Snapshot snapshot = diagnostics.snapshot(); + + assertEquals(2L, snapshot.count(AdAudioDiagnostics.Code.QUEUE_OVERFLOW)); + assertEquals(1L, snapshot.count(AdAudioDiagnostics.Code.SEEK_REJECTED)); + assertEquals(AdAudioDiagnostics.Code.SEEK_REJECTED, snapshot.lastCode()); + } + + @Test + public void repeatedCodesAreSampledSoTheAudioThreadNeverLogsPerFrame() { + // record() is reachable once per PCM frame (STALE_GENERATION after a flush, + // QUEUE_OVERFLOW while the recognizer lags) and each log line is a synchronous + // file write, so only the first few plus a decayed tail may be emitted. + assertTrue(AdAudioDiagnostics.shouldLogCount(1L)); + assertTrue(AdAudioDiagnostics.shouldLogCount(3L)); + assertFalse(AdAudioDiagnostics.shouldLogCount(4L)); + assertFalse(AdAudioDiagnostics.shouldLogCount(99L)); + assertTrue(AdAudioDiagnostics.shouldLogCount(100L)); + assertFalse(AdAudioDiagnostics.shouldLogCount(101L)); + + long logged = 0; + for (long i = 1; i <= 5_000L; i++) if (AdAudioDiagnostics.shouldLogCount(i)) logged++; + assertEquals(53L, logged); + } + + @Test + public void countersStayAccurateEvenWhenLoggingIsSampled() { + AdAudioDiagnostics diagnostics = new AdAudioDiagnostics(); + for (int i = 0; i < 250; i++) { + diagnostics.record(AdAudioDiagnostics.Code.STALE_GENERATION); + } + assertEquals(250L, diagnostics.count(AdAudioDiagnostics.Code.STALE_GENERATION)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleSourceContractTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleSourceContractTest.java new file mode 100644 index 0000000000..7a96164b96 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleSourceContractTest.java @@ -0,0 +1,42 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Path; + +public class AdAudioRuleSourceContractTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void localStoreImplementsStableSourceContract() throws Exception { + Path directory = temporaryFolder.newFolder("rules").toPath(); + AdAudioRuleSource source = new AdAudioRuleStore(directory); + + AdAudioRuleSnapshot snapshot = source.load(); + + assertEquals("local", snapshot.sourceId()); + assertFalse(snapshot.hasRules()); + assertNull(snapshot.probeSidecar()); + assertFalse(snapshot.probeAvailable()); + assertThrows(UnsupportedOperationException.class, + () -> snapshot.warnings().add("mutable")); + } + + @Test + public void legacySnapshotRemainsWithoutProbeSidecar() { + AdAudioRuleSnapshot snapshot = new AdAudioRuleSnapshot( + "local", "", AudioFingerprintRuleSet.empty(), java.util.List.of(), ""); + + assertNull(snapshot.probeSidecar()); + assertFalse(snapshot.probeAvailable()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleStoreTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleStoreTest.java new file mode 100644 index 0000000000..88f33c36c7 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleStoreTest.java @@ -0,0 +1,131 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class AdAudioRuleStoreTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void importedSnapshotSurvivesRestart() throws Exception { + Path directory = temporaryFolder.newFolder().toPath(); + + AdAudioRuleSnapshot imported = new AdAudioRuleStore(directory).importJson(validRuleJson("rule-a")); + AdAudioRuleSnapshot reloaded = new AdAudioRuleStore(directory).load(); + + assertEquals("rule-a", imported.ruleSet().rules().get(0).id()); + assertEquals("rule-a", reloaded.ruleSet().rules().get(0).id()); + assertEquals(imported.version(), reloaded.version()); + assertFalse(reloaded.hasError()); + } + + /** 社区 Probe Rules v1 可以直接导入,落盘时被规范化成本项目的 v2 格式。 */ + @Test + public void probeV1RulesImportAndReloadAsCanonicalV2() throws Exception { + Path directory = temporaryFolder.newFolder().toPath(); + String probeJson; + try (InputStream stream = getClass().getResourceAsStream("/probe-rules-v1-community.json")) { + assertNotNull(stream); + probeJson = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + AdAudioRuleSnapshot imported = new AdAudioRuleStore(directory).importJson(probeJson); + AdAudioRuleSnapshot reloaded = new AdAudioRuleStore(directory).load(); + String stored = Files.readString(directory.resolve("ad-audio-rules.json"), StandardCharsets.UTF_8); + + assertEquals(4, imported.ruleSet().rules().size()); + assertEquals(4, reloaded.ruleSet().rules().size()); + assertEquals(imported.version(), reloaded.version()); + assertFalse(reloaded.hasError()); + assertTrue(stored.contains(AudioFingerprintRuleSet.ALGORITHM_ID)); + assertFalse(stored.contains(ProbeRuleCodec.FORMAT)); + } + + /** + * v2 的规则 id 是自由字符串,所以 id 里出现 Probe 的 format 值时不能被误判成 v1。 + * 用子串匹配分流会让这个合法的 v2 文件整包被拒。 + */ + @Test + public void v2RuleIdContainingTheProbeFormatValueStillImportsAsV2() throws Exception { + Path directory = temporaryFolder.newFolder().toPath(); + + AdAudioRuleSnapshot imported = + new AdAudioRuleStore(directory).importJson(validRuleJson(ProbeRuleCodec.FORMAT)); + + assertEquals(ProbeRuleCodec.FORMAT, imported.ruleSet().rules().get(0).id()); + } + + @Test + public void invalidImportDoesNotReplaceLastGoodSnapshot() throws Exception { + Path directory = temporaryFolder.newFolder().toPath(); + AdAudioRuleStore store = new AdAudioRuleStore(directory); + store.importJson(validRuleJson("rule-a")); + + assertThrows(IllegalArgumentException.class, () -> store.importJson("{\"rules\":[")); + + AdAudioRuleSnapshot reloaded = new AdAudioRuleStore(directory).load(); + assertEquals("rule-a", reloaded.ruleSet().rules().get(0).id()); + assertFalse(Files.exists(directory.resolve("ad-audio-rules.json.tmp"))); + } + + @Test + public void oversizedImportDoesNotReplaceLastGoodSnapshot() throws Exception { + Path directory = temporaryFolder.newFolder().toPath(); + AdAudioRuleStore store = new AdAudioRuleStore(directory); + store.importJson(validRuleJson("rule-a")); + + String oversized = " ".repeat(AdAudioRuleStore.MAX_IMPORT_BYTES + 1); + assertThrows(IllegalArgumentException.class, () -> store.importJson(oversized)); + + assertEquals("rule-a", new AdAudioRuleStore(directory).load().ruleSet().rules().get(0).id()); + } + + @Test + public void corruptPersistedFileProducesErrorSnapshot() throws Exception { + Path directory = temporaryFolder.newFolder().toPath(); + Files.writeString(directory.resolve("ad-audio-rules.json"), "not-json"); + + AdAudioRuleSnapshot snapshot = new AdAudioRuleStore(directory).load(); + + assertTrue(snapshot.ruleSet().rules().isEmpty()); + assertTrue(snapshot.hasError()); + } + + @Test + public void clearRemovesRulesAndTemporaryFile() throws Exception { + Path directory = temporaryFolder.newFolder().toPath(); + AdAudioRuleStore store = new AdAudioRuleStore(directory); + store.importJson(validRuleJson("rule-a")); + Files.writeString(directory.resolve("ad-audio-rules.json.tmp"), "partial"); + + AdAudioRuleSnapshot snapshot = store.clear(); + + assertTrue(snapshot.ruleSet().rules().isEmpty()); + assertFalse(Files.exists(directory.resolve("ad-audio-rules.json"))); + assertFalse(Files.exists(directory.resolve("ad-audio-rules.json.tmp"))); + } + + private static String validRuleJson(String id) { + return "{" + + "\"schemaVersion\":2," + + "\"algorithm\":{\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," + + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16}," + + "\"rules\":[{\"id\":\"" + id + "\",\"durationMs\":15000,\"anchorOffsetMs\":0," + + "\"anchorDurationMs\":3000,\"fingerprint\":[" + + "\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]}]}"; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerTest.java new file mode 100644 index 0000000000..2488c2222a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerTest.java @@ -0,0 +1,770 @@ +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 com.fongmi.android.tv.subtitle.SpeechRecognitionFactory; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +public class AdAudioRuntimeControllerTest { + + @Test + public void enabledRulesRequestCaptureOnlyWhileEligibleUiIsBound() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + AdAudioRuntimeController runtime = runtime(hub, playback, goodSnapshot()); + + runtime.start(true); + assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + runtime.bindUi(new FakeUiPort()); + assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + assertTrue(runtime.needsPipelineRebuild()); + + runtime.unbindUi(); + assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + runtime.close(); + } + + @Test + public void disabledEmptyAndIneligibleConfigurationsNeverCapture() { + PlaybackMediaSignalHub disabledHub = new PlaybackMediaSignalHub(8); + disabledHub.beginSession(0L); + AdAudioRuntimeController disabled = runtime( + disabledHub, new FakePlaybackPort(disabledHub, true), goodSnapshot()); + disabled.start(false); + disabled.bindUi(new FakeUiPort()); + + PlaybackMediaSignalHub emptyHub = new PlaybackMediaSignalHub(8); + emptyHub.beginSession(0L); + AdAudioRuntimeController empty = runtime( + emptyHub, new FakePlaybackPort(emptyHub, true), emptySnapshot()); + empty.start(true); + empty.bindUi(new FakeUiPort()); + + PlaybackMediaSignalHub ineligibleHub = new PlaybackMediaSignalHub(8); + ineligibleHub.beginSession(0L); + AdAudioRuntimeController ineligible = runtime( + ineligibleHub, new FakePlaybackPort(ineligibleHub, false), goodSnapshot()); + ineligible.start(true); + ineligible.bindUi(new FakeUiPort()); + + assertFalse(disabledHub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + assertFalse(emptyHub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + assertFalse(ineligibleHub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + disabled.close(); + empty.close(); + ineligible.close(); + } + + @Test + public void refreshReleasesCaptureWhenPlaybackBecomesLive() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + AdAudioRuntimeController runtime = runtime(hub, playback, goodSnapshot()); + runtime.start(true); + runtime.bindUi(new FakeUiPort()); + assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + + playback.eligible = false; + runtime.refresh(); + + assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + runtime.close(); + } + + @Test + public void ruleSnapshotErrorIsReportedAndCaptureStaysDisabled() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + AdAudioRuntimeController runtime = runtime( + hub, new FakePlaybackPort(hub, true), errorSnapshot()); + + runtime.start(true); + runtime.bindUi(new FakeUiPort()); + + assertEquals(1L, runtime.diagnostics().count( + AdAudioDiagnostics.Code.RULE_LOAD_FAILED)); + assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + runtime.close(); + } + + @Test + public void nullRuleSnapshotIsReportedAndCaptureStaysDisabled() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + AdAudioRuntimeController runtime = new AdAudioRuntimeController( + hub, new PlaybackMediaClock(500L), () -> null, playback, + Runnable::run, () -> { }); + + runtime.start(true); + runtime.bindUi(new FakeUiPort()); + + assertEquals(1L, runtime.diagnostics().count( + AdAudioDiagnostics.Code.RULE_LOAD_FAILED)); + assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + runtime.close(); + } + + @Test + public void reloadRulesReplacesTheActiveMatcher() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + PlaybackMediaSignalHub.Session firstSession = hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + MutableRuleSource rules = new MutableRuleSource(snapshotForRule("old-ad")); + AdAudioRuntimeController runtime = new AdAudioRuntimeController( + hub, new PlaybackMediaClock(500L), rules, playback, + Runnable::run, () -> { }); + FakeUiPort ui = new FakeUiPort(); + runtime.start(true); + runtime.bindUi(ui); + + rules.snapshot = snapshotForRule("new-ad"); + runtime.reloadRules(); + PlaybackMediaSignalHub.Session nextSession = hub.beginSession(0L); + hub.publishPcm(nextSession.frame(chirp16k(), 16_000, 0L)); + + assertEquals("new-ad", ui.lastPrompt.ruleId()); + assertTrue(firstSession.id() != nextSession.id()); + runtime.close(); + } + + @Test + public void repeatedStartReplacesTheActiveMatcher() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + MutableRuleSource rules = new MutableRuleSource(snapshotForRule("old-ad")); + AdAudioRuntimeController runtime = new AdAudioRuntimeController( + hub, new PlaybackMediaClock(500L), rules, playback, + Runnable::run, () -> { }); + FakeUiPort ui = new FakeUiPort(); + runtime.start(true); + runtime.bindUi(ui); + + rules.snapshot = snapshotForRule("new-ad"); + runtime.start(true); + PlaybackMediaSignalHub.Session session = hub.beginSession(0L); + hub.publishPcm(session.frame(chirp16k(), 16_000, 0L)); + + assertEquals("new-ad", ui.lastPrompt.ruleId()); + runtime.close(); + } + + @Test + public void probeProviderStartsOnlyWhenSnapshotHasVerifiedSidecar() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + FakeSignalProvider probe = new FakeSignalProvider("probe"); + AtomicInteger factoryCalls = new AtomicInteger(); + AdAudioRuntimeController runtime = runtimeWithProbe( + hub, playback, snapshotForRuleWithSidecar("ad"), sidecar -> { + factoryCalls.incrementAndGet(); + return probe; + }); + + runtime.start(true); + runtime.bindUi(new FakeUiPort()); + + assertEquals(1, factoryCalls.get()); + assertEquals(1, probe.starts); + assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, probe.state()); + runtime.close(); + + PlaybackMediaSignalHub noSidecarHub = new PlaybackMediaSignalHub(8); + noSidecarHub.beginSession(0L); + AdAudioRuntimeController noSidecar = runtimeWithProbe( + noSidecarHub, new FakePlaybackPort(noSidecarHub, true), + snapshotForRule("ad"), sidecar -> { + factoryCalls.incrementAndGet(); + return new FakeSignalProvider("unexpected"); + }); + noSidecar.start(true); + noSidecar.bindUi(new FakeUiPort()); + + assertEquals(1, factoryCalls.get()); + noSidecar.close(); + } + + @Test + public void duplicateProbeCandidatesReachThePromptOnlyOnce() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + PlaybackMediaSignalHub.Session session = hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + FakeSignalProvider probe = new FakeSignalProvider("probe"); + AdAudioRuntimeController runtime = runtimeWithProbe( + hub, playback, snapshotForRuleWithSidecar("ad"), sidecar -> probe); + FakeUiPort ui = new FakeUiPort(); + runtime.start(true); + runtime.bindUi(ui); + AdAudioSignalProvider.AdAudioCandidate candidate = providerCandidate( + session, "ad", 0L, 10_000L); + + probe.emit(candidate); + probe.emit(candidate); + + assertEquals(1, ui.candidateShows); + assertEquals(AdSkipPolicyController.Mode.PROMPT, runtime.skipMode()); + runtime.close(); + } + + @Test + public void switchingToAutoDuringPlaybackAppliesOnlyLaterProbeCandidates() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + PlaybackMediaSignalHub.Session session = hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + FakeSignalProvider probe = new FakeSignalProvider("probe"); + AdAudioRuntimeController runtime = runtimeWithProbe( + hub, playback, snapshotForRulesWithSidecar( + "prompt-ad", "auto-ad"), sidecar -> probe); + FakeUiPort ui = new FakeUiPort(); + runtime.start(true); + runtime.bindUi(ui); + + probe.emit(providerCandidate(session, "prompt-ad", 0L, 8_000L)); + ui.actions.ignore(); + runtime.setSkipMode(AdSkipPolicyController.Mode.AUTO); + probe.emit(providerCandidate(session, "auto-ad", 0L, 10_000L)); + + assertEquals(1, ui.candidateShows); + assertEquals(AdSkipPolicyController.Mode.AUTO, runtime.skipMode()); + assertEquals(1, playback.seekTargets.size()); + runtime.close(); + } + + @Test + public void probeFailureAndSuspendDoNotLeakOrDisablePcmFallback() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + FakeSignalProvider probe = new FakeSignalProvider("probe"); + AdAudioRuntimeController runtime = runtimeWithProbe( + hub, playback, snapshotForRuleWithSidecar("ad"), sidecar -> probe); + runtime.start(true); + runtime.bindUi(new FakeUiPort()); + + probe.fail(AdAudioSignalProvider.ErrorCode.ANALYSIS_FAILED); + + assertTrue(runtime.isActive()); + assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + + runtime.suspend(); + + assertEquals(1, probe.closes); + assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + runtime.close(); + } + + @Test + public void speechRunsWithoutFingerprintRulesAndUsesItsOwnAutoMode() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + FakeSignalProvider speech = new FakeSignalProvider(SpeechAdSignalProvider.ID); + AdAudioRuntimeController runtime = runtimeWithProviders( + hub, playback, emptySnapshot(), + ignored -> new NoopAdAudioSignalProvider("probe"), () -> speech); + FakeUiPort ui = new FakeUiPort(); + + runtime.setSpeechConfig(SpeechAdConfig.create( + true, "\u8d4c\u573a", 15, "AUTO")); + runtime.start(false); + runtime.bindUi(ui); + + assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, speech.state()); + speech.emit(SpeechAdSignalProvider.RULE_ID, 10_000L, 25_000L); + assertEquals(List.of(25_000L), playback.seekTargets); + assertEquals(0, ui.candidateShows); + runtime.close(); + } + + @Test + public void speechStartFailureDoesNotStopPcmOrProbe() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + FakeSignalProvider probe = new FakeSignalProvider("probe"); + FakeSignalProvider speech = new FakeSignalProvider(SpeechAdSignalProvider.ID); + speech.failOnStart = true; + AdAudioRuntimeController runtime = runtimeWithProviders( + hub, playback, snapshotForRuleWithSidecar("ad"), + ignored -> probe, () -> speech); + + runtime.setSpeechConfig(SpeechAdConfig.create( + true, "\u8d4c\u573a", 15, "PROMPT")); + runtime.start(true); + runtime.bindUi(new FakeUiPort()); + + assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, probe.state()); + assertTrue(runtime.isActive()); + assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + runtime.close(); + } + + @Test + public void speechModeSwitchAffectsOnlyFutureSpeechCandidates() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + List speeches = new ArrayList<>(); + AdAudioRuntimeController runtime = runtimeWithProviders( + hub, playback, emptySnapshot(), + ignored -> new NoopAdAudioSignalProvider("probe"), () -> { + FakeSignalProvider provider = new FakeSignalProvider( + SpeechAdSignalProvider.ID); + speeches.add(provider); + return provider; + }); + FakeUiPort ui = new FakeUiPort(); + + runtime.setSpeechConfig(SpeechAdConfig.create( + true, "\u8d4c\u573a", 15, "PROMPT")); + runtime.start(false); + runtime.bindUi(ui); + speeches.get(0).emit(SpeechAdSignalProvider.RULE_ID, 1_000L, 16_000L); + ui.actions.ignore(); + + runtime.setSpeechConfig(SpeechAdConfig.create( + true, "\u8d4c\u573a", 15, "AUTO")); + speeches.get(1).emit(SpeechAdSignalProvider.RULE_ID, 30_000L, 45_000L); + + assertEquals(1, ui.candidateShows); + assertEquals(List.of(45_000L), playback.seekTargets); + assertEquals(AdAudioSignalProvider.ProviderState.CLOSED, + speeches.get(0).state()); + runtime.close(); + } + @Test + public void replacingSpeechConfigClosesProductionSessionAndDropsOldCallbacks() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + FakeSpeechRecognitionFactory recognition = new FakeSpeechRecognitionFactory(); + AdAudioRuntimeController runtime = new AdAudioRuntimeController( + hub, new PlaybackMediaClock(500L), + AdAudioRuntimeControllerTest::emptySnapshot, playback, recognition); + FakeUiPort ui = new FakeUiPort(); + + runtime.setSpeechConfig(SpeechAdConfig.create( + true, "\u8d4c\u573a", 15, "PROMPT")); + runtime.start(false); + runtime.bindUi(ui); + FakeSpeechSession first = recognition.sessions.get(0); + + runtime.setSpeechConfig(SpeechAdConfig.create( + true, "\u9996\u5145", 30, "AUTO")); + + assertEquals(1, first.closeCalls); + assertEquals(2, recognition.sessions.size()); + first.listener.onResult("\u8d4c\u573a", 1L, 2L, 1); + assertTrue(playback.seekTargets.isEmpty()); + assertEquals(0, ui.candidateShows); + runtime.close(); + } + @Test + public void rebindingTheSameUiKeepsTheRunningSpeechSession() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + FakeSpeechRecognitionFactory recognition = new FakeSpeechRecognitionFactory(); + AdAudioRuntimeController runtime = new AdAudioRuntimeController( + hub, new PlaybackMediaClock(500L), + AdAudioRuntimeControllerTest::emptySnapshot, playback, recognition); + FakeUiPort ui = new FakeUiPort(); + + runtime.setSpeechConfig(SpeechAdConfig.create( + true, "赌场", 15, "PROMPT")); + runtime.start(false); + runtime.bindUi(ui); + assertEquals(1, recognition.sessions.size()); + + // PlaybackActivity rebinds on every playback state change; that must not tear the + // recognizer down and lose the in-flight utterance. + runtime.bindUi(ui); + runtime.bindUi(ui); + + assertEquals(1, recognition.sessions.size()); + assertEquals(0, recognition.sessions.get(0).closeCalls); + + // The rebind installed a new coordinator. Candidates from the still-running + // provider must reach it, not the discarded one. + hub.publishPcm(hub.session().frame(new float[] {0.1f}, 16_000, 30_000L)); + recognition.sessions.get(0).listener.onResult( + "赌场", 30_000_000L, 31_000_000L, 1); + assertEquals(1, ui.candidateShows); + runtime.close(); + } + + @Test + public void refreshKeepsAnIdleSpeechProviderInsteadOfRebuildingIt() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + // Duration still unknown: mirrors STATE_BUFFERING right after the pipeline rebuild. + playback.positionEligible = false; + FakeSpeechRecognitionFactory recognition = new FakeSpeechRecognitionFactory(); + AdAudioRuntimeController runtime = new AdAudioRuntimeController( + hub, new PlaybackMediaClock(500L), + AdAudioRuntimeControllerTest::emptySnapshot, playback, recognition); + + runtime.setSpeechConfig(SpeechAdConfig.create(true, "赌场", 15, "PROMPT")); + runtime.start(false); + runtime.bindUi(new FakeUiPort()); + // Resources are only acquired once the position is eligible, so nothing exists yet. + assertTrue(recognition.sessions.isEmpty()); + + // Playback flaps between BUFFERING and READY; each flap calls refresh(). An IDLE + // provider must count as live, otherwise every refresh destroys and re-creates the + // recognizer and it never reaches RUNNING. + runtime.refresh(); + runtime.refresh(); + runtime.refresh(); + + assertTrue(recognition.sessions.isEmpty()); + + // Once the duration is known the provider must acquire its recognizer exactly once + // and then survive further refreshes without being rebuilt. + playback.positionEligible = true; + runtime.refresh(); + assertEquals(1, recognition.sessions.size()); + assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + + runtime.refresh(); + runtime.refresh(); + assertEquals(1, recognition.sessions.size()); + assertEquals(0, recognition.sessions.get(0).closeCalls); + + // Falling back to buffering must not tear the recognizer down again. + playback.positionEligible = false; + runtime.refresh(); + assertEquals(1, recognition.sessions.size()); + assertEquals(0, recognition.sessions.get(0).closeCalls); + runtime.close(); + } + + @Test + public void newHubSessionRebuildsAParkedSpeechProviderInsteadOfKeepingItsStaleLease() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub, true); + FakeSpeechRecognitionFactory recognition = new FakeSpeechRecognitionFactory(); + AdAudioRuntimeController runtime = new AdAudioRuntimeController( + hub, new PlaybackMediaClock(500L), + AdAudioRuntimeControllerTest::emptySnapshot, playback, recognition); + + runtime.setSpeechConfig(SpeechAdConfig.create(true, "赌场", 15, "PROMPT")); + runtime.start(false); + runtime.bindUi(new FakeUiPort()); + assertEquals(1, recognition.sessions.size()); + FakeSpeechSession first = recognition.sessions.get(0); + + // A new media session (换源/换集) without a suspend() in between. The parked provider + // holds a capture lease bound to the old session, so it must be rebuilt. + hub.beginSession(0L); + runtime.refresh(); + + assertEquals(1, first.closeCalls); + assertEquals(2, recognition.sessions.size()); + 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 AdAudioRuntimeController runtimeWithProbe( + PlaybackMediaSignalHub hub, FakePlaybackPort playback, + AdAudioRuleSnapshot snapshot, + AdAudioRuntimeController.ProbeProviderFactory factory) { + return new AdAudioRuntimeController( + hub, new PlaybackMediaClock(500L), () -> snapshot, playback, + Runnable::run, () -> { }, factory); + } + + private static AdAudioRuntimeController runtimeWithProviders( + PlaybackMediaSignalHub hub, FakePlaybackPort playback, + AdAudioRuleSnapshot snapshot, + AdAudioRuntimeController.ProbeProviderFactory probeFactory, + AdAudioRuntimeController.SpeechProviderFactory speechFactory) { + return new AdAudioRuntimeController( + hub, new PlaybackMediaClock(500L), () -> snapshot, playback, + Runnable::run, () -> { }, probeFactory, speechFactory); + } + private static AdAudioRuleSnapshot goodSnapshot() { + return snapshotForRule("ad"); + } + + private static AdAudioRuleSnapshot snapshotForRule(String ruleId) { + AudioFingerprintRuleSet rules = AudioFingerprintRuleCodec.fromJson("{" + + "\"schemaVersion\":2,\"algorithm\":{" + + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," + + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16}," + + "\"rules\":[{\"id\":\"" + ruleId + "\",\"durationMs\":10000," + + "\"anchorOffsetMs\":0,\"anchorDurationMs\":3000," + + "\"fingerprint\":[\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]}]}" ); + return new AdAudioRuleSnapshot("test", "v1", rules, List.of(), ""); + } + + private static AdAudioRuleSnapshot snapshotForRuleWithSidecar(String ruleId) { + return snapshotForRulesWithSidecar(ruleId); + } + + private static AdAudioRuleSnapshot snapshotForRulesWithSidecar(String... ruleIds) { + AdAudioRuleSnapshot base = snapshotForRule(ruleIds[0]); + AudioFingerprintRule prototype = base.ruleSet().rules().get(0); + List rules = new ArrayList<>(ruleIds.length); + for (String ruleId : ruleIds) { + rules.add(new AudioFingerprintRule( + ruleId, prototype.durationMs(), prototype.anchorOffsetMs(), + prototype.anchorDurationMs(), prototype.fingerprint(), + prototype.variants())); + } + AudioFingerprintRuleSet ruleSet = new AudioFingerprintRuleSet( + base.ruleSet().config(), rules); + byte[] canonical = "{\"format\":\"ad-audio-probe-rules\"}" + .getBytes(StandardCharsets.UTF_8); + ProbeRuleSidecar sidecar = ProbeRuleSidecar.verified( + "test", 1L, new byte[32], ProbeRuleSidecar.ALGORITHM_ID, + "converter-1", canonical, sha256(canonical)); + return new AdAudioRuleSnapshot( + base.sourceId(), base.version(), ruleSet, + base.warnings(), base.lastError(), sidecar); + } + + private static AdAudioSignalProvider.AdAudioCandidate providerCandidate( + PlaybackMediaSignalHub.Session session, String ruleId, + long startMs, long endMs) { + return new AdAudioSignalProvider.AdAudioCandidate( + session.id(), session.generation(), ruleId, "v1", + startMs, endMs, true, 0.95d, "probe"); + } + + private static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + private static AdAudioRuleSnapshot emptySnapshot() { + return new AdAudioRuleSnapshot( + "test", "", AudioFingerprintRuleSet.empty(), List.of(), ""); + } + + private static AdAudioRuleSnapshot errorSnapshot() { + return new AdAudioRuleSnapshot( + "test", "", AudioFingerprintRuleSet.empty(), List.of(), "INVALID_JSON"); + } + + private static float[] chirp16k() { + int sampleRate = 16_000; + int seconds = 3; + float[] output = new float[sampleRate * seconds]; + double startFrequency = 300; + double endFrequency = 3_000; + double rate = (endFrequency - startFrequency) / seconds; + for (int index = 0; index < output.length; index++) { + double time = index / (double) sampleRate; + double phase = 2.0 * Math.PI * (startFrequency * time + 0.5 * rate * time * time); + short pcm = (short) Math.round(Math.sin(phase) * Short.MAX_VALUE * 0.8); + output[index] = pcm / (float) Short.MAX_VALUE; + } + return output; + } + + private static final class MutableRuleSource implements AdAudioRuleSource { + private AdAudioRuleSnapshot snapshot; + + MutableRuleSource(AdAudioRuleSnapshot snapshot) { + this.snapshot = snapshot; + } + + @Override + public AdAudioRuleSnapshot load() { + return snapshot; + } + } + + private static final class FakePlaybackPort implements AdAudioRuntimeController.PlaybackPort { + private final PlaybackMediaSignalHub hub; + private final List seekTargets = new java.util.ArrayList<>(); + private boolean eligible; + /** Mirrors STATE_BUFFERING: the controller gate passes but duration is unknown. */ + private boolean positionEligible = true; + + 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) { + boolean seekable = eligible && positionEligible; + return new AdSkipCoordinator.PlaybackSnapshot( + sessionId, generation, 1_000L, + positionEligible ? 100_000L : -1L, seekable, !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 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) { + this.actions = actions; + } + + @Override + public void dismiss(long sessionId) { + } + } + + private static final class FakeSpeechRecognitionFactory + implements SpeechRecognitionFactory { + private final List sessions = new ArrayList<>(); + + @Override + public boolean isReady() { + return true; + } + + @Override + public Session create(Listener listener) { + FakeSpeechSession session = new FakeSpeechSession(listener); + sessions.add(session); + return session; + } + } + + private static final class FakeSpeechSession + implements SpeechRecognitionFactory.Session { + private final SpeechRecognitionFactory.Listener listener; + private int closeCalls; + + private FakeSpeechSession(SpeechRecognitionFactory.Listener listener) { + this.listener = listener; + } + + @Override + public void accept(float[] samples, long startUs, long endUs, + int timelineToken) { + } + + @Override + public void reset() { + } + + @Override + public void close() { + closeCalls++; + } + } + private static final class FakeSignalProvider implements AdAudioSignalProvider { + private final String id; + private Listener listener; + private SessionContext context; + private String ruleVersion; + private ProviderState state = ProviderState.DISABLED; + private int starts; + private int closes; + private boolean failOnStart; + + private FakeSignalProvider(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + + @Override + public void start(SessionContext context, AdAudioRuleSnapshot rules, Listener listener) { + if (failOnStart) throw new IllegalStateException("start failed"); + this.context = context; + this.ruleVersion = rules.version(); + this.listener = listener; + starts++; + state = ProviderState.RUNNING; + } + + @Override + public void onHostPosition(HostPosition position) { + } + + @Override + public void onTimelineReset(TimelineReset reset) { + } + + @Override + public void setEnabled(boolean enabled) { + state = enabled ? ProviderState.IDLE : ProviderState.DISABLED; + } + + @Override + public ProviderState state() { + return state; + } + + @Override + public void close() { + if (state == ProviderState.CLOSED) return; + state = ProviderState.CLOSED; + closes++; + } + + private void emit(AdAudioCandidate candidate) { + listener.onCandidate(candidate); + } + + private void emit(String ruleId, long startMs, long endMs) { + listener.onCandidate(new AdAudioCandidate( + context.sessionId(), context.generation(), ruleId, ruleVersion, + startMs, endMs, true, 1.0d, id)); + } + + private void fail(ErrorCode code) { + listener.onProviderError(new ProviderError(id, code, "test failure")); + } + } +} 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) { + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioSignalProviderTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioSignalProviderTest.java new file mode 100644 index 0000000000..7af43ac818 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioSignalProviderTest.java @@ -0,0 +1,280 @@ +package com.fongmi.android.tv.ad.audio; + +import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.LinkedHashMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +public class AdAudioSignalProviderTest { + + @Test + public void sessionContextDefensivelyCopiesHeaders() { + Map headers = new LinkedHashMap<>(); + headers.put("Referer", "https://media.example/"); + + AdAudioSignalProvider.SessionContext context = + new AdAudioSignalProvider.SessionContext( + 7L, 2L, "media-1", "https://media.example/video.m3u8", headers); + headers.put("Authorization", "late mutation"); + + assertEquals(Map.of("Referer", "https://media.example/"), context.headers()); + assertThrows(UnsupportedOperationException.class, + () -> context.headers().put("X-Test", "value")); + } + + @Test + public void boundaryValuesRejectInvalidTimelineAndCandidateData() { + assertThrows(IllegalArgumentException.class, + () -> new AdAudioSignalProvider.SessionContext( + -1L, 0L, "media", "https://media.example/video", Map.of())); + assertThrows(IllegalArgumentException.class, + () -> new AdAudioSignalProvider.HostPosition( + 1L, 0L, -1L, 10_000L, true, false)); + assertThrows(IllegalArgumentException.class, + () -> new AdAudioSignalProvider.AdAudioCandidate( + 1L, 0L, "rule", "v1", 2_000L, 1_000L, + true, 1.0d, "pcm")); + assertThrows(IllegalArgumentException.class, + () -> new AdAudioSignalProvider.AdAudioCandidate( + 1L, 0L, "rule", "v1", 1_000L, 2_000L, + true, Double.NaN, "pcm")); + } + + @Test + public void noopProviderHasNoResourcesOrCallbacksAndClosesIdempotently() { + NoopAdAudioSignalProvider provider = new NoopAdAudioSignalProvider("probe"); + AtomicInteger callbacks = new AtomicInteger(); + AdAudioSignalProvider.Listener listener = new AdAudioSignalProvider.Listener() { + @Override + public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { + callbacks.incrementAndGet(); + } + + @Override + public void onProviderError(AdAudioSignalProvider.ProviderError error) { + callbacks.incrementAndGet(); + } + }; + + assertEquals("probe", provider.id()); + assertEquals(AdAudioSignalProvider.ProviderState.DISABLED, provider.state()); + provider.setEnabled(true); + assertEquals(AdAudioSignalProvider.ProviderState.IDLE, provider.state()); + provider.start(context(7L, 2L), snapshot(), listener); + provider.onHostPosition(new AdAudioSignalProvider.HostPosition( + 6L, 1L, 1_000L, 10_000L, true, false)); + provider.onTimelineReset(new AdAudioSignalProvider.TimelineReset( + 6L, 1L, AdAudioSignalProvider.ResetReason.SEEK, 2_000L)); + + assertEquals(0, callbacks.get()); + assertEquals(AdAudioSignalProvider.ProviderState.IDLE, provider.state()); + + provider.close(); + provider.close(); + provider.setEnabled(true); + provider.start(context(8L, 0L), snapshot(), listener); + + assertEquals(0, callbacks.get()); + assertEquals(AdAudioSignalProvider.ProviderState.CLOSED, provider.state()); + } + + @Test + public void disablingNoopReturnsItToDisabledState() { + NoopAdAudioSignalProvider provider = new NoopAdAudioSignalProvider("probe"); + + provider.setEnabled(true); + provider.setEnabled(false); + + assertEquals(AdAudioSignalProvider.ProviderState.DISABLED, provider.state()); + } + + @Test + public void pcmProviderMapsMatcherEventsAndTracksTimelineGeneration() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); + PlaybackMediaSignalHub.Session session = hub.beginSession(0L); + RecordingListener listener = new RecordingListener(); + PcmAdAudioSignalProvider provider = pcmProvider(hub, ruleSet -> matcherReturning( + new AudioFingerprintMatcher.MatchEvent( + AudioFingerprintMatcher.Type.FULL_MATCHED, + "ad-1", 1_000L, 3_000L, 0.95f, 8))); + + provider.setEnabled(true); + provider.start(context(session.id(), session.generation()), rulesSnapshot(), listener); + hub.publishPcm(session.frame(new float[] {0.1f, -0.1f}, 16_000, 900L)); + + assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, provider.state()); + assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + assertEquals(1, listener.candidates.size()); + AdAudioSignalProvider.AdAudioCandidate first = listener.candidates.get(0); + assertEquals(session.id(), first.sessionId()); + assertEquals(0L, first.generation()); + assertEquals("rules-v1", first.ruleVersion()); + assertEquals("pcm", first.providerId()); + assertTrue(first.fullMatch()); + + PlaybackMediaSignalHub.Session reset = hub.resetTimeline( + 4_000L, PlaybackMediaSignalHub.ResetReason.SEEK); + hub.publishPcm(reset.frame(new float[] {0.2f, -0.2f}, 16_000, 4_000L)); + + assertEquals(2, listener.candidates.size()); + assertEquals(reset.generation(), listener.candidates.get(1).generation()); + assertTrue(listener.errors.isEmpty()); + } + + @Test + public void disablingPcmProviderReleasesCaptureAndStopsCallbacks() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); + PlaybackMediaSignalHub.Session session = hub.beginSession(0L); + RecordingListener listener = new RecordingListener(); + PcmAdAudioSignalProvider provider = pcmProvider(hub, ruleSet -> matcherReturning( + new AudioFingerprintMatcher.MatchEvent( + AudioFingerprintMatcher.Type.START_MATCHED, + "ad-1", 1_000L, 3_000L, 0.9f, 4))); + provider.setEnabled(true); + provider.start(context(session.id(), session.generation()), rulesSnapshot(), listener); + + provider.setEnabled(false); + hub.publishPcm(session.frame(new float[] {0.1f}, 16_000, 1_000L)); + + assertEquals(AdAudioSignalProvider.ProviderState.DISABLED, provider.state()); + assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + assertTrue(listener.candidates.isEmpty()); + provider.close(); + provider.close(); + assertEquals(AdAudioSignalProvider.ProviderState.CLOSED, provider.state()); + } + + @Test + public void pcmMatcherFailureIsReportedWithoutEscapingTheHub() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); + PlaybackMediaSignalHub.Session session = hub.beginSession(0L); + RecordingListener listener = new RecordingListener(); + PcmAdAudioSignalProvider provider = pcmProvider(hub, ruleSet -> new AdAudioConsumer.Matcher() { + @Override + public List feed( + short[] samples, int sampleRate, long captureStartMs) { + throw new IllegalStateException("matcher failed"); + } + + @Override + public List finish() { + return List.of(); + } + }); + provider.setEnabled(true); + provider.start(context(session.id(), session.generation()), rulesSnapshot(), listener); + + hub.publishPcm(session.frame(new float[] {0.1f}, 16_000, 1_000L)); + provider.onHostPosition(new AdAudioSignalProvider.HostPosition( + session.id(), session.generation(), 1_000L, 10_000L, true, false)); + + assertEquals(1, listener.errors.size()); + assertEquals(AdAudioSignalProvider.ErrorCode.ANALYSIS_FAILED, + listener.errors.get(0).code()); + assertEquals(AdAudioSignalProvider.ProviderState.DEGRADED, provider.state()); + } + + @Test + public void pcmMatcherRestartFailureAfterResetIsReported() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); + PlaybackMediaSignalHub.Session session = hub.beginSession(0L); + RecordingListener listener = new RecordingListener(); + AtomicInteger creates = new AtomicInteger(); + PcmAdAudioSignalProvider provider = pcmProvider(hub, ruleSet -> { + if (creates.incrementAndGet() > 1) { + throw new IllegalStateException("restart failed"); + } + return quietMatcher(); + }); + provider.setEnabled(true); + provider.start(context(session.id(), session.generation()), rulesSnapshot(), listener); + + hub.resetTimeline(2_000L, PlaybackMediaSignalHub.ResetReason.SEEK); + + assertEquals(1, listener.errors.size()); + assertEquals(AdAudioSignalProvider.ErrorCode.ANALYSIS_FAILED, + listener.errors.get(0).code()); + assertEquals(AdAudioSignalProvider.ProviderState.DEGRADED, provider.state()); + } + + private static AdAudioSignalProvider.SessionContext context(long sessionId, long generation) { + return new AdAudioSignalProvider.SessionContext( + sessionId, generation, "media", "https://media.example/video.m3u8", Map.of()); + } + + private static AdAudioRuleSnapshot snapshot() { + return new AdAudioRuleSnapshot( + "test", "v1", AudioFingerprintRuleSet.empty(), List.of(), ""); + } + + private static AdAudioRuleSnapshot rulesSnapshot() { + AudioFingerprintRule rule = new AudioFingerprintRule( + "ad-1", 3_000L, 0L, 1_000L, + new int[] {1, 2, 3, 4}, List.of()); + AudioFingerprintRuleSet rules = new AudioFingerprintRuleSet( + AudioFingerprintConfig.standard(), List.of(rule)); + return new AdAudioRuleSnapshot("signed:test", "rules-v1", rules, List.of(), ""); + } + + private static PcmAdAudioSignalProvider pcmProvider( + PlaybackMediaSignalHub hub, AdAudioConsumer.MatcherFactory matcherFactory) { + return new PcmAdAudioSignalProvider( + hub, matcherFactory, 4, Runnable::run, new AdAudioDiagnostics()); + } + + private static AdAudioConsumer.Matcher matcherReturning( + AudioFingerprintMatcher.MatchEvent event) { + return new AdAudioConsumer.Matcher() { + @Override + public List feed( + short[] samples, int sampleRate, long captureStartMs) { + return List.of(event); + } + + @Override + public List finish() { + return List.of(); + } + }; + } + + private static AdAudioConsumer.Matcher quietMatcher() { + return new AdAudioConsumer.Matcher() { + @Override + public List feed( + short[] samples, int sampleRate, long captureStartMs) { + return List.of(); + } + + @Override + public List finish() { + return List.of(); + } + }; + } + + private static final class RecordingListener implements AdAudioSignalProvider.Listener { + private final List candidates = new ArrayList<>(); + private final List errors = new ArrayList<>(); + + @Override + public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { + candidates.add(candidate); + } + + @Override + public void onProviderError(AdAudioSignalProvider.ProviderError error) { + errors.add(error); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorTest.java new file mode 100644 index 0000000000..142abee943 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorTest.java @@ -0,0 +1,284 @@ +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; + +public class AdSkipCoordinatorTest { + + @Test + public void confirmSeeksAndUndoReturnsToOriginalPosition() { + ManualTime time = new ManualTime(); + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 45_000L, 120_000L, true, false, + freshClock(2L, 35_000L, 35_000L, 25_000L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); + + coordinator.onCandidate(candidate(7L, 2L, "ad-1", 10_000L, 25_000L, + AudioFingerprintMatcher.Type.START_MATCHED)); + ui.actions.confirm(); + ui.actions.undo(); + + assertEquals(List.of(60_000L, 45_000L), playback.seekTargets); + assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); + } + + @Test + public void coordinatorSeekLifecyclePreservesUndoAcrossNewGeneration() { + ManualTime time = new ManualTime(); + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 7L, 2L, 45_000L, 120_000L, true, false, + freshClock(2L, 35_000L, 35_000L, 25_000L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); + + coordinator.onCandidate(candidate(7L, 2L, "ad-1", 10_000L, 25_000L, + AudioFingerprintMatcher.Type.START_MATCHED)); + ui.actions.confirm(); + coordinator.onTimelineReset(new com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub.Lifecycle( + 7L, 3L, com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub.ResetReason.SEEK, 60_000L)); + ui.actions.undo(); + + assertEquals(List.of(60_000L, 45_000L), playback.seekTargets); + assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); + } + + @Test + public void staleGenerationAndLivePlaybackAreRejected() { + FakePlaybackPort stalePlayback = new FakePlaybackPort(snapshot( + 7L, 3L, 1_000L, 100_000L, true, false, + freshClock(3L, 0L, 30_000L, 1_000L))); + FakeUiPort staleUi = new FakeUiPort(); + AdSkipCoordinator stale = new AdSkipCoordinator(stalePlayback, staleUi, 5_000L); + stale.onCandidate(candidate(7L, 2L, "old", 0L, 10_000L, + AudioFingerprintMatcher.Type.START_MATCHED)); + + FakePlaybackPort livePlayback = new FakePlaybackPort(snapshot( + 8L, 1L, 1_000L, 100_000L, true, true, + freshClock(1L, 0L, 30_000L, 1_000L))); + FakeUiPort liveUi = new FakeUiPort(); + AdSkipCoordinator live = new AdSkipCoordinator(livePlayback, liveUi, 5_000L); + live.onCandidate(candidate(8L, 1L, "live", 0L, 10_000L, + AudioFingerprintMatcher.Type.START_MATCHED)); + + assertEquals(0, staleUi.candidateShows); + assertEquals(0, liveUi.candidateShows); + } + + @Test + public void fullMatchUpgradesPromptButDuplicateStartIsIgnored() { + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 1L, 1L, 5_000L, 100_000L, true, false, + freshClock(1L, 0L, 30_000L, 5_000L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + + coordinator.onCandidate(candidate(1L, 1L, "ad", 0L, 10_000L, + AudioFingerprintMatcher.Type.START_MATCHED)); + coordinator.onCandidate(candidate(1L, 1L, "ad", 0L, 10_000L, + AudioFingerprintMatcher.Type.START_MATCHED)); + coordinator.onCandidate(candidate(1L, 1L, "ad", 0L, 10_000L, + AudioFingerprintMatcher.Type.FULL_MATCHED)); + + assertEquals(2, ui.candidateShows); + assertTrue(ui.lastPrompt.fullMatch()); + } + + @Test + public void ignoreSuppressesOnlyTheCurrentCandidate() { + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 1L, 1L, 5_000L, 100_000L, true, false, + freshClock(1L, 0L, 30_000L, 5_000L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + AdAudioConsumer.Candidate first = candidate(1L, 1L, "ad-1", 0L, 10_000L, + AudioFingerprintMatcher.Type.START_MATCHED); + + coordinator.onCandidate(first); + ui.actions.ignore(); + coordinator.onCandidate(first); + coordinator.onCandidate(candidate(1L, 1L, "ad-2", 0L, 12_000L, + AudioFingerprintMatcher.Type.START_MATCHED)); + + assertEquals(2, ui.candidateShows); + assertEquals("ad-2", ui.lastPrompt.ruleId()); + } + + @Test + public void undoAfterDeadlineDoesNotSeekAgain() { + ManualTime time = new ManualTime(); + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 1L, 1L, 5_000L, 100_000L, true, false, + freshClock(1L, 0L, 30_000L, 5_000L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); + coordinator.onCandidate(candidate(1L, 1L, "ad", 0L, 10_000L, + AudioFingerprintMatcher.Type.FULL_MATCHED)); + ui.actions.confirm(); + time.now = 5_001L; + + ui.actions.undo(); + + assertEquals(List.of(10_000L), playback.seekTargets); + assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); + } + + @Test + public void staleDialogActionCannotConfirmReplacementPrompt() { + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 1L, 1L, 5_000L, 100_000L, true, false, + freshClock(1L, 0L, 30_000L, 5_000L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + coordinator.onCandidate(candidate(1L, 1L, "ad-1", 0L, 10_000L, + AudioFingerprintMatcher.Type.START_MATCHED)); + AdSkipCoordinator.Actions old = ui.actions; + coordinator.onCandidate(candidate(1L, 1L, "ad-2", 0L, 20_000L, + AudioFingerprintMatcher.Type.START_MATCHED)); + + old.confirm(); + + assertTrue(playback.seekTargets.isEmpty()); + assertEquals("ad-2", ui.lastPrompt.ruleId()); + } + + @Test + public void automaticCandidateSeeksImmediatelyAndKeepsUndo() { + ManualTime time = new ManualTime(); + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 1L, 1L, 5_000L, 100_000L, true, false, + freshClock(1L, 0L, 30_000L, 5_000L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); + + boolean applied = coordinator.onAutoCandidate(providerCandidate( + 1L, 1L, "ad", 0L, 10_000L, true)); + + assertTrue(applied); + assertEquals(0, ui.candidateShows); + assertEquals(List.of(10_000L), playback.seekTargets); + assertEquals(AdSkipCoordinator.State.UNDO_WINDOW, coordinator.state()); + + ui.actions.undo(); + + assertEquals(List.of(10_000L, 5_000L), playback.seekTargets); + assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); + } + + @Test + public void ignoreSuppressesOnlyTheMatchedIntervalNotEveryLaterMatchOfTheRule() { + FakePlaybackPort playback = new FakePlaybackPort(snapshot( + 1L, 1L, 5_000L, 1_500_000L, true, false, + freshClock(1L, 0L, 1_000_000L, 5_000L))); + FakeUiPort ui = new FakeUiPort(); + AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); + + coordinator.onCandidate(candidate(1L, 1L, "speech-keyword", 60_000L, 75_000L, + AudioFingerprintMatcher.Type.FULL_MATCHED)); + ui.actions.ignore(); + coordinator.onCandidate(candidate(1L, 1L, "speech-keyword", 60_000L, 75_000L, + AudioFingerprintMatcher.Type.FULL_MATCHED)); + coordinator.onCandidate(candidate(1L, 1L, "speech-keyword", 900_000L, 915_000L, + AudioFingerprintMatcher.Type.FULL_MATCHED)); + + assertEquals(2, ui.candidateShows); + assertEquals(915_000L, ui.lastPrompt.targetMediaMs()); + } + + 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, long anchor, long captured, long presented) { + return new PlaybackMediaClock.Snapshot(generation, anchor, captured, presented, true, true); + } + + private static AdAudioConsumer.Candidate candidate( + long session, long generation, String ruleId, long start, long end, + AudioFingerprintMatcher.Type type) { + return new AdAudioConsumer.Candidate(session, generation, + new AudioFingerprintMatcher.MatchEvent(type, ruleId, start, end, 0.9f, 6)); + } + + private static AdAudioSignalProvider.AdAudioCandidate providerCandidate( + long session, long generation, String ruleId, long start, long end, + boolean fullMatch) { + return new AdAudioSignalProvider.AdAudioCandidate( + session, generation, ruleId, "rules-v1", start, end, + fullMatch, 0.9d, "pcm"); + } + + 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); + } + } + + private static final class FakeUiPort implements AdSkipCoordinator.UiPort { + private int candidateShows; + 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) { + this.actions = actions; + } + + @Override + public void dismiss(long sessionId) { + } + } +} 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) { + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipPolicyControllerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipPolicyControllerTest.java new file mode 100644 index 0000000000..9dc9b4c6b6 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipPolicyControllerTest.java @@ -0,0 +1,177 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class AdSkipPolicyControllerTest { + + @Test + public void defaultsToPromptAndRoutesNewCandidatesToPromptSink() { + RecordingSinks sinks = new RecordingSinks(); + AdSkipPolicyController policy = policy(sinks); + + policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, false)); + + assertEquals(AdSkipPolicyController.Mode.PROMPT, policy.mode()); + assertEquals(1, sinks.prompted.size()); + assertTrue(sinks.automated.isEmpty()); + } + + @Test + public void switchingToAutoAffectsOnlyNewCandidates() { + RecordingSinks sinks = new RecordingSinks(); + AdSkipPolicyController policy = policy(sinks); + policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, false)); + + policy.setMode(AdSkipPolicyController.Mode.AUTO); + policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); + policy.onCandidate(candidate("rule-2", 4_000L, 6_000L, true)); + + assertEquals(2, sinks.prompted.size()); + assertTrue(sinks.prompted.get(1).fullMatch()); + assertEquals(1, sinks.automated.size()); + assertEquals("rule-2", sinks.automated.get(0).ruleId()); + } + + @Test + public void switchingBackToPromptAffectsOnlyLaterCandidates() { + RecordingSinks sinks = new RecordingSinks(); + AdSkipPolicyController policy = policy(sinks); + policy.setMode(AdSkipPolicyController.Mode.AUTO); + policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); + + policy.setMode(AdSkipPolicyController.Mode.PROMPT); + policy.onCandidate(candidate("rule-2", 4_000L, 6_000L, true)); + + assertEquals(1, sinks.automated.size()); + assertEquals(1, sinks.prompted.size()); + assertEquals("rule-2", sinks.prompted.get(0).ruleId()); + } + + @Test + public void generationResetClearsDecisionsAndRejectsOldCallbacks() { + RecordingSinks sinks = new RecordingSinks(); + AdSkipPolicyController policy = policy(sinks); + policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); + + policy.onTimelineReset(new AdAudioSignalProvider.TimelineReset( + 7L, 3L, AdAudioSignalProvider.ResetReason.SEEK, 5_000L)); + policy.onCandidate(candidate("rule-2", 4_000L, 6_000L, true)); + policy.onCandidate(new AdAudioSignalProvider.AdAudioCandidate( + 7L, 3L, "rule-1", "rules-v1", 1_000L, 3_000L, + true, 0.95d, "pcm")); + + assertEquals(2, sinks.prompted.size()); + assertEquals(1L, policy.diagnostics().stale()); + assertEquals(1L, policy.diagnostics().resets()); + } + + @Test + public void repeatedModeAndDuplicateCandidatesAreIdempotent() { + RecordingSinks sinks = new RecordingSinks(); + AdSkipPolicyController policy = policy(sinks); + + policy.setMode(AdSkipPolicyController.Mode.PROMPT); + policy.setMode(AdSkipPolicyController.Mode.PROMPT); + policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); + policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); + + assertEquals(1, sinks.prompted.size()); + assertEquals(0L, policy.diagnostics().modeSwitches()); + assertEquals(1L, policy.diagnostics().duplicates()); + } + + @Test + public void speechAutoDoesNotChangePcmOrProbePolicy() { + RecordingSinks sinks = new RecordingSinks(); + AdSkipPolicyController policy = policy(sinks); + policy.setModeResolver(provider -> "speech".equals(provider) + ? AdSkipPolicyController.Mode.AUTO + : AdSkipPolicyController.Mode.PROMPT); + + policy.onCandidate(candidate("speech-rule", 1_000L, 3_000L, true, "speech")); + policy.onCandidate(candidate("pcm-rule", 4_000L, 6_000L, true, "pcm")); + policy.onCandidate(candidate("probe-rule", 7_000L, 9_000L, true, "probe")); + + assertEquals(1, sinks.automated.size()); + assertEquals("speech", sinks.automated.get(0).providerId()); + assertEquals(2, sinks.prompted.size()); + assertEquals("pcm", sinks.prompted.get(0).providerId()); + assertEquals("probe", sinks.prompted.get(1).providerId()); + } + + @Test + public void unknownProviderDefaultsToPromptWhenResolverReturnsNull() { + RecordingSinks sinks = new RecordingSinks(); + AdSkipPolicyController policy = policy(sinks); + policy.setModeResolver(provider -> null); + + policy.onCandidate(candidate("future-rule", 1_000L, 3_000L, true, "future")); + + assertEquals(1, sinks.prompted.size()); + assertTrue(sinks.automated.isEmpty()); + } + + @Test + public void resolverFailureDefaultsToPrompt() { + RecordingSinks sinks = new RecordingSinks(); + AdSkipPolicyController policy = policy(sinks); + policy.setModeResolver(provider -> { throw new IllegalStateException("boom"); }); + + policy.onCandidate(candidate("future-rule", 1_000L, 3_000L, true, "future")); + + assertEquals(1, sinks.prompted.size()); + assertTrue(sinks.automated.isEmpty()); + } + @Test + public void closeStopsAllFutureDispatch() { + RecordingSinks sinks = new RecordingSinks(); + AdSkipPolicyController policy = policy(sinks); + + policy.close(); + policy.setMode(AdSkipPolicyController.Mode.AUTO); + policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); + + assertTrue(sinks.prompted.isEmpty()); + assertTrue(sinks.automated.isEmpty()); + } + + private static AdSkipPolicyController policy(RecordingSinks sinks) { + return new AdSkipPolicyController( + new AdAudioSignalProvider.SessionContext( + 7L, 2L, "media", "https://media.example/video.m3u8", Map.of()), + "rules-v1", 16, sinks::prompt, sinks::auto); + } + + private static AdAudioSignalProvider.AdAudioCandidate candidate( + String ruleId, long startMs, long endMs, boolean fullMatch) { + return new AdAudioSignalProvider.AdAudioCandidate( + 7L, 2L, ruleId, "rules-v1", startMs, endMs, + fullMatch, 0.95d, "pcm"); + } + + private static AdAudioSignalProvider.AdAudioCandidate candidate( + String ruleId, long startMs, long endMs, boolean fullMatch, String providerId) { + return new AdAudioSignalProvider.AdAudioCandidate( + 7L, 2L, ruleId, "rules-v1", startMs, endMs, + fullMatch, 0.95d, providerId); + } + private static final class RecordingSinks { + private final List prompted = new ArrayList<>(); + private final List automated = new ArrayList<>(); + + private void prompt(AdAudioSignalProvider.AdAudioCandidate candidate) { + prompted.add(candidate); + } + + private void auto(AdAudioSignalProvider.AdAudioCandidate candidate) { + automated.add(candidate); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintContractTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintContractTest.java new file mode 100644 index 0000000000..11d36829f1 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintContractTest.java @@ -0,0 +1,94 @@ +package com.fongmi.android.tv.ad.audio; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class AudioFingerprintContractTest { + + @Test + public void standardConfigMatchesSdkV2() { + AudioFingerprintConfig config = AudioFingerprintConfig.standard(); + + assertEquals(16_000, config.sampleRate()); + assertEquals(512, config.windowMs()); + assertEquals(256, config.hopMs()); + assertEquals(16, config.bandCount()); + assertEquals(8_192, config.windowSamples()); + assertEquals(4_096, config.hopSamples()); + } + + @Test + public void sampleCountsUseSdkRoundingForFractionalMilliseconds() { + AudioFingerprintConfig config = new AudioFingerprintConfig(11_025, 513, 257, 16); + + assertEquals(5_656, config.windowSamples()); + assertEquals(2_833, config.hopSamples()); + } + + @Test(expected = IllegalArgumentException.class) + public void configRejectsExcessiveFftWorkload() { + new AudioFingerprintConfig(192_000, 4_000, 64, 16); + } + + @Test(expected = IllegalArgumentException.class) + public void configRejectsHopLongerThanWindow() { + new AudioFingerprintConfig(16_000, 256, 512, 16); + } + + @Test(expected = IllegalArgumentException.class) + public void ruleRejectsTooShortFingerprint() { + new AudioFingerprintRule("ad-1", 15_000, 0, 5_000, new int[]{1, 2, 3}, List.of()); + } + + @Test(expected = IllegalArgumentException.class) + public void ruleRejectsAnchorOutsideDuration() { + new AudioFingerprintRule("ad-1", 5_000, 2_000, 4_000, sequence(1), List.of()); + } + + @Test + public void ruleDefensivelyCopiesSequences() { + int[] primary = sequence(1); + int[] variant = sequence(11); + AudioFingerprintRule rule = new AudioFingerprintRule( + "ad-1", 15_000, 0, 5_000, primary, List.of(variant)); + + primary[0] = 99; + variant[0] = 99; + int[] returned = rule.fingerprint(); + returned[0] = 77; + + assertArrayEquals(sequence(1), rule.fingerprint()); + assertArrayEquals(sequence(11), rule.variants().get(0)); + assertEquals(2, rule.allSequences().size()); + } + + @Test(expected = IllegalArgumentException.class) + public void ruleSetRejectsDuplicateIds() { + AudioFingerprintRule first = rule("same", 1); + AudioFingerprintRule second = rule("same", 11); + + new AudioFingerprintRuleSet(AudioFingerprintConfig.standard(), List.of(first, second)); + } + + @Test + public void ruleSetExposesSdkSchemaAndAlgorithm() { + AudioFingerprintRuleSet rules = new AudioFingerprintRuleSet( + AudioFingerprintConfig.standard(), List.of(rule("ad-1", 1))); + + assertEquals(2, AudioFingerprintRuleSet.SCHEMA_VERSION); + assertEquals("spectral-sequence-v2", AudioFingerprintRuleSet.ALGORITHM_ID); + assertEquals(1, rules.rules().size()); + } + + private static AudioFingerprintRule rule(String id, int seed) { + return new AudioFingerprintRule(id, 15_000, 0, 5_000, sequence(seed), List.of()); + } + + private static int[] sequence(int seed) { + return new int[]{seed, seed + 1, seed + 2, seed + 3}; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintMatcherTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintMatcherTest.java new file mode 100644 index 0000000000..7a12f73c50 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintMatcherTest.java @@ -0,0 +1,217 @@ +package com.fongmi.android.tv.ad.audio; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class AudioFingerprintMatcherTest { + + private static final AudioFingerprintConfig CONFIG = AudioFingerprintConfig.standard(); + private static final int[] CHIRP_HASHES = hashes( + "32f0007c,35c100e0,3b8b01c0,d30a0380,2e0b0700," + + "0c650600,9c560c00,49db0c00,d9161800,70d41800"); + + @Test + public void emitsStartThenFullMatchWithPredictedEnd() { + AudioFingerprintMatcher matcher = matcherFor(chirpRule()); + List events = feedInChunks(matcher, chirp16k()); + + assertEquals(2, events.size()); + assertEquals(AudioFingerprintMatcher.Type.START_MATCHED, events.get(0).type()); + assertEquals("chirp-ad", events.get(0).ruleId()); + assertEquals(0, events.get(0).startTimeMs()); + assertEquals(15_000, events.get(0).endTimeMs()); + assertEquals(6, events.get(0).matchedFrames()); + assertEquals(AudioFingerprintMatcher.Type.FULL_MATCHED, events.get(1).type()); + assertTrue(events.get(1).confidence() >= 0.78f); + } + + @Test + public void shortRuleEmitsStartAndFullMatchOnTheSameFrame() { + AudioFingerprintRule shortRule = new AudioFingerprintRule( + "short-ad", 15_000, 0, 1_280, + Arrays.copyOf(CHIRP_HASHES, AudioFingerprintRule.MIN_SEQUENCE_FRAMES), List.of()); + AudioFingerprintMatcher matcher = matcherFor(shortRule); + + List events = feedInChunks(matcher, chirp16k()); + + assertEquals(2, events.size()); + assertEquals(AudioFingerprintMatcher.Type.START_MATCHED, events.get(0).type()); + assertEquals(AudioFingerprintMatcher.Type.FULL_MATCHED, events.get(1).type()); + } + + @Test + public void exactWindowBoundaryDoesNotRequireAnExtraPcmSample() { + int frames = AudioFingerprintRule.MIN_SEQUENCE_FRAMES; + int sampleCount = CONFIG.windowSamples() + (frames - 1) * CONFIG.hopSamples(); + AudioFingerprintRule shortRule = new AudioFingerprintRule( + "exact-boundary", 15_000, 0, 1_280, + Arrays.copyOf(CHIRP_HASHES, frames), List.of()); + + List events = feedInChunks( + matcherFor(shortRule), Arrays.copyOf(chirp16k(), sampleCount)); + + assertEquals(2, events.size()); + assertEquals(AudioFingerprintMatcher.Type.FULL_MATCHED, events.get(1).type()); + } + + @Test + public void finishFlushesTheFinalFractionalResample() { + short[] source = Arrays.copyOf(chirp(8_000, 2), 10_240); + List variants = SpectralFingerprint.extractVariants(source, 8_000, 1, CONFIG); + AudioFingerprintRule rule = new AudioFingerprintRule( + "fractional-tail", 15_000, 0, 1_280, variants.get(0), List.of()); + AudioFingerprintMatcher matcher = matcherFor(rule); + + assertTrue(matcher.feed(new AudioFingerprintMatcher.PcmChunk(source, 8_000, 1, 0)) + .events().isEmpty()); + AudioFingerprintMatcher.FeedResult result = matcher.finish(); + + assertEquals(AudioFingerprintMatcher.Status.MATCHED, result.status()); + assertEquals(2, result.events().size()); + assertEquals(AudioFingerprintMatcher.Type.FULL_MATCHED, result.events().get(1).type()); + } + + @Test + public void resamplingIsInvariantToPcmChunkBoundaries() { + short[] source = chirp(48_000, 3); + List variants = SpectralFingerprint.extractVariants(source, 48_000, 1, CONFIG); + AudioFingerprintRule rule = new AudioFingerprintRule( + "chunk-invariant", 15_000, 0, 3_000, variants.get(0), + variants.subList(1, variants.size())); + + List whole = feedInChunks( + matcherFor(rule), source, 48_000, source.length); + List split = feedInChunks( + matcherFor(rule), source, 48_000, 1_024); + + assertEquals(types(whole), types(split)); + assertEquals(2, split.size()); + assertEquals(AudioFingerprintMatcher.Type.FULL_MATCHED, split.get(1).type()); + } + + @Test + public void timelineGapResetsPendingCandidate() { + AudioFingerprintMatcher matcher = matcherFor(chirpRule()); + short[] first = Arrays.copyOf(chirp16k(), 10_000); + short[] second = Arrays.copyOfRange(chirp16k(), 10_000, 20_000); + + assertEquals(AudioFingerprintMatcher.Status.NO_MATCH, + matcher.feed(new AudioFingerprintMatcher.PcmChunk(first, 16_000, 1, 0)).status()); + assertEquals(AudioFingerprintMatcher.Status.RESET, + matcher.feed(new AudioFingerprintMatcher.PcmChunk(second, 16_000, 1, 10_000)).status()); + } + + @Test + public void timelineResetRemainsObservableWhenTheNewChunkMatches() { + AudioFingerprintMatcher matcher = matcherFor(chirpRule()); + matcher.feed(new AudioFingerprintMatcher.PcmChunk(new short[10_000], 16_000, 1, 0)); + + AudioFingerprintMatcher.FeedResult result = matcher.feed( + new AudioFingerprintMatcher.PcmChunk(chirp16k(), 16_000, 1, 10_000)); + + assertEquals(AudioFingerprintMatcher.Status.RESET, result.status()); + assertTrue(!result.events().isEmpty()); + } + + @Test + public void malformedChunkIsRejectedWithoutThrowing() { + AudioFingerprintMatcher matcher = matcherFor(chirpRule()); + + AudioFingerprintMatcher.FeedResult result = matcher.feed(null); + + assertEquals(AudioFingerprintMatcher.Status.INVALID_INPUT, result.status()); + assertTrue(result.events().isEmpty()); + } + + @Test + public void oversizedOrMalformedPcmIsRejected() { + AudioFingerprintMatcher matcher = matcherFor(chirpRule()); + + assertEquals(AudioFingerprintMatcher.Status.INVALID_INPUT, + matcher.feed(new AudioFingerprintMatcher.PcmChunk(new short[16_000 * 31], 16_000, 1, 0)).status()); + assertEquals(AudioFingerprintMatcher.Status.INVALID_INPUT, + matcher.feed(new AudioFingerprintMatcher.PcmChunk(new short[9], 16_000, 9, 0)).status()); + assertEquals(AudioFingerprintMatcher.Status.INVALID_INPUT, + matcher.feed(new AudioFingerprintMatcher.PcmChunk(new short[10], 16_000, 3, 0)).status()); + assertEquals(AudioFingerprintMatcher.Status.INVALID_INPUT, + matcher.feed(new AudioFingerprintMatcher.PcmChunk( + new short[1_000_008], 192_000, 8, 0)).status()); + } + + @Test(expected = IllegalArgumentException.class) + public void matcherConfigRejectsNaNRatio() { + new AudioFingerprintMatcher.Config(6, 7, Float.NaN, 0.78f, 5_000, 2_500); + } + + @Test + public void silenceDoesNotCreateMatch() { + AudioFingerprintMatcher matcher = matcherFor(chirpRule()); + + AudioFingerprintMatcher.FeedResult result = matcher.feed( + new AudioFingerprintMatcher.PcmChunk(new short[16_000 * 3], 16_000, 1, 0)); + + assertEquals(AudioFingerprintMatcher.Status.NO_MATCH, result.status()); + assertTrue(result.events().isEmpty()); + } + + private static AudioFingerprintMatcher matcherFor(AudioFingerprintRule rule) { + return new AudioFingerprintMatcher( + new AudioFingerprintRuleSet(CONFIG, List.of(rule)), + AudioFingerprintMatcher.Config.conservative()); + } + + private static AudioFingerprintRule chirpRule() { + return new AudioFingerprintRule("chirp-ad", 15_000, 0, 3_000, CHIRP_HASHES, List.of()); + } + + private static List feedInChunks( + AudioFingerprintMatcher matcher, short[] samples) { + return feedInChunks(matcher, samples, 16_000, samples.length); + } + + private static List feedInChunks( + AudioFingerprintMatcher matcher, short[] samples, int sampleRate, int chunkFrames) { + List events = new ArrayList<>(); + for (int offset = 0; offset < samples.length; offset += chunkFrames) { + int length = Math.min(chunkFrames, samples.length - offset); + events.addAll(matcher.feed(new AudioFingerprintMatcher.PcmChunk( + Arrays.copyOfRange(samples, offset, offset + length), + sampleRate, 1, offset * 1_000L / sampleRate)).events()); + } + return events; + } + + private static List types(List events) { + return events.stream().map(AudioFingerprintMatcher.MatchEvent::type).toList(); + } + + private static int[] hashes(String csv) { + return Arrays.stream(csv.split(",")) + .mapToInt(value -> (int) Long.parseLong(value, 16)) + .toArray(); + } + + private static short[] chirp16k() { + return chirp(16_000, 3); + } + + private static short[] chirp(int sampleRate, int seconds) { + int sampleCount = sampleRate * seconds; + double startFrequency = 300; + double endFrequency = 3_000; + double rate = (endFrequency - startFrequency) / seconds; + short[] output = new short[sampleCount]; + for (int i = 0; i < output.length; i++) { + double time = i / (double) sampleRate; + double phase = 2 * Math.PI * (startFrequency * time + 0.5 * rate * time * time); + output[i] = (short) Math.round(16_000 * Math.sin(phase)); + } + return output; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintRuleCodecTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintRuleCodecTest.java new file mode 100644 index 0000000000..3ba642435f --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintRuleCodecTest.java @@ -0,0 +1,124 @@ +package com.fongmi.android.tv.ad.audio; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class AudioFingerprintRuleCodecTest { + + @Test + public void sdkV2JsonRoundTrips() { + AudioFingerprintRuleSet parsed = AudioFingerprintRuleCodec.fromJson(validSdkJson()); + AudioFingerprintRuleSet roundTripped = AudioFingerprintRuleCodec.fromJson( + AudioFingerprintRuleCodec.toJson(parsed)); + + assertEquals(2, parsed.rules().size()); + assertEquals("spectral-sequence-v2", AudioFingerprintRuleSet.ALGORITHM_ID); + assertArrayEquals(parsed.rules().get(0).fingerprint(), roundTripped.rules().get(0).fingerprint()); + assertEquals(parsed.rules().get(0).variants().size(), roundTripped.rules().get(0).variants().size()); + } + + @Test(expected = IllegalArgumentException.class) + public void malformedHexIsRejectedInsteadOfSilentlyDroppingRule() { + AudioFingerprintRuleCodec.fromJson(jsonWithHash("not-hex")); + } + + @Test(expected = IllegalArgumentException.class) + public void oversizedRuleSetIsRejectedBeforeAllocation() { + AudioFingerprintRuleCodec.fromJson(jsonWithRuleCount(AudioFingerprintRuleSet.MAX_RULES + 1)); + } + + @Test(expected = IllegalArgumentException.class) + public void duplicateRuleIdsRejectTheWholePackage() { + AudioFingerprintRuleCodec.fromJson(jsonWithRuleCount(2) + .replace("ad-1", "same") + .replace("ad-2", "same")); + } + + @Test(expected = IllegalArgumentException.class) + public void unsupportedAlgorithmIsRejected() { + AudioFingerprintRuleCodec.fromJson(validSdkJson().replace("spectral-sequence-v2", "other-v1")); + } + + @Test(expected = IllegalArgumentException.class) + public void unknownRootFieldIsRejected() { + AudioFingerprintRuleCodec.fromJson(validSdkJson().replace("\"rules\":[", "\"unknown\":{},\"rules\":[")); + } + + @Test(expected = IllegalArgumentException.class) + public void deeplyNestedJsonIsRejectedBeforeTreeConstruction() { + StringBuilder nested = new StringBuilder(validSdkJson().replace("\"rules\":[", "\"junk\":")); + for (int i = 0; i < 80; i++) nested.append('['); + nested.append('0'); + for (int i = 0; i < 80; i++) nested.append(']'); + nested.append(",\"rules\":[]}"); + AudioFingerprintRuleCodec.fromJson(nested.toString()); + } + + @Test(expected = IllegalArgumentException.class) + public void oversizedSerializedRuleSetIsRejected() { + int[] hashes = new int[AudioFingerprintRule.MAX_SEQUENCE_FRAMES]; + List rules = new ArrayList<>(); + for (int i = 0; i < 11; i++) { + rules.add(new AudioFingerprintRule("large-" + i, 15_000, 0, 3_000, + hashes, List.of(hashes, hashes, hashes, hashes))); + } + AudioFingerprintRuleCodec.toJson(new AudioFingerprintRuleSet( + AudioFingerprintConfig.standard(), rules)); + } + + @Test + public void largeAcceptedRuleSetStillRoundTrips() { + int[] hashes = new int[46]; + List rules = new ArrayList<>(); + for (int i = 0; i < AudioFingerprintRuleSet.MAX_RULES; i++) { + String prefix = "rule-" + i + '-'; + String id = prefix + "x".repeat(128 - prefix.length()); + rules.add(new AudioFingerprintRule(id, 15_000, 0, 3_000, hashes, List.of())); + } + AudioFingerprintRuleSet ruleSet = new AudioFingerprintRuleSet( + AudioFingerprintConfig.standard(), rules); + + String json = AudioFingerprintRuleCodec.toJson(ruleSet); + + assertEquals(AudioFingerprintRuleSet.MAX_RULES, + AudioFingerprintRuleCodec.fromJson(json).rules().size()); + } + + private static String validSdkJson() { + return "{" + + "\"schemaVersion\":2," + + "\"algorithm\":{" + + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," + + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16}," + + "\"rules\":[" + + "{\"id\":\"ad-1\",\"durationMs\":15000,\"anchorOffsetMs\":0," + + "\"anchorDurationMs\":3000,\"fingerprint\":[\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]," + + "\"variants\":[[\"32e00078\",\"2dc500e0\",\"33890180\",\"f70a0300\"]]}," + + "{\"id\":\"ad-2\",\"durationMs\":12000,\"anchorOffsetMs\":1000," + + "\"anchorDurationMs\":3000,\"fingerprint\":[\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]}" + + "]}"; + } + + private static String jsonWithHash(String hash) { + return validSdkJson().replace("32f0007c", hash); + } + + private static String jsonWithRuleCount(int count) { + String rule = "{\"id\":\"ad-1\",\"durationMs\":15000,\"anchorOffsetMs\":0," + + "\"anchorDurationMs\":3000,\"fingerprint\":[\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]}"; + StringBuilder rules = new StringBuilder(); + for (int i = 0; i < count; i++) { + if (i > 0) rules.append(','); + rules.append(rule.replace("ad-1", "ad-" + (i + 1))); + } + return "{\"schemaVersion\":2,\"algorithm\":{" + + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," + + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16},\"rules\":[" + + rules + "]}"; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/PrioritizedAdAudioRuleSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/PrioritizedAdAudioRuleSourceTest.java new file mode 100644 index 0000000000..a1c8f43c47 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/PrioritizedAdAudioRuleSourceTest.java @@ -0,0 +1,117 @@ +package com.fongmi.android.tv.ad.audio; + +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PrioritizedAdAudioRuleSourceTest { + + @Test + public void validLocalRulesWinWithoutLoadingSignedSource() { + AdAudioRuleSnapshot local = snapshot("local", "l1", rules("local-rule"), List.of(), ""); + AtomicInteger signedLoads = new AtomicInteger(); + AdAudioRuleSource signed = () -> { + signedLoads.incrementAndGet(); + return snapshot("signed:official", "s1", rules("signed-rule"), List.of(), ""); + }; + + AdAudioRuleSnapshot selected = + new PrioritizedAdAudioRuleSource(() -> local, signed).load(); + + assertEquals(local, selected); + assertEquals(0, signedLoads.get()); + } + + @Test + public void emptyLocalRulesFallBackToSignedSnapshot() { + AdAudioRuleSnapshot signed = + snapshot("signed:official", "s1", rules("signed-rule"), List.of(), ""); + PrioritizedAdAudioRuleSource source = new PrioritizedAdAudioRuleSource( + () -> snapshot("local", "", AudioFingerprintRuleSet.empty(), List.of(), ""), + () -> signed); + + AdAudioRuleSnapshot selected = source.load(); + + assertEquals(signed, selected); + } + + @Test + public void invalidLocalRulesFallBackWithFixedWarning() { + AdAudioRuleSnapshot signed = snapshot( + "signed:official", "s2", rules("signed-rule"), List.of("CACHE_RECOVERED"), ""); + PrioritizedAdAudioRuleSource source = new PrioritizedAdAudioRuleSource( + () -> snapshot("local", "", AudioFingerprintRuleSet.empty(), + List.of(), "RULE_LOAD_FAILED"), + () -> signed); + + AdAudioRuleSnapshot selected = source.load(); + + assertEquals("signed:official", selected.sourceId()); + assertEquals("signed-rule", selected.ruleSet().rules().get(0).id()); + assertTrue(selected.warnings().contains("CACHE_RECOVERED")); + assertTrue(selected.warnings().contains("LOCAL_SOURCE_INVALID")); + assertFalse(selected.hasError()); + } + + @Test + public void localExceptionStillFallsBackToSignedRules() { + AdAudioRuleSnapshot signed = + snapshot("signed:official", "s3", rules("signed-rule"), List.of(), ""); + PrioritizedAdAudioRuleSource source = new PrioritizedAdAudioRuleSource( + () -> { + throw new IllegalStateException("broken local source"); + }, + () -> signed); + + AdAudioRuleSnapshot selected = source.load(); + + assertEquals(signed.sourceId(), selected.sourceId()); + assertTrue(selected.warnings().contains("LOCAL_SOURCE_INVALID")); + } + + @Test + public void nullLocalSnapshotStillFallsBackToSignedRules() { + AdAudioRuleSnapshot signed = + snapshot("signed:official", "s4", rules("signed-rule"), List.of(), ""); + PrioritizedAdAudioRuleSource source = + new PrioritizedAdAudioRuleSource(() -> null, () -> signed); + + AdAudioRuleSnapshot selected = source.load(); + + assertEquals(signed.sourceId(), selected.sourceId()); + assertTrue(selected.warnings().contains("LOCAL_SOURCE_INVALID")); + } + + @Test + public void bothUnavailableReturnEmptySnapshotWithLocalSpecificError() { + PrioritizedAdAudioRuleSource source = new PrioritizedAdAudioRuleSource( + () -> snapshot("local", "", AudioFingerprintRuleSet.empty(), + List.of(), "RULE_LOAD_FAILED"), + () -> snapshot("signed:official", "", AudioFingerprintRuleSet.empty(), + List.of(), "NO_VALID_SIGNED_PACKAGE")); + + AdAudioRuleSnapshot selected = source.load(); + + assertFalse(selected.hasRules()); + assertEquals("RULE_LOAD_FAILED", selected.lastError()); + assertTrue(selected.warnings().contains("LOCAL_SOURCE_INVALID")); + } + + private static AdAudioRuleSnapshot snapshot(String sourceId, String version, + AudioFingerprintRuleSet ruleSet, + List warnings, String error) { + return new AdAudioRuleSnapshot(sourceId, version, ruleSet, warnings, error); + } + + private static AudioFingerprintRuleSet rules(String id) { + AudioFingerprintRule rule = new AudioFingerprintRule( + id, 15_000L, 0L, 3_000L, + new int[]{0, 1, 2, 3}, List.of()); + return new AudioFingerprintRuleSet(AudioFingerprintConfig.standard(), List.of(rule)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecBoundaryTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecBoundaryTest.java new file mode 100644 index 0000000000..373cfcbc68 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecBoundaryTest.java @@ -0,0 +1,90 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +/** 评审清单里的边界与溢出项,逐条钉住。 */ +public class ProbeRuleCodecBoundaryTest { + + /** revision 超出 JS 安全整数必须拒绝,且不能逃出成 NumberFormatException。 */ + @Test + public void revisionBeyondSafeIntegerIsRejectedAsIllegalArgument() { + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":9007199254740992"))); + } + + /** 20 位数字超 long 上限,Long.parseLong 会抛 NumberFormatException,必须被转成 IAE。 */ + @Test + public void revisionWiderThanLongIsRejectedAsIllegalArgument() { + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":99999999999999999999"))); + } + + /** durationMs 上下边界都是闭区间。 */ + @Test + public void durationBoundsAreInclusive() { + assertEquals(1, ProbeRuleCodec.fromJson(json(rule("a", 600_000, 0, 5_000))).ruleSet().rules().size()); + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleCodec.fromJson(json(rule("a", 600_001, 0, 5_000)))); + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleCodec.fromJson(json(rule("a", 999, 0, 5_000)))); + } + + /** anchorDurationMs 2000..5000 闭区间。 */ + @Test + public void anchorDurationBoundsAreInclusive() { + assertEquals(1, ProbeRuleCodec.fromJson(json(rule("a", 20_000, 0, 2_000))).ruleSet().rules().size()); + assertEquals(1, ProbeRuleCodec.fromJson(json(rule("a", 20_000, 0, 5_000))).ruleSet().rules().size()); + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleCodec.fromJson(json(rule("a", 20_000, 0, 1_999)))); + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleCodec.fromJson(json(rule("a", 20_000, 0, 5_001)))); + } + + /** anchorOffset + anchorDuration == durationMs 恰好合法(边界不能少算一)。 */ + @Test + public void anchorTouchingTheAdEndIsAccepted() { + assertEquals(1, ProbeRuleCodec.fromJson(json(rule("a", 5_000, 0, 5_000))).ruleSet().rules().size()); + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleCodec.fromJson(json(rule("a", 5_000, 1, 5_000)))); + } + + /** test.adStartMs 加上 durationMs 溢出 long 时不能因为回绕而通过检查。 */ + @Test + public void testAdStartOverflowIsRejected() { + String rule = rule("a", 20_000, 0, 5_000); + String withTest = rule.substring(0, rule.length() - 1) + + ",\"test\":{\"url\":\"https://e.com/a.m3u8\",\"adStartMs\":9223372036854775000}}"; + assertThrows(IllegalArgumentException.class, () -> ProbeRuleCodec.fromJson(json(withTest))); + } + + private static String valid() { + return json(rule("a", 20_000, 0, 5_000)); + } + + private static String json(String rules) { + return "{\"format\":\"ad-audio-probe-rules\",\"schemaVersion\":1,\"revision\":2," + + "\"algorithm\":\"spectral-sequence-v1\",\"rules\":[" + rules + "]}"; + } + + private static String rule(String id, long durationMs, long anchorOffsetMs, long anchorDurationMs) { + StringBuilder phases = new StringBuilder(); + for (int p = 0; p < ProbeRuleCodec.PHASES_MS.length; p++) { + int phaseMs = ProbeRuleCodec.PHASES_MS[p]; + if (p > 0) phases.append(','); + phases.append("{\"phaseMs\":").append(phaseMs).append(",\"hashes\":["); + int n = (int) ((anchorDurationMs - phaseMs - 512) / 256) + 1; + for (int f = 0; f < n; f++) { + if (f > 0) phases.append(','); + phases.append('"').append(String.format("%08x", 0x0f1e2d3c ^ (f * 0x9e3779b9))).append('"'); + } + phases.append("]}"); + } + return "{\"id\":\"" + id + "\",\"durationMs\":" + durationMs + + ",\"anchorOffsetMs\":" + anchorOffsetMs + + ",\"anchorDurationMs\":" + anchorDurationMs + + ",\"fingerprints\":[" + phases + "]}"; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecTest.java new file mode 100644 index 0000000000..4b535c2f74 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecTest.java @@ -0,0 +1,317 @@ +package com.fongmi.android.tv.ad.audio; + +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Probe Rules v1 与本项目 {@code spectral-sequence-v2} 的兼容契约。 + * + *

固件 {@code probe-rules-v1-community.json} 是 0o755/m3u8-ad-audio-rules 在 revision 2 + * 时的真实文件,用来保证解析器不是只能吃自造样本。 + */ +public class ProbeRuleCodecTest { + + private static final String COMMUNITY_RULES = "/probe-rules-v1-community.json"; + private static final int SEED = 0x0f1e2d3c; + + @Test + public void communityRulesJsonConvertsToProjectRuleSet() throws IOException { + ProbeRuleCodec.Document document = ProbeRuleCodec.fromBytes(communityRules()); + + assertEquals(2L, document.revision()); + assertEquals(4, document.ruleSet().rules().size()); + assertEquals(AudioFingerprintConfig.standard(), document.ruleSet().config()); + + AudioFingerprintRule first = document.ruleSet().rules().get(0); + assertEquals("ad-1786989757783-1", first.id()); + assertEquals(47_000L, first.durationMs()); + assertEquals(0L, first.anchorOffsetMs()); + assertEquals(5_000L, first.anchorDurationMs()); + assertEquals(4, first.allSequences().size()); + } + + @Test + public void phaseZeroBecomesMainFingerprintAndRemainingPhasesBecomeVariants() throws IOException { + AudioFingerprintRule rule = ProbeRuleCodec.fromBytes(communityRules()).ruleSet().rules().get(0); + + assertEquals(0x687f0807, rule.fingerprint()[0]); + assertEquals(3, rule.variants().size()); + assertEquals(0x4e370617, rule.variants().get(0)[0]); + assertEquals(0x4f960397, rule.variants().get(1)[0]); + assertEquals(0x4f9601d7, rule.variants().get(2)[0]); + assertEquals(18, rule.fingerprint().length); + assertEquals(18, rule.variants().get(0).length); + assertEquals(18, rule.variants().get(1).length); + assertEquals(17, rule.variants().get(2).length); + } + + /** + * 上游相位标签必须与 {@link SpectralFingerprint} 的取相位步长一致,否则 v1 的 {@code phaseMs} + * 就不能直接当成本项目的变体顺序。 + */ + @Test + public void projectPhaseOffsetsMatchProbePhaseLabels() { + AudioFingerprintConfig config = AudioFingerprintConfig.standard(); + int phaseStep = Math.max(1, config.hopSamples() / 4); + + for (int phase = 0; phase < ProbeRuleCodec.PHASES_MS.length; phase++) { + long phaseMs = Math.round(phase * phaseStep * 1_000.0 / config.sampleRate()); + assertEquals(ProbeRuleCodec.PHASES_MS[phase], phaseMs); + } + } + + /** 每个相位的帧数必须等于本项目取窗循环在同样锚点长度下真正产出的帧数。 */ + @Test + public void frameCountsMatchTheProjectExtractionLoop() throws IOException { + AudioFingerprintConfig config = AudioFingerprintConfig.standard(); + + for (AudioFingerprintRule rule : ProbeRuleCodec.fromBytes(communityRules()).ruleSet().rules()) { + for (int phase = 0; phase < ProbeRuleCodec.PHASES_MS.length; phase++) { + assertEquals("phase " + ProbeRuleCodec.PHASES_MS[phase], + framesFromExtractionLoop(rule.anchorDurationMs(), + ProbeRuleCodec.PHASES_MS[phase], config), + rule.allSequences().get(phase).length); + } + } + } + + /** + * 反演社区 hash 的位布局:低 16 位是「频带 >= 均值」,高 16 位是「频带 >= 下一频带」, + * 且 band15 与 band0 环绕比较。两者互相约束,随机布局无法全部满足。 + */ + @Test + public void communityHashesUseTheProjectBitLayout() throws IOException { + int checked = 0; + for (AudioFingerprintRule rule : ProbeRuleCodec.fromBytes(communityRules()).ruleSet().rules()) { + for (int[] sequence : rule.allSequences()) { + for (int hash : sequence) { + if (hash == 0) continue; + checked += assertBandLayout(hash); + } + } + } + assertTrue("constraints checked: " + checked, checked > 500); + } + + @Test + public void testMetadataIsValidatedThenDropped() { + String json = json(withTest(rule("ad-one", 20_000, 0, 5_000, SEED), + "{\"url\":\"https://example.com/a.m3u8\",\"adStartMs\":15000}")); + + assertEquals(1, ProbeRuleCodec.fromJson(json).ruleSet().rules().size()); + } + + @Test + public void byteOrderMarkIsAccepted() { + byte[] bytes = ("\uFEFF" + valid()).getBytes(StandardCharsets.UTF_8); + + assertEquals(1, ProbeRuleCodec.fromBytes(bytes).ruleSet().rules().size()); + } + + @Test(expected = IllegalArgumentException.class) + public void unsupportedFormatIsRejected() { + ProbeRuleCodec.fromJson(valid().replace("ad-audio-probe-rules", "ad-audio-rules")); + } + + @Test(expected = IllegalArgumentException.class) + public void unsupportedSchemaVersionIsRejected() { + ProbeRuleCodec.fromJson(valid().replace("\"schemaVersion\":1", "\"schemaVersion\":2")); + } + + /** 本项目自己的 v2 规则文件必须走 {@link AudioFingerprintRuleCodec},不能从这里进来。 */ + @Test(expected = IllegalArgumentException.class) + public void projectAlgorithmIdIsRejected() { + ProbeRuleCodec.fromJson(valid() + .replace("spectral-sequence-v1", AudioFingerprintRuleSet.ALGORITHM_ID)); + } + + @Test(expected = IllegalArgumentException.class) + public void zeroRevisionIsRejected() { + ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":0")); + } + + @Test(expected = IllegalArgumentException.class) + public void nonIntegerRevisionIsRejected() { + ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":2.0")); + } + + @Test(expected = IllegalArgumentException.class) + public void duplicateRootFieldIsRejected() { + ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":2,\"revision\":3")); + } + + @Test(expected = IllegalArgumentException.class) + public void unknownRootFieldIsRejected() { + ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":2,\"enabled\":true")); + } + + @Test(expected = IllegalArgumentException.class) + public void trailingDataIsRejected() { + ProbeRuleCodec.fromJson(valid() + "{}"); + } + + @Test(expected = IllegalArgumentException.class) + public void unknownRuleFieldIsRejected() { + ProbeRuleCodec.fromJson(valid().replace("\"durationMs\":20000", "\"durationMs\":20000,\"enabled\":true")); + } + + @Test(expected = IllegalArgumentException.class) + public void uppercaseRuleIdIsRejected() { + ProbeRuleCodec.fromJson(valid().replace("\"ad-one\"", "\"AD-ONE\"")); + } + + @Test(expected = IllegalArgumentException.class) + public void uppercaseHashIsRejected() { + ProbeRuleCodec.fromJson(valid().replaceFirst("\"([0-9a-f]{8})\"", "\"0F1E2D3C\"")); + } + + @Test(expected = IllegalArgumentException.class) + public void missingPhaseIsRejected() { + ProbeRuleCodec.fromJson(valid() + .replaceFirst(",\\{\"phaseMs\":192,\"hashes\":\\[[^\\]]*]}", "")); + } + + @Test(expected = IllegalArgumentException.class) + public void duplicatePhaseIsRejected() { + ProbeRuleCodec.fromJson(valid().replace("\"phaseMs\":64", "\"phaseMs\":0")); + } + + @Test(expected = IllegalArgumentException.class) + public void unsupportedPhaseIsRejected() { + ProbeRuleCodec.fromJson(valid().replace("\"phaseMs\":64", "\"phaseMs\":32")); + } + + /** 帧数少一帧就说明锚点长度与序列不自洽,必须整包拒绝而不是当成短规则收下。 */ + @Test(expected = IllegalArgumentException.class) + public void wrongFrameCountIsRejected() { + ProbeRuleCodec.fromJson(valid().replaceFirst(",\"[0-9a-f]{8}\"]", "]")); + } + + @Test(expected = IllegalArgumentException.class) + public void anchorShorterThanUpstreamMinimumIsRejected() { + ProbeRuleCodec.fromJson(json(rule("ad-one", 20_000, 0, 1_536, SEED))); + } + + @Test(expected = IllegalArgumentException.class) + public void anchorBeyondDurationIsRejected() { + ProbeRuleCodec.fromJson(json(rule("ad-one", 4_000, 2_000, 5_000, SEED))); + } + + @Test(expected = IllegalArgumentException.class) + public void indistinctPrefixIsRejected() { + ProbeRuleCodec.fromJson(json(flatRule("ad-one", 20_000, 5_000))); + } + + /** 前缀相同但推导出的广告终点不同,运行时无法安全裁决,整包拒绝。 */ + @Test(expected = IllegalArgumentException.class) + public void sharedPrefixWithDifferentEndOffsetIsRejected() { + ProbeRuleCodec.fromJson(json(rule("ad-one", 20_000, 0, 5_000, SEED) + + "," + rule("ad-two", 30_000, 0, 5_000, SEED))); + } + + @Test + public void sharedPrefixWithTheSameEndOffsetIsAccepted() { + String json = json(rule("ad-one", 20_000, 0, 5_000, SEED) + + "," + rule("ad-two", 25_000, 5_000, 5_000, SEED)); + + assertEquals(2, ProbeRuleCodec.fromJson(json).ruleSet().rules().size()); + } + + @Test(expected = IllegalArgumentException.class) + public void unknownTestFieldIsRejected() { + ProbeRuleCodec.fromJson(json(withTest(rule("ad-one", 20_000, 0, 5_000, SEED), + "{\"url\":\"https://example.com/a.m3u8\",\"adStartMs\":15000,\"note\":\"x\"}"))); + } + + @Test(expected = IllegalArgumentException.class) + public void nonHttpTestUrlIsRejected() { + ProbeRuleCodec.fromJson(json(withTest(rule("ad-one", 20_000, 0, 5_000, SEED), + "{\"url\":\"file:///tmp/a.m3u8\",\"adStartMs\":15000}"))); + } + + private byte[] communityRules() throws IOException { + try (InputStream stream = getClass().getResourceAsStream(COMMUNITY_RULES)) { + assertNotNull(stream); + return stream.readAllBytes(); + } + } + + private static String valid() { + return json(rule("ad-one", 20_000, 0, 5_000, SEED)); + } + + private static String json(String rules) { + return "{\"format\":\"ad-audio-probe-rules\",\"schemaVersion\":1,\"revision\":2," + + "\"algorithm\":\"spectral-sequence-v1\",\"rules\":[" + rules + "]}"; + } + + private static String rule(String id, long durationMs, long anchorOffsetMs, + long anchorDurationMs, int seed) { + return "{\"id\":\"" + id + "\",\"durationMs\":" + durationMs + + ",\"anchorOffsetMs\":" + anchorOffsetMs + + ",\"anchorDurationMs\":" + anchorDurationMs + + ",\"fingerprints\":[" + fingerprints(anchorDurationMs, seed, false) + "]}"; + } + + /** 前八帧全部相同,用来触发区分度不足的拒绝路径。 */ + private static String flatRule(String id, long durationMs, long anchorDurationMs) { + return "{\"id\":\"" + id + "\",\"durationMs\":" + durationMs + + ",\"anchorOffsetMs\":0,\"anchorDurationMs\":" + anchorDurationMs + + ",\"fingerprints\":[" + fingerprints(anchorDurationMs, SEED, true) + "]}"; + } + + private static String withTest(String rule, String test) { + return rule.substring(0, rule.length() - 1) + ",\"test\":" + test + "}"; + } + + private static String fingerprints(long anchorDurationMs, int seed, boolean flat) { + StringBuilder phases = new StringBuilder(); + for (int phase = 0; phase < ProbeRuleCodec.PHASES_MS.length; phase++) { + int phaseMs = ProbeRuleCodec.PHASES_MS[phase]; + if (phase > 0) phases.append(','); + phases.append("{\"phaseMs\":").append(phaseMs).append(",\"hashes\":["); + int count = frames(anchorDurationMs, phaseMs); + for (int frame = 0; frame < count; frame++) { + if (frame > 0) phases.append(','); + int hash = flat ? seed : seed ^ (frame * 0x9e3779b9); + phases.append('"').append(String.format("%08x", hash)).append('"'); + } + phases.append("]}"); + } + return phases.toString(); + } + + private static int frames(long anchorDurationMs, int phaseMs) { + return (int) ((anchorDurationMs - phaseMs - 512) / 256) + 1; + } + + private static int framesFromExtractionLoop(long anchorDurationMs, int phaseMs, + AudioFingerprintConfig config) { + int total = (int) Math.round(anchorDurationMs * config.sampleRate() / 1_000.0); + int offset = (int) Math.round(phaseMs * config.sampleRate() / 1_000.0); + int frames = 0; + for (int start = offset; start + config.windowSamples() <= total; start += config.hopSamples()) { + frames++; + } + return frames; + } + + private static int assertBandLayout(int hash) { + int checked = 0; + for (int band = 0; band < 16; band++) { + boolean aboveMean = (hash & (1 << band)) != 0; + if (aboveMean == ((hash & (1 << ((band + 1) % 16))) != 0)) continue; + assertEquals("band " + band + " of " + Integer.toHexString(hash), + aboveMean, (hash & (1 << (band + 16))) != 0); + checked++; + } + return checked; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleDownloaderTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleDownloaderTest.java new file mode 100644 index 0000000000..9e5e262134 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleDownloaderTest.java @@ -0,0 +1,262 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okio.Buffer; + +public class ProbeRuleDownloaderTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private MockWebServer server; + private OkHttpClient client; + + @Before + public void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + // 必须与生产配置一致:重定向由 ProbeRuleDownloader 手动逐跳跟随, + // 用默认 followRedirects(true) 的 client 会让重定向相关断言失去意义。 + client = new OkHttpClient.Builder().followRedirects(false).build(); + } + + @After + public void tearDown() throws IOException { + server.shutdown(); + } + + /** 规则源不签名,所以传输层必须是 HTTPS,否则任何中间人都能改指纹。 */ + @Test + public void plainHttpUrlIsRejectedBeforeAnyRequest() throws Exception { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> ProbeRuleDownloader.refresh("http://example.com/rules.json", store())); + + assertEquals("probe rule url must be https", error.getMessage()); + assertEquals(0, server.getRequestCount()); + } + + @Test + public void successfulDownloadIsInstalled() throws Exception { + server.enqueue(new MockResponse().setBody(community())); + ProbeRuleStore store = store(); + + AdAudioRuleSnapshot snapshot = ProbeRuleDownloader.refresh(url(), store, client); + + assertEquals(4, snapshot.ruleSet().rules().size()); + assertTrue(snapshot.version().startsWith("2:")); + assertEquals(2L, store.revision()); + } + + @Test + public void oversizedBodyIsRejectedAndNothingIsCached() throws Exception { + Buffer body = new Buffer(); + body.write(new byte[ProbeRuleStore.MAX_DOWNLOAD_BYTES + 1024]); + server.enqueue(new MockResponse().setBody(body)); + ProbeRuleStore store = store(); + + assertThrows(IOException.class, () -> ProbeRuleDownloader.refresh(url(), store, client)); + + assertEquals(0L, store.revision()); + } + + @Test + public void serverErrorIsRejected() throws Exception { + server.enqueue(new MockResponse().setResponseCode(500)); + + assertThrows(IOException.class, () -> ProbeRuleDownloader.refresh(url(), store(), client)); + } + + @Test + public void malformedBodyLeavesTheStoreEmpty() throws Exception { + server.enqueue(new MockResponse().setBody("{\"format\":\"nope\"}")); + ProbeRuleStore store = store(); + + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleDownloader.refresh(url(), store, client)); + + assertEquals(0L, store.revision()); + } + + /** 304 表示内容没变,应当返回现有缓存而不是当成下载失败。 */ + @Test + public void notModifiedKeepsTheCachedSnapshot() throws Exception { + ProbeRuleStore store = store(); + store.install(community().getBytes(StandardCharsets.UTF_8)); + server.enqueue(new MockResponse().setResponseCode(304)); + + AdAudioRuleSnapshot snapshot = ProbeRuleDownloader.refresh(url(), store, client); + + assertEquals(4, snapshot.ruleSet().rules().size()); + assertEquals(2L, store.revision()); + } + + /** + * chunked 响应没有 Content-Length,声明长度检查会拿到 -1, + * 因此边读边计数的上限必须独立生效。 + */ + @Test + public void oversizedChunkedBodyIsStillRejected() throws Exception { + Buffer body = new Buffer(); + body.write(new byte[ProbeRuleStore.MAX_DOWNLOAD_BYTES + 1024]); + server.enqueue(new MockResponse().setChunkedBody(body, 64 * 1024)); + ProbeRuleStore store = store(); + + assertThrows(IOException.class, () -> ProbeRuleDownloader.refresh(url(), store, client)); + + assertEquals(0L, store.revision()); + } + + /** + * URL 非法时必须回调,否则设置页置上的「正在刷新」文本会一直停在那里。 + * 这里只覆盖不联网的分支,联网分支由 {@link #successfulDownloadIsInstalled()} 覆盖。 + */ + @Test + public void invalidUrlReportsFailureExactlyOnce() { + RecordingCallback callback = new RecordingCallback(); + + boolean started = ProbeRuleDownloader.refreshNow( + callback, "http://example.com/rules.json", null); + + assertTrue("caller must not have to reset its own state", started); + assertEquals(0, callback.success.get()); + assertEquals(0, callback.disabled.get()); + assertNotNull(callback.failure.get()); + assertEquals(0, server.getRequestCount()); + } + + /** + * 空地址表示用户关闭了社区规则源,属于正常状态,必须走 onDisabled 而不是 + * 把「必须是 https」这种内部英文消息弹给用户。 + */ + @Test + public void emptySourceReportsDisabledInsteadOfAnError() { + RecordingCallback callback = new RecordingCallback(); + + boolean started = ProbeRuleDownloader.refreshNow(callback, "", null); + + assertTrue(started); + assertEquals(1, callback.disabled.get()); + assertNull(callback.failure.get()); + assertEquals(0, callback.success.get()); + assertEquals(0, server.getRequestCount()); + } + + /** + * 规则源无签名,传输层是唯一保障,所以 https 到明文的降级必须在**发出明文请求之前**拒绝。 + * OkHttp 的 followSslRedirects(false) 实测挡不住这一跳(它照样跟随), + * 所以重定向改成手动逐跳跟随,判定逻辑就是这里直接测的这个函数。 + */ + @Test + public void redirectOffHttpsIsRejectedBeforeTheNextRequest() throws Exception { + HttpUrl current = HttpUrl.get("https://rules.example.com/rules.json"); + + assertThrows(IOException.class, + () -> ProbeRuleDownloader.nextRedirect(current, "http://evil.example.com/x.json", true)); + + // 协议相对地址会沿用当前协议,仍是 https,不算降级;换主机本身是允许的。 + assertEquals("https://evil.example.com/x.json", + ProbeRuleDownloader.nextRedirect(current, "//evil.example.com/x.json", true)); + } + + @Test + public void redirectWithinHttpsIsAccepted() throws Exception { + HttpUrl current = HttpUrl.get("https://rules.example.com/rules.json"); + + assertEquals("https://rules.example.com/v2.json", + ProbeRuleDownloader.nextRedirect(current, "/v2.json", true)); + assertEquals("https://cdn.example.com/v2.json", + ProbeRuleDownloader.nextRedirect(current, "https://cdn.example.com/v2.json", true)); + } + + @Test + public void redirectWithoutLocationIsRejected() { + HttpUrl current = HttpUrl.get("https://rules.example.com/rules.json"); + + assertThrows(IOException.class, () -> ProbeRuleDownloader.nextRedirect(current, null, true)); + assertThrows(IOException.class, () -> ProbeRuleDownloader.nextRedirect(current, "", true)); + } + + /** 同协议内的重定向必须能正常跟随。 */ + @Test + public void sameSchemeRedirectIsFollowedEndToEnd() throws Exception { + server.enqueue(new MockResponse().setResponseCode(302).setHeader("Location", "/final.json")); + server.enqueue(new MockResponse().setBody(community())); + ProbeRuleStore store = store(); + + AdAudioRuleSnapshot snapshot = ProbeRuleDownloader.refresh(url(), store, client); + + assertEquals(4, snapshot.ruleSet().rules().size()); + assertEquals(2, server.getRequestCount()); + } + + /** 重定向循环必须有界,否则会一直打请求。 */ + @Test + public void redirectLoopIsBounded() throws Exception { + for (int i = 0; i < 20; i++) { + server.enqueue(new MockResponse().setResponseCode(302).setHeader("Location", "/again.json")); + } + + assertThrows(IOException.class, () -> ProbeRuleDownloader.refresh(url(), store(), client)); + + assertTrue("redirects must be bounded, saw " + server.getRequestCount(), + server.getRequestCount() <= 8); + } + + private static final class RecordingCallback implements ProbeRuleDownloader.Callback { + + private final AtomicInteger success = new AtomicInteger(); + private final AtomicInteger disabled = new AtomicInteger(); + private final AtomicReference failure = new AtomicReference<>(); + + @Override + public void onSuccess(AdAudioRuleSnapshot snapshot) { + success.incrementAndGet(); + } + + @Override + public void onFailure(Throwable error) { + failure.set(error); + } + + @Override + public void onDisabled() { + disabled.incrementAndGet(); + } + } + + private String url() { + return server.url("/rules.json").toString(); + } + + private ProbeRuleStore store() throws IOException { + return new ProbeRuleStore(temporaryFolder.newFolder().toPath()); + } + + private String community() throws IOException { + try (InputStream stream = getClass().getResourceAsStream("/probe-rules-v1-community.json")) { + assertNotNull(stream); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecarTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecarTest.java new file mode 100644 index 0000000000..8580c4d0ab --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecarTest.java @@ -0,0 +1,138 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +public class ProbeRuleSidecarTest { + + private static final long REVISION = 8L; + private static final String SOURCE_PACKAGE_ID = "official.ad-audio"; + + @Test + public void validSidecarBindsToSignedPayload() throws Exception { + byte[] sourceDigest = digest("signed-v2"); + byte[] canonicalRules = rules(); + ProbeRuleSidecar sidecar = sidecar(sourceDigest, canonicalRules); + + sidecar.requireBoundTo(SOURCE_PACKAGE_ID, REVISION, sourceDigest); + + assertEquals(SOURCE_PACKAGE_ID, sidecar.sourcePackageId()); + assertEquals(REVISION, sidecar.sourceRevision()); + assertEquals(ProbeRuleSidecar.ALGORITHM_ID, sidecar.algorithm()); + assertEquals("converter-1", sidecar.converterVersion()); + assertArrayEquals(canonicalRules, sidecar.canonicalRules()); + } + + @Test + public void bindingRejectsRevisionMismatch() throws Exception { + byte[] sourceDigest = digest("signed-v2"); + ProbeRuleSidecar sidecar = sidecar(sourceDigest, rules()); + + assertThrows(IllegalArgumentException.class, + () -> sidecar.requireBoundTo(SOURCE_PACKAGE_ID, REVISION + 1L, sourceDigest)); + } + + @Test + public void bindingRejectsSourcePackageMismatch() throws Exception { + byte[] sourceDigest = digest("signed-v2"); + ProbeRuleSidecar sidecar = sidecar(sourceDigest, rules()); + + assertThrows(IllegalArgumentException.class, + () -> sidecar.requireBoundTo("other.ad-audio", REVISION, sourceDigest)); + } + + @Test + public void bindingRejectsSourceDigestMismatch() throws Exception { + ProbeRuleSidecar sidecar = sidecar(digest("signed-v2"), rules()); + + assertThrows(IllegalArgumentException.class, + () -> sidecar.requireBoundTo(SOURCE_PACKAGE_ID, REVISION, digest("other-v2"))); + } + + @Test + public void verifiedRejectsSidecarDigestMismatch() throws Exception { + byte[] canonicalRules = rules(); + + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, digest("signed-v2"), + ProbeRuleSidecar.ALGORITHM_ID, "converter-1", canonicalRules, + digest("different-sidecar"))); + } + + @Test + public void verifiedRejectsUnsupportedAlgorithm() throws Exception { + byte[] canonicalRules = rules(); + + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, digest("signed-v2"), + "spectral-sequence-v2", "converter-1", canonicalRules, + digest(canonicalRules))); + } + + @Test + public void verifiedRejectsOversizedCanonicalRules() throws Exception { + byte[] oversized = new byte[AdAudioRuleStore.MAX_IMPORT_BYTES + 1]; + + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, digest("signed-v2"), + ProbeRuleSidecar.ALGORITHM_ID, "converter-1", oversized, + digest(oversized))); + } + + @Test + public void verifiedRejectsInvalidUtf8Rules() throws Exception { + byte[] invalidUtf8 = new byte[]{(byte) 0xC3, 0x28}; + + assertThrows(IllegalArgumentException.class, + () -> ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, + digest("signed-v2"), ProbeRuleSidecar.ALGORITHM_ID, + "converter-1", invalidUtf8, digest(invalidUtf8))); + } + + @Test + public void byteArraysAreDefensivelyCopied() throws Exception { + byte[] sourceDigest = digest("signed-v2"); + byte[] canonicalRules = rules(); + byte[] sidecarDigest = digest(canonicalRules); + ProbeRuleSidecar sidecar = ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, sourceDigest, + ProbeRuleSidecar.ALGORITHM_ID, "converter-1", canonicalRules, sidecarDigest); + + sourceDigest[0] ^= 1; + canonicalRules[0] ^= 1; + sidecarDigest[0] ^= 1; + + byte[] first = sidecar.canonicalRules(); + byte[] second = sidecar.canonicalRules(); + assertNotSame(first, second); + assertArrayEquals(rules(), first); + assertArrayEquals(digest("signed-v2"), sidecar.sourceDigest()); + assertArrayEquals(digest(rules()), sidecar.sidecarDigest()); + } + + private static ProbeRuleSidecar sidecar(byte[] sourceDigest, byte[] canonicalRules) + throws Exception { + return ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, sourceDigest, + ProbeRuleSidecar.ALGORITHM_ID, "converter-1", canonicalRules, + digest(canonicalRules)); + } + + private static byte[] rules() { + return ("{\"format\":\"ad-audio-probe-rules\",\"schemaVersion\":1," + + "\"revision\":8,\"rules\":[]}").getBytes(StandardCharsets.UTF_8); + } + + private static byte[] digest(String value) throws Exception { + return digest(value.getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] digest(byte[] value) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(value); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleStoreTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleStoreTest.java new file mode 100644 index 0000000000..180beffb1c --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleStoreTest.java @@ -0,0 +1,153 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** 上游 m3u8-ad-audio-probe 的规则更新语义:只升不降、同版本冲突拒绝、失败保留旧缓存。 */ +public class ProbeRuleStoreTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void installedRulesSurviveRestart() throws Exception { + Path directory = temporaryFolder.newFolder().toPath(); + + AdAudioRuleSnapshot installed = new ProbeRuleStore(directory).install(community()); + ProbeRuleStore reopened = new ProbeRuleStore(directory); + AdAudioRuleSnapshot loaded = reopened.load(); + + assertEquals(4, installed.ruleSet().rules().size()); + assertEquals(4, loaded.ruleSet().rules().size()); + assertEquals(installed.version(), loaded.version()); + assertTrue(loaded.version().startsWith("2:")); + assertEquals(2L, reopened.revision()); + assertFalse(loaded.hasError()); + } + + @Test + public void higherRevisionReplacesTheCache() throws Exception { + ProbeRuleStore store = new ProbeRuleStore(temporaryFolder.newFolder().toPath()); + store.install(community()); + + AdAudioRuleSnapshot updated = store.install(withRevision(3)); + + assertTrue(updated.version().startsWith("3:")); + assertEquals(3L, store.revision()); + } + + @Test + public void lowerRevisionIsRejectedAndCacheKeeps() throws Exception { + ProbeRuleStore store = new ProbeRuleStore(temporaryFolder.newFolder().toPath()); + store.install(community()); + + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> store.install(withRevision(1))); + + assertEquals(ProbeRuleStore.ERROR_ROLLBACK, error.getMessage()); + assertEquals(2L, store.revision()); + } + + @Test + public void sameRevisionWithDifferentContentIsAPublishConflict() throws Exception { + ProbeRuleStore store = new ProbeRuleStore(temporaryFolder.newFolder().toPath()); + store.install(community()); + byte[] tampered = text().replace("ad-1786989757783-1", "ad-1786989757783-9") + .getBytes(StandardCharsets.UTF_8); + + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> store.install(tampered)); + + assertEquals(ProbeRuleStore.ERROR_CONFLICT, error.getMessage()); + assertEquals("ad-1786989757783-1", store.load().ruleSet().rules().get(0).id()); + } + + @Test + public void reinstallingIdenticalBytesIsAccepted() throws Exception { + ProbeRuleStore store = new ProbeRuleStore(temporaryFolder.newFolder().toPath()); + AdAudioRuleSnapshot first = store.install(community()); + + AdAudioRuleSnapshot second = store.install(community()); + + assertEquals(first.version(), second.version()); + } + + @Test + public void corruptCacheReportsAnErrorAndCanBeReplaced() throws Exception { + Path directory = temporaryFolder.newFolder().toPath(); + ProbeRuleStore store = new ProbeRuleStore(directory); + store.install(community()); + Files.writeString(directory.resolve("ad-audio-probe-rules.json"), "{\"rules\":["); + + AdAudioRuleSnapshot broken = store.load(); + AdAudioRuleSnapshot repaired = store.install(community()); + + assertEquals(ProbeRuleStore.ERROR_LOAD_FAILED, broken.lastError()); + assertFalse(broken.hasRules()); + assertEquals(4, repaired.ruleSet().rules().size()); + } + + @Test + public void malformedDownloadLeavesNoTemporaryFile() throws Exception { + Path directory = temporaryFolder.newFolder().toPath(); + ProbeRuleStore store = new ProbeRuleStore(directory); + + assertThrows(IllegalArgumentException.class, + () -> store.install("{\"format\":\"nope\"}".getBytes(StandardCharsets.UTF_8))); + + assertFalse(Files.exists(directory.resolve("ad-audio-probe-rules.json"))); + assertFalse(Files.exists(directory.resolve("ad-audio-probe-rules.json.tmp"))); + assertEquals(0L, store.revision()); + } + + @Test + public void emptyStoreLoadsWithoutError() throws Exception { + AdAudioRuleSnapshot snapshot = + new ProbeRuleStore(temporaryFolder.newFolder().toPath()).load(); + + assertFalse(snapshot.hasRules()); + assertFalse(snapshot.hasError()); + assertEquals(ProbeRuleStore.SOURCE_ID, snapshot.sourceId()); + } + + /** 手动导入的规则优先于社区源,社区源只在本地为空时补位。 */ + @Test + public void manualImportWinsOverTheCommunityFeed() throws Exception { + ProbeRuleStore probe = new ProbeRuleStore(temporaryFolder.newFolder().toPath()); + AdAudioRuleStore local = new AdAudioRuleStore(temporaryFolder.newFolder().toPath()); + probe.install(community()); + local.importJson(text().replace("ad-1786989757783-1", "mine")); + + AdAudioRuleSnapshot resolved = new PrioritizedAdAudioRuleSource(local, probe).load(); + + assertEquals("mine", resolved.ruleSet().rules().get(0).id()); + } + + private byte[] withRevision(int revision) throws Exception { + return text().replace("\"revision\": 2", "\"revision\": " + revision) + .getBytes(StandardCharsets.UTF_8); + } + + private byte[] community() throws Exception { + return text().getBytes(StandardCharsets.UTF_8); + } + + private String text() throws Exception { + try (InputStream stream = getClass().getResourceAsStream("/probe-rules-v1-community.json")) { + assertNotNull(stream); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarFixtures.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarFixtures.java new file mode 100644 index 0000000000..64a62004e7 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarFixtures.java @@ -0,0 +1,84 @@ +package com.fongmi.android.tv.ad.audio; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Optional; + +final class SignedProbeRuleSidecarFixtures { + + static final long NOW = 1_786_900_000_000L; + static final String PACKAGE_ID = "official.ad-audio.probe-v1"; + static final String SOURCE_PACKAGE_ID = "official.ad-audio"; + static final String KEY_ID = "release-1"; + static final String SIGNATURE_ALGORITHM = "ED25519"; + + private SignedProbeRuleSidecarFixtures() { + } + + static byte[] signedEnvelope(long artifactRevision, String converterVersion) + throws Exception { + String unsigned = envelope(artifactRevision, converterVersion, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + byte[] input = SignedProbeRuleSidecarCodec.parse(bytes(unsigned)).signingInput(); + String signature = Base64.getUrlEncoder().withoutPadding().encodeToString( + SignedRulePackageTestKeys.signer().sign(input)); + return bytes(envelope(artifactRevision, converterVersion, signature)); + } + + static SignedProbeRuleSidecarVerifier verifier() { + TrustedRuleKeyRegistry keys = keyId -> KEY_ID.equals(keyId) + ? Optional.of(new TrustedRuleKeyRegistry.Entry(KEY_ID, SIGNATURE_ALGORITHM, + NOW - 86_400_000L, NOW + 86_400_000L, + TrustedRuleKeyRegistry.Status.ACTIVE, SignedRulePackageTestKeys.verifier())) + : Optional.empty(); + return new SignedProbeRuleSidecarVerifier(PACKAGE_ID, keys, () -> NOW); + } + + static byte[] rules() { + return ("{\"format\":\"ad-audio-probe-rules\",\"schemaVersion\":1," + + "\"revision\":42,\"algorithm\":\"spectral-sequence-v1\"," + + "\"rules\":[]}").getBytes(StandardCharsets.UTF_8); + } + + private static String envelope(long artifactRevision, String converterVersion, + String signature) throws Exception { + byte[] rules = rules(); + return "{" + + "\"artifactSchemaVersion\":1," + + "\"artifactType\":\"ad-audio-probe-sidecar\"," + + "\"packageId\":\"" + PACKAGE_ID + "\"," + + "\"revision\":" + artifactRevision + "," + + "\"createdAtEpochMs\":" + (NOW - 60_000L) + "," + + "\"expiresAtEpochMs\":" + (NOW + 86_400_000L) + "," + + "\"sourcePackageId\":\"" + SOURCE_PACKAGE_ID + "\"," + + "\"sourceRevision\":42," + + "\"sourcePayloadSha256\":\"" + hex(digest("signed-v2-payload")) + "\"," + + "\"algorithm\":\"spectral-sequence-v1\"," + + "\"converterVersion\":\"" + converterVersion + "\"," + + "\"rulesSha256\":\"" + hex(digest(rules)) + "\"," + + "\"rulesBase64Url\":\"" + + Base64.getUrlEncoder().withoutPadding().encodeToString(rules) + "\"," + + "\"signature\":{\"keyId\":\"" + KEY_ID + + "\",\"algorithm\":\"" + SIGNATURE_ALGORITHM + + "\",\"value\":\"" + signature + "\"}}"; + } + + private static byte[] digest(String value) throws Exception { + return digest(value.getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] digest(byte[] value) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(value); + } + + private static String hex(byte[] value) { + StringBuilder result = new StringBuilder(value.length * 2); + for (byte item : value) result.append(String.format("%02x", item & 0xff)); + return result.toString(); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarStoreTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarStoreTest.java new file mode 100644 index 0000000000..17eeba5d36 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarStoreTest.java @@ -0,0 +1,100 @@ +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 static org.junit.Assert.fail; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class SignedProbeRuleSidecarStoreTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void installAndLoadUseIndependentCurrentSlot() throws Exception { + Path directory = temporaryFolder.newFolder("sidecar").toPath(); + SignedProbeRuleSidecarStore store = new SignedProbeRuleSidecarStore( + directory, SignedProbeRuleSidecarFixtures.verifier()); + + SignedProbeRuleSidecarStore.InstallResult installed = store.install( + SignedProbeRuleSidecarFixtures.signedEnvelope(1L, "probe-1")); + SignedProbeRuleSidecarStore.LoadResult loaded = store.load(); + + assertTrue(installed.changed()); + assertEquals(1L, installed.verifiedPackage().revision()); + assertTrue(loaded.hasPackage()); + assertFalse(loaded.recoveredPrevious()); + assertEquals(1L, loaded.verifiedPackage().revision()); + assertEquals(1L, loaded.highestAcceptedRevision()); + } + + @Test + public void corruptCurrentRecoversPreviousWithoutLoweringHighWater() throws Exception { + Path directory = temporaryFolder.newFolder("recovery").toPath(); + SignedProbeRuleSidecarStore store = new SignedProbeRuleSidecarStore( + directory, SignedProbeRuleSidecarFixtures.verifier()); + store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(1L, "probe-1")); + store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(2L, "probe-2")); + + Files.write(directory.resolve("current.json"), + "corrupt".getBytes(StandardCharsets.UTF_8)); + + SignedProbeRuleSidecarStore.LoadResult loaded = store.load(); + + assertTrue(loaded.hasPackage()); + assertTrue(loaded.recoveredPrevious()); + assertEquals(1L, loaded.verifiedPackage().revision()); + assertEquals(2L, loaded.highestAcceptedRevision()); + assertTrue(loaded.warnings().contains("CACHE_RECOVERED")); + } + + @Test + public void rollbackAndSameRevisionConflictAreRejected() throws Exception { + Path directory = temporaryFolder.newFolder("rollback").toPath(); + SignedProbeRuleSidecarStore store = new SignedProbeRuleSidecarStore( + directory, SignedProbeRuleSidecarFixtures.verifier()); + store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(2L, "probe-2")); + + assertCode(SignedRulePackageException.Code.REVISION_ROLLBACK, + () -> store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(1L, "probe-1"))); + assertCode(SignedRulePackageException.Code.REVISION_CONFLICT, + () -> store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(2L, "probe-3"))); + } + + @Test + public void clearDownloadedLeavesNoUsableSidecar() throws Exception { + Path directory = temporaryFolder.newFolder("clear").toPath(); + SignedProbeRuleSidecarStore store = new SignedProbeRuleSidecarStore( + directory, SignedProbeRuleSidecarFixtures.verifier()); + store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(1L, "probe-1")); + + store.clearDownloaded(); + + assertFalse(store.load().hasPackage()); + } + + private static void assertCode(SignedRulePackageException.Code expected, + ThrowingRunnable action) { + try { + action.run(); + fail("expected " + expected); + } catch (SignedRulePackageException e) { + assertEquals(expected, e.code()); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarVerifierTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarVerifierTest.java new file mode 100644 index 0000000000..30001c6074 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarVerifierTest.java @@ -0,0 +1,258 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Optional; + +public class SignedProbeRuleSidecarVerifierTest { + + private static final long MINUTE_MS = 60_000L; + private static final long DAY_MS = 24 * 60 * MINUTE_MS; + private static final long NOW = 1_786_900_000_000L; + private static final long CREATED_AT = NOW - MINUTE_MS; + private static final long EXPIRES_AT = NOW + DAY_MS; + private static final String PACKAGE_ID = "official.ad-audio.probe-v1"; + private static final String SOURCE_PACKAGE_ID = "official.ad-audio"; + private static final long ARTIFACT_REVISION = 5L; + private static final long SOURCE_REVISION = 42L; + private static final String KEY_ID = "release-1"; + private static final String SIGNATURE_ALGORITHM = "ED25519"; + private static final String CONVERTER_VERSION = "probe-oracle-1"; + private static final String ZERO_SIGNATURE = + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + @Test + public void activeKeyVerifiesIndependentSidecarArtifact() throws Exception { + byte[] envelope = signedEnvelope(); + + VerifiedProbeRuleSidecarPackage verified = verifier( + TrustedRuleKeyRegistry.Status.ACTIVE).verify(envelope, VerificationMode.INSTALL); + + assertEquals(PACKAGE_ID, verified.packageId()); + assertEquals(ARTIFACT_REVISION, verified.revision()); + assertEquals(CREATED_AT, verified.createdAtEpochMs()); + assertEquals(EXPIRES_AT, verified.expiresAtEpochMs()); + assertEquals(KEY_ID, verified.keyId()); + assertEquals(SIGNATURE_ALGORITHM, verified.signatureAlgorithm()); + assertEquals(SOURCE_PACKAGE_ID, verified.sidecar().sourcePackageId()); + assertEquals(SOURCE_REVISION, verified.sidecar().sourceRevision()); + assertEquals(CONVERTER_VERSION, verified.sidecar().converterVersion()); + assertArrayEquals(rules(), verified.sidecar().canonicalRules()); + verified.sidecar().requireBoundTo(SOURCE_PACKAGE_ID, SOURCE_REVISION, sourceDigest()); + assertEquals(64, verified.artifactSha256().length()); + } + + @Test + public void everySecurityFieldIsCoveredBySignatureOrDigest() throws Exception { + String valid = text(signedEnvelope()); + String sourceDigest = hex(sourceDigest()); + String rulesDigest = hex(digest(rules())); + + assertCode(SignedRulePackageException.Code.PACKAGE_ID_MISMATCH, + () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE).verify( + bytes(valid.replace("\"packageId\":\"" + PACKAGE_ID + "\"", + "\"packageId\":\"other.probe-v1\"")), VerificationMode.INSTALL)); + assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, + () -> verifyTampered(valid.replace("\"revision\":5", "\"revision\":6"))); + assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, + () -> verifyTampered(valid.replace("\"sourcePackageId\":\"" + SOURCE_PACKAGE_ID + "\"", + "\"sourcePackageId\":\"other.ad-audio\""))); + assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, + () -> verifyTampered(valid.replace("\"sourceRevision\":42", + "\"sourceRevision\":43"))); + assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, + () -> verifyTampered(valid.replace(sourceDigest, flipHex(sourceDigest)))); + assertCode(SignedRulePackageException.Code.PAYLOAD_INVALID, + () -> verifyTampered(valid.replace(ProbeRuleSidecar.ALGORITHM_ID, + "spectral-sequence-v2"))); + assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, + () -> verifyTampered(valid.replace(CONVERTER_VERSION, "probe-oracle-2"))); + assertCode(SignedRulePackageException.Code.PAYLOAD_DIGEST_MISMATCH, + () -> verifyTampered(valid.replace(rulesDigest, flipHex(rulesDigest)))); + + byte[] changedRules = "{\"format\":\"changed\"}".getBytes(StandardCharsets.UTF_8); + assertCode(SignedRulePackageException.Code.PAYLOAD_DIGEST_MISMATCH, + () -> verifyTampered(valid.replace(base64(rules()), base64(changedRules)))); + assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, + () -> verifyTampered(tamperSignature(valid))); + } + + @Test + public void malformedJsonAndEncodingAreRejectedBeforeVerification() throws Exception { + String valid = text(signedEnvelope()); + + assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, + () -> SignedProbeRuleSidecarCodec.parse(bytes(valid.replace( + "\"artifactType\"", "\"unknown\":0,\"artifactType\"")))); + assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, + () -> SignedProbeRuleSidecarCodec.parse(bytes(valid.replace( + "\"revision\":5", "\"revision\":5,\"revision\":6")))); + assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, + () -> SignedProbeRuleSidecarCodec.parse(bytes(valid + "{}"))); + assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, + () -> SignedProbeRuleSidecarCodec.parse(bytes(valid.replace( + "\"revision\":5", "\"revision\":5.0")))); + assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, + () -> SignedProbeRuleSidecarCodec.parse(bytes(valid.replace( + base64(rules()), "not+base64")))); + assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, + () -> SignedProbeRuleSidecarCodec.parse(new byte[]{(byte) 0xC3, 0x28})); + } + + @Test + public void nonCanonicalSignatureBase64UrlIsRejected() throws Exception { + String valid = text(signedEnvelope()); + String signature = signatureValue(valid); + String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + int canonicalIndex = alphabet.indexOf(signature.charAt(signature.length() - 1)); + String nonCanonical = signature.substring(0, signature.length() - 1) + + alphabet.charAt((canonicalIndex & 0x30) + 1); + assertArrayEquals(Base64.getUrlDecoder().decode(signature), + Base64.getUrlDecoder().decode(nonCanonical)); + + assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, + () -> SignedProbeRuleSidecarCodec.parse(bytes(valid.replace( + "\"value\":\"" + signature + "\"", + "\"value\":\"" + nonCanonical + "\"")))); + } + + @Test + public void retiredKeyIsCacheOnlyAndRevokedKeyIsAlwaysRejected() throws Exception { + byte[] envelope = signedEnvelope(); + + assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, + () -> verifier(TrustedRuleKeyRegistry.Status.RETIRED) + .verify(envelope, VerificationMode.INSTALL)); + assertEquals(ARTIFACT_REVISION, + verifier(TrustedRuleKeyRegistry.Status.RETIRED) + .verify(envelope, VerificationMode.CACHE).revision()); + assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, + () -> verifier(TrustedRuleKeyRegistry.Status.REVOKED) + .verify(envelope, VerificationMode.CACHE)); + } + + @Test + public void artifactArraysAreDefensivelyCopied() throws Exception { + VerifiedProbeRuleSidecarPackage verified = verifier( + TrustedRuleKeyRegistry.Status.ACTIVE).verify( + signedEnvelope(), VerificationMode.INSTALL); + + byte[] first = verified.artifactDigest(); + first[0] ^= 1; + + assertArrayEquals(digest(verified.signingInput()), verified.artifactDigest()); + } + + private static byte[] signedEnvelope() throws Exception { + String unsigned = envelope(ZERO_SIGNATURE); + byte[] input = SignedProbeRuleSidecarCodec.parse(bytes(unsigned)).signingInput(); + return bytes(envelope(base64(SignedRulePackageTestKeys.signer().sign(input)))); + } + + private static String envelope(String signature) throws Exception { + return "{" + + "\"artifactSchemaVersion\":1," + + "\"artifactType\":\"ad-audio-probe-sidecar\"," + + "\"packageId\":\"" + PACKAGE_ID + "\"," + + "\"revision\":" + ARTIFACT_REVISION + "," + + "\"createdAtEpochMs\":" + CREATED_AT + "," + + "\"expiresAtEpochMs\":" + EXPIRES_AT + "," + + "\"sourcePackageId\":\"" + SOURCE_PACKAGE_ID + "\"," + + "\"sourceRevision\":" + SOURCE_REVISION + "," + + "\"sourcePayloadSha256\":\"" + hex(sourceDigest()) + "\"," + + "\"algorithm\":\"" + ProbeRuleSidecar.ALGORITHM_ID + "\"," + + "\"converterVersion\":\"" + CONVERTER_VERSION + "\"," + + "\"rulesSha256\":\"" + hex(digest(rules())) + "\"," + + "\"rulesBase64Url\":\"" + base64(rules()) + "\"," + + "\"signature\":{" + + "\"keyId\":\"" + KEY_ID + "\"," + + "\"algorithm\":\"" + SIGNATURE_ALGORITHM + "\"," + + "\"value\":\"" + signature + "\"}}"; + } + + private static SignedProbeRuleSidecarVerifier verifier(TrustedRuleKeyRegistry.Status status) { + TrustedRuleKeyRegistry keys = keyId -> KEY_ID.equals(keyId) + ? Optional.of(new TrustedRuleKeyRegistry.Entry(KEY_ID, SIGNATURE_ALGORITHM, + NOW - DAY_MS, NOW + DAY_MS, status, SignedRulePackageTestKeys.verifier())) + : Optional.empty(); + return new SignedProbeRuleSidecarVerifier(PACKAGE_ID, keys, () -> NOW); + } + + private static VerifiedProbeRuleSidecarPackage verifyTampered(String value) throws Exception { + return verifier(TrustedRuleKeyRegistry.Status.ACTIVE) + .verify(bytes(value), VerificationMode.INSTALL); + } + + private static void assertCode(SignedRulePackageException.Code expected, + ThrowingRunnable action) { + try { + action.run(); + fail("expected " + expected); + } catch (SignedRulePackageException e) { + assertEquals(expected, e.code()); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + private static String tamperSignature(String envelope) { + int index = envelope.lastIndexOf("\"value\":\"") + "\"value\":\"".length(); + char current = envelope.charAt(index); + char replacement = current == 'A' ? 'B' : 'A'; + return envelope.substring(0, index) + replacement + envelope.substring(index + 1); + } + + private static String signatureValue(String envelope) { + int start = envelope.lastIndexOf("\"value\":\"") + "\"value\":\"".length(); + return envelope.substring(start, envelope.indexOf('"', start)); + } + + private static byte[] rules() { + return ("{\"format\":\"ad-audio-probe-rules\",\"schemaVersion\":1," + + "\"revision\":42,\"algorithm\":\"spectral-sequence-v1\"," + + "\"rules\":[]}").getBytes(StandardCharsets.UTF_8); + } + + private static byte[] sourceDigest() throws Exception { + return digest("signed-v2-payload".getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] digest(byte[] value) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(value); + } + + private static String base64(byte[] value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value); + } + + private static String hex(byte[] value) { + StringBuilder result = new StringBuilder(value.length * 2); + for (byte item : value) result.append(String.format("%02x", item & 0xff)); + return result.toString(); + } + + private static String flipHex(String value) { + return (value.charAt(0) == '0' ? "1" : "0") + value.substring(1); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static String text(byte[] value) { + return new String(value, StandardCharsets.UTF_8); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageCodecTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageCodecTest.java new file mode 100644 index 0000000000..2e29469f2e --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageCodecTest.java @@ -0,0 +1,215 @@ +package com.fongmi.android.tv.ad.audio; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class SignedRulePackageCodecTest { + + private static final String PAYLOAD_DIGEST = + "94db293f86613c918ea2135ff6cfde34fb6219dafd721ac194d7f35c1074a3d5"; + private static final String SIGNATURE = + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + private static final String SIGNING_INPUT_HEX = + "7765626874762e61642d617564696f2e7061636b6167652f763100" + + "00116f6666696369616c2e61642d617564696f" + + "000000000000002a" + + "000001a00b4d2400" + + "000001a0a5cbec00" + + "000972656c656173652d31" + + "000745443235353139" + + PAYLOAD_DIGEST; + + @Test + public void validEnvelopeParsesAndProducesExactSigningInput() throws Exception { + SignedRulePackageCodec.Parsed parsed = SignedRulePackageCodec.parse(bytes(validEnvelope())); + + assertEquals(1, parsed.packageSchemaVersion()); + assertEquals("official.ad-audio", parsed.packageId()); + assertEquals(42L, parsed.revision()); + assertEquals(1_786_896_000_000L, parsed.createdAtEpochMs()); + assertEquals(1_789_488_000_000L, parsed.expiresAtEpochMs()); + assertEquals("release-1", parsed.keyId()); + assertEquals("ED25519", parsed.algorithm()); + assertEquals(canonicalPayload(), parsed.canonicalPayload()); + assertArrayEquals(hex(PAYLOAD_DIGEST), parsed.payloadDigest()); + assertArrayEquals(hex(SIGNING_INPUT_HEX), parsed.signingInput()); + assertEquals(64, parsed.signature().length); + assertEquals(0, parsed.ruleSet().rules().size()); + + parsed.payloadDigest()[0] ^= 1; + parsed.signature()[0] ^= 1; + parsed.signingInput()[0] ^= 1; + assertArrayEquals(hex(PAYLOAD_DIGEST), parsed.payloadDigest()); + assertEquals(0, parsed.signature()[0]); + assertArrayEquals(hex(SIGNING_INPUT_HEX), parsed.signingInput()); + } + + @Test + public void unknownFieldsAreRejectedAtEveryDefinedObject() { + assertMalformed(validEnvelope().replace("\"packageId\"", "\"unknown\":0,\"packageId\"")); + assertMalformed(validEnvelope().replace("\"keyId\"", "\"unknown\":0,\"keyId\"")); + assertCode(SignedRulePackageException.Code.PAYLOAD_INVALID, + () -> SignedRulePackageCodec.parse(bytes(validEnvelope() + .replace("\"rules\":[]", "\"unknown\":0,\"rules\":[]")))); + } + + @Test + public void duplicateFieldsAreRejectedAtEveryNestingLevel() { + assertMalformed(validEnvelope().replace("\"revision\":42,", + "\"revision\":42,\"revision\":43,")); + assertMalformed(validEnvelope().replace("\"keyId\":\"release-1\",", + "\"keyId\":\"release-1\",\"keyId\":\"release-2\",")); + assertMalformed(validEnvelope().replace("\"schemaVersion\":2", + "\"schemaVersion\":2,\"schemaVersion\":2")); + assertMalformed(validEnvelope().replace("\"id\":\"spectral-sequence-v2\"}", + "\"id\":\"spectral-sequence-v2\",\"id\":\"spectral-sequence-v2\"}")); + assertMalformed(validEnvelope().replace("\"rules\":[]", "\"rules\":[{" + + "\"id\":\"ad-1\",\"id\":\"ad-2\",\"durationMs\":15000," + + "\"anchorOffsetMs\":0,\"anchorDurationMs\":3000," + + "\"fingerprint\":[\"00000000\",\"00000001\",\"00000002\",\"00000003\"]}]")); + } + + @Test + public void trailingJsonIsRejected() { + assertMalformed(validEnvelope() + "{}"); + } + + @Test + public void decimalRevisionIsRejected() { + assertMalformed(validEnvelope().replace("\"revision\":42", "\"revision\":42.0")); + } + + @Test + public void illegalPackageAndKeyIdsAreRejected() { + assertMalformed(validEnvelope().replace("official.ad-audio", "official/ad-audio")); + assertMalformed(validEnvelope().replace("release-1", "release key")); + } + + @Test + public void uppercaseDigestIsRejected() { + assertMalformed(validEnvelope().replace(PAYLOAD_DIGEST, + PAYLOAD_DIGEST.toUpperCase(Locale.ROOT))); + } + + @Test + public void paddedBase64UrlIsRejected() { + assertMalformed(validEnvelope().replace(SIGNATURE + "\"", SIGNATURE + "=\"")); + } + + @Test + public void wrongEd25519SignatureLengthIsRejected() { + assertMalformed(validEnvelope().replace(SIGNATURE, "AA")); + } + + @Test + public void deeplyNestedJsonIsRejectedBeforeTreeConstruction() { + StringBuilder nested = new StringBuilder("\"junk\":"); + for (int i = 0; i < 80; i++) nested.append('['); + nested.append('0'); + for (int i = 0; i < 80; i++) nested.append(']'); + nested.append(','); + assertMalformed(validEnvelope().replaceFirst("\\{", "{" + nested)); + } + + @Test + public void tokenBudgetIsEnforcedBeforeTreeConstruction() { + StringBuilder values = new StringBuilder("\"junk\":["); + for (int i = 0; i <= SignedRulePackageCodec.MAX_JSON_TOKENS; i++) { + if (i > 0) values.append(','); + values.append('0'); + } + values.append("],"); + assertMalformed(validEnvelope().replaceFirst("\\{", "{" + values)); + } + + @Test + public void objectMemberBudgetIsEnforcedBeforeTreeConstruction() { + StringBuilder members = new StringBuilder("\"junk\":{"); + for (int i = 0; i < 65; i++) { + if (i > 0) members.append(','); + members.append('\"').append('f').append(i).append("\":0"); + } + members.append("},"); + assertMalformed(validEnvelope().replaceFirst("\\{", "{" + members)); + } + + @Test + public void invalidUtf8IsRejected() { + assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, + () -> SignedRulePackageCodec.parse(new byte[]{'{', (byte) 0xc3, '}'})); + } + + @Test + public void rawPackageSizeIsRejectedBeforeDecoding() { + assertCode(SignedRulePackageException.Code.PACKAGE_TOO_LARGE, + () -> SignedRulePackageCodec.parse( + new byte[SignedRulePackageCodec.MAX_PACKAGE_BYTES + 1])); + } + + private static void assertMalformed(String json) { + assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, + () -> SignedRulePackageCodec.parse(bytes(json))); + } + + private static void assertCode(SignedRulePackageException.Code expected, ThrowingRunnable action) { + try { + action.run(); + fail("Expected " + expected); + } catch (SignedRulePackageException e) { + assertEquals(expected, e.code()); + assertEquals(expected.name(), e.getMessage()); + } catch (Exception e) { + throw new AssertionError("Unexpected exception", e); + } + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static byte[] hex(String value) { + byte[] result = new byte[value.length() / 2]; + for (int i = 0; i < result.length; i++) { + result[i] = (byte) Integer.parseInt(value.substring(i * 2, i * 2 + 2), 16); + } + return result; + } + + private static String canonicalPayload() { + return "{\"schemaVersion\":2,\"algorithm\":{" + + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," + + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16},\"rules\":[]}"; + } + + private static String nonCanonicalPayload() { + return "{\"rules\":[],\"algorithm\":{" + + "\"bandCount\":16,\"hopMs\":256,\"windowMs\":512," + + "\"sampleRate\":16000,\"id\":\"spectral-sequence-v2\"}," + + "\"schemaVersion\":2}"; + } + + private static String validEnvelope() { + return "{" + + "\"packageSchemaVersion\":1," + + "\"packageId\":\"official.ad-audio\"," + + "\"revision\":42," + + "\"createdAtEpochMs\":1786896000000," + + "\"expiresAtEpochMs\":1789488000000," + + "\"payloadSha256\":\"" + PAYLOAD_DIGEST + "\"," + + "\"payload\":" + nonCanonicalPayload() + ',' + + "\"signature\":{" + + "\"keyId\":\"release-1\"," + + "\"algorithm\":\"ED25519\"," + + "\"value\":\"" + SIGNATURE + "\"}}"; + } + + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageSourceTest.java new file mode 100644 index 0000000000..31b230b12f --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageSourceTest.java @@ -0,0 +1,154 @@ +package com.fongmi.android.tv.ad.audio; + +import org.junit.Test; + +import java.util.List; + +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 java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +public class SignedRulePackageSourceTest { + + private static final String PACKAGE_ID = "official.audio.ads"; + private static final String DIGEST = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + @Test + public void verifiedPackageMapsToStableSignedSnapshotIdentity() { + VerifiedRulePackage verified = verifiedPackage(42); + SignedRulePackageSource source = new SignedRulePackageSource( + PACKAGE_ID, + () -> SignedRulePackageStore.LoadResult.success( + verified, false, 42, List.of())); + + AdAudioRuleSnapshot snapshot = source.load(); + + assertEquals("signed:" + PACKAGE_ID, snapshot.sourceId()); + assertEquals("42:0123456789abcdef", snapshot.version()); + assertEquals(verified.ruleSet(), snapshot.ruleSet()); + assertTrue(snapshot.warnings().isEmpty()); + assertFalse(snapshot.hasError()); + assertFalse(snapshot.probeAvailable()); + } + + @Test + public void matchingSidecarIsAttachedWithoutChangingMainSnapshotIdentity() { + VerifiedRulePackage main = verifiedPackage(42); + VerifiedProbeRuleSidecarPackage sidecar = verifiedSidecar( + PACKAGE_ID, 42, main.payloadDigest()); + SignedRulePackageSource source = new SignedRulePackageSource( + PACKAGE_ID, + () -> SignedRulePackageStore.LoadResult.success(main, false, 42, List.of()), + () -> SignedProbeRuleSidecarStore.LoadResult.success(sidecar, false, 1, List.of())); + + AdAudioRuleSnapshot snapshot = source.load(); + + assertEquals("42:0123456789abcdef", snapshot.version()); + assertTrue(snapshot.probeAvailable()); + assertEquals(sidecar.sidecar(), snapshot.probeSidecar()); + assertTrue(snapshot.warnings().isEmpty()); + } + + @Test + public void mismatchingSidecarIsIgnoredAndMainPackageStillLoads() { + VerifiedRulePackage main = verifiedPackage(42); + VerifiedProbeRuleSidecarPackage sidecar = verifiedSidecar( + PACKAGE_ID, 41, main.payloadDigest()); + SignedRulePackageSource source = new SignedRulePackageSource( + PACKAGE_ID, + () -> SignedRulePackageStore.LoadResult.success(main, false, 42, List.of()), + () -> SignedProbeRuleSidecarStore.LoadResult.success(sidecar, false, 1, List.of())); + + AdAudioRuleSnapshot snapshot = source.load(); + + assertFalse(snapshot.probeAvailable()); + assertNull(snapshot.probeSidecar()); + assertEquals(List.of("PROBE_SIDECAR_MISMATCH"), snapshot.warnings()); + assertFalse(snapshot.hasError()); + } + + @Test + public void unavailableSidecarAddsWarningWithoutFailingMainPackage() { + VerifiedRulePackage main = verifiedPackage(42); + SignedRulePackageSource source = new SignedRulePackageSource( + PACKAGE_ID, + () -> SignedRulePackageStore.LoadResult.success(main, false, 42, List.of()), + () -> SignedProbeRuleSidecarStore.LoadResult.failure( + 0, SignedRulePackageException.Code.NO_VALID_SIGNED_PACKAGE)); + + AdAudioRuleSnapshot snapshot = source.load(); + + assertFalse(snapshot.probeAvailable()); + assertEquals(List.of("PROBE_SIDECAR_UNAVAILABLE"), snapshot.warnings()); + assertFalse(snapshot.hasError()); + } + + @Test + public void recoveredPreviousAddsCacheRecoveredWarningExactlyOnce() { + SignedRulePackageSource source = new SignedRulePackageSource( + PACKAGE_ID, + () -> SignedRulePackageStore.LoadResult.success( + verifiedPackage(7), true, 9, List.of("CACHE_RECOVERED"))); + + AdAudioRuleSnapshot snapshot = source.load(); + + assertEquals(List.of("CACHE_RECOVERED"), snapshot.warnings()); + } + + @Test + public void unavailableCacheMapsItsSpecificErrorToAnEmptySnapshot() { + SignedRulePackageSource source = new SignedRulePackageSource( + PACKAGE_ID, + () -> SignedRulePackageStore.LoadResult.failure( + 8, SignedRulePackageException.Code.CACHE_IO_FAILED)); + + AdAudioRuleSnapshot snapshot = source.load(); + + assertEquals("signed:" + PACKAGE_ID, snapshot.sourceId()); + assertEquals("", snapshot.version()); + assertFalse(snapshot.hasRules()); + assertEquals("CACHE_IO_FAILED", snapshot.lastError()); + } + + private static VerifiedRulePackage verifiedPackage(long revision) { + return new VerifiedRulePackage( + PACKAGE_ID, + revision, + 1_000L, + 2_000L, + DIGEST, + new byte[32], + "{}", + AudioFingerprintRuleSet.empty(), + "test-key", + "ED25519"); + } + + private static VerifiedProbeRuleSidecarPackage verifiedSidecar( + String sourcePackageId, long sourceRevision, byte[] sourceDigest) { + byte[] rules = "{\"format\":\"ad-audio-probe-rules\"}" + .getBytes(StandardCharsets.UTF_8); + byte[] sidecarDigest = sha256(rules); + ProbeRuleSidecar sidecar = ProbeRuleSidecar.verified( + sourcePackageId, sourceRevision, sourceDigest, + ProbeRuleSidecar.ALGORITHM_ID, "converter-1", rules, sidecarDigest); + byte[] artifactDigest = new byte[32]; + return new VerifiedProbeRuleSidecarPackage( + "probe.package", 1L, 1_000L, 2_000L, sidecar, + "0000000000000000000000000000000000000000000000000000000000000000", + artifactDigest, "test-key", "ED25519", new byte[] {1, 2, 3}); + } + + private static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (Exception e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageStoreTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageStoreTest.java new file mode 100644 index 0000000000..8f07229fd3 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageStoreTest.java @@ -0,0 +1,442 @@ +package com.fongmi.android.tv.ad.audio; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class SignedRulePackageStoreTest { + + private static final long MINUTE_MS = 60_000L; + private static final long DAY_MS = 24 * 60 * MINUTE_MS; + private static final long NOW = 1_786_900_000_000L; + private static final long CREATED_AT = NOW - MINUTE_MS; + private static final long EXPIRES_AT = NOW + 30 * DAY_MS; + private static final String PACKAGE_ID = "official.ad-audio"; + private static final String KEY_ID = "release-1"; + private static final String ALGORITHM = "ED25519"; + private static final String EMPTY_PAYLOAD = + "{\"schemaVersion\":2,\"algorithm\":{" + + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," + + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16},\"rules\":[]}"; + private static final String ONE_RULE_PAYLOAD = + "{\"schemaVersion\":2,\"algorithm\":{" + + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," + + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16}," + + "\"rules\":[{\"id\":\"ad-1\",\"durationMs\":15000," + + "\"anchorOffsetMs\":0,\"anchorDurationMs\":3000," + + "\"fingerprint\":[\"00000000\",\"00000001\"," + + "\"00000002\",\"00000003\"]}]}"; + + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void firstInstallPersistsCurrentAndState() throws Exception { + TestTrust trust = new TestTrust(); + Path directory = directory("first"); + SignedRulePackageStore store = store(directory, trust); + + SignedRulePackageStore.InstallResult installed = store.install(envelope(1)); + SignedRulePackageStore.LoadResult loaded = store.load(); + + assertTrue(installed.changed()); + assertEquals(1L, installed.verifiedPackage().revision()); + assertEquals(1L, installed.highestAcceptedRevision()); + assertTrue(Files.exists(directory.resolve("current.json"))); + assertTrue(Files.exists(directory.resolve("state.json"))); + assertFalse(Files.exists(directory.resolve("previous.json"))); + assertTrue(loaded.hasPackage()); + assertEquals(1L, loaded.verifiedPackage().revision()); + assertFalse(loaded.recoveredPrevious()); + assertEquals(1L, loaded.highestAcceptedRevision()); + } + + @Test + public void higherRevisionRotatesCurrentToPrevious() throws Exception { + TestTrust trust = new TestTrust(); + Path directory = directory("rotation"); + SignedRulePackageStore store = store(directory, trust); + store.install(envelope(1)); + + store.install(envelope(2)); + String previous = Files.readString(directory.resolve("previous.json")); + + assertEquals(2L, store.load().verifiedPackage().revision()); + assertTrue(previous.contains("\"revision\":1")); + } + + @Test + public void lowerRevisionIsRejectedWithoutChangingCurrent() throws Exception { + TestTrust trust = new TestTrust(); + SignedRulePackageStore store = store(directory("rollback"), trust); + store.install(envelope(2)); + + assertCode(SignedRulePackageException.Code.REVISION_ROLLBACK, + () -> store.install(envelope(1))); + assertEquals(2L, store.load().verifiedPackage().revision()); + } + + @Test + public void sameRevisionAndDigestIsIdempotentWithoutAnyWrite() throws Exception { + TestTrust trust = new TestTrust(); + Path directory = directory("idempotent"); + store(directory, trust).install(envelope(1)); + SignedRulePackageStore noWriteStore = new SignedRulePackageStore( + directory, trust.verifier(), stage -> { + throw new IOException("write should not occur"); + }); + + SignedRulePackageStore.InstallResult result = noWriteStore.install(envelope(1)); + + assertFalse(result.changed()); + assertEquals(1L, result.highestAcceptedRevision()); + } + + @Test + public void sameRevisionWithDifferentDigestIsRejected() throws Exception { + TestTrust trust = new TestTrust(); + SignedRulePackageStore store = store(directory("conflict"), trust); + store.install(envelope(1)); + + assertCode(SignedRulePackageException.Code.REVISION_CONFLICT, + () -> store.install(signedEnvelope(1, CREATED_AT, EXPIRES_AT, + ONE_RULE_PAYLOAD))); + assertEquals(0, store.load().verifiedPackage().ruleSet().rules().size()); + } + + @Test + public void sameRevisionCanRestoreCurrentAfterDownloadedFilesAreCleared() throws Exception { + TestTrust trust = new TestTrust(); + Path directory = directory("clear-restore"); + SignedRulePackageStore store = store(directory, trust); + store.install(envelope(1)); + + store.clearDownloaded(); + SignedRulePackageStore.LoadResult cleared = store.load(); + SignedRulePackageStore.InstallResult restored = store.install(envelope(1)); + + assertFalse(cleared.hasPackage()); + assertEquals(1L, cleared.highestAcceptedRevision()); + assertTrue(restored.changed()); + assertEquals(1L, store.load().verifiedPackage().revision()); + } + + @Test + public void corruptCurrentRecoversPreviousWithoutLoweringHighWater() throws Exception { + TestTrust trust = new TestTrust(); + Path directory = directory("recover-previous"); + SignedRulePackageStore store = store(directory, trust); + store.install(envelope(1)); + store.install(envelope(2)); + corrupt(directory.resolve("current.json")); + + SignedRulePackageStore.LoadResult recovered = store(directory, trust).load(); + + assertTrue(recovered.hasPackage()); + assertTrue(recovered.recoveredPrevious()); + assertTrue(recovered.warnings().contains("CACHE_RECOVERED")); + assertEquals(1L, recovered.verifiedPackage().revision()); + assertEquals(2L, recovered.highestAcceptedRevision()); + assertCode(SignedRulePackageException.Code.REVISION_ROLLBACK, + () -> store(directory, trust).install(envelope(1))); + } + + @Test + public void expiredPreviousIsNotRecovered() throws Exception { + TestTrust trust = new TestTrust(); + Path directory = directory("expired-previous"); + SignedRulePackageStore store = store(directory, trust); + store.install(signedEnvelope(1, CREATED_AT, NOW + 1_000, EMPTY_PAYLOAD)); + store.install(signedEnvelope(2, CREATED_AT, NOW + DAY_MS, EMPTY_PAYLOAD)); + corrupt(directory.resolve("current.json")); + trust.now.set(NOW + 2_000); + + SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); + + assertFalse(loaded.hasPackage()); + assertEquals(SignedRulePackageException.Code.NO_VALID_SIGNED_PACKAGE, + loaded.errorCode()); + assertEquals(2L, loaded.highestAcceptedRevision()); + } + + @Test + public void revokedPreviousIsNotRecovered() throws Exception { + TestTrust trust = new TestTrust(); + Path directory = directory("revoked-previous"); + SignedRulePackageStore store = store(directory, trust); + store.install(envelope(1)); + store.install(envelope(2)); + corrupt(directory.resolve("current.json")); + trust.status.set(TrustedRuleKeyRegistry.Status.REVOKED); + + SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); + + assertFalse(loaded.hasPackage()); + assertEquals(SignedRulePackageException.Code.NO_VALID_SIGNED_PACKAGE, + loaded.errorCode()); + assertEquals(2L, loaded.highestAcceptedRevision()); + } + + @Test + public void missingOrCorruptStateIsReconstructedFromCurrent() throws Exception { + TestTrust trust = new TestTrust(); + for (String mode : new String[]{"missing", "corrupt"}) { + Path directory = directory("state-" + mode); + SignedRulePackageStore store = store(directory, trust); + store.install(envelope(2)); + if ("missing".equals(mode)) Files.delete(directory.resolve("state.json")); + else corrupt(directory.resolve("state.json")); + + SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); + + assertTrue(loaded.hasPackage()); + assertEquals(2L, loaded.highestAcceptedRevision()); + assertTrue(Files.readString(directory.resolve("state.json")) + .contains("\"highestAcceptedRevision\":2")); + } + } + + @Test + public void syntacticallyValidConflictingStateIsRepairedFromSignedCurrent() throws Exception { + TestTrust trust = new TestTrust(); + Path directory = directory("state-conflicting-digest"); + SignedRulePackageStore store = store(directory, trust); + store.install(envelope(1)); + Path stateFile = directory.resolve("state.json"); + String validState = Files.readString(stateFile); + String conflictingState = validState.replaceFirst( + "\"payloadSha256\":\"[0-9a-f]{64}\"", + "\"payloadSha256\":\"" + "0".repeat(64) + "\""); + assertFalse(validState.equals(conflictingState)); + Files.writeString(stateFile, conflictingState); + + SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); + + assertTrue(loaded.hasPackage()); + assertEquals(validState, Files.readString(stateFile)); + assertFalse(store(directory, trust).install(envelope(1)).changed()); + } + + @Test + public void clearDownloadedPreservesReconstructedHighWater() throws Exception { + TestTrust trust = new TestTrust(); + Path directory = directory("preserve-high-water"); + SignedRulePackageStore store = store(directory, trust); + store.install(envelope(1)); + store.install(envelope(2)); + Files.delete(directory.resolve("state.json")); + store(directory, trust).load(); + + store(directory, trust).clearDownloaded(); + + assertCode(SignedRulePackageException.Code.REVISION_ROLLBACK, + () -> store(directory, trust).install(envelope(1))); + } + + @Test + public void corruptPreviousDoesNotDisplaceValidCurrent() throws Exception { + TestTrust trust = new TestTrust(); + Path directory = directory("corrupt-previous"); + SignedRulePackageStore store = store(directory, trust); + store.install(envelope(1)); + store.install(envelope(2)); + corrupt(directory.resolve("previous.json")); + + SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); + + assertTrue(loaded.hasPackage()); + assertEquals(2L, loaded.verifiedPackage().revision()); + assertFalse(loaded.recoveredPrevious()); + } + + @Test + public void everyAtomicReplacementFailureLeavesOnlyACompleteOldOrNewPackage() + throws Exception { + for (SignedRulePackageStore.WriteStage target : SignedRulePackageStore.WriteStage.values()) { + TestTrust trust = new TestTrust(); + Path directory = directory("fault-" + target.name()); + store(directory, trust).install(envelope(1)); + SignedRulePackageStore failing = new SignedRulePackageStore( + directory, trust.verifier(), stage -> { + if (stage == target) throw new IOException("injected"); + }); + + assertCode(SignedRulePackageException.Code.CACHE_IO_FAILED, + () -> failing.install(envelope(2))); + SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); + + assertTrue(loaded.hasPackage()); + long revision = loaded.verifiedPackage().revision(); + assertTrue(revision == 1 || revision == 2); + assertFalse(Files.exists(directory.resolve("current.tmp"))); + assertFalse(Files.exists(directory.resolve("previous.tmp"))); + assertFalse(Files.exists(directory.resolve("state.tmp"))); + } + } + + @Test + public void separateStoreInstancesSerializeOperationsForTheSameDirectory() + throws Exception { + TestTrust trust = new TestTrust(); + Path directory = directory("concurrent-instances"); + CountDownLatch firstBeforeCurrent = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch secondBeforeCurrent = new CountDownLatch(1); + CountDownLatch releaseSecond = new CountDownLatch(1); + AtomicReference firstFailure = new AtomicReference<>(); + AtomicReference secondFailure = new AtomicReference<>(); + SignedRulePackageStore first = new SignedRulePackageStore( + directory, trust.verifier(), stage -> { + if (stage == SignedRulePackageStore.WriteStage.BEFORE_CURRENT_REPLACE) { + firstBeforeCurrent.countDown(); + await(releaseFirst); + } + }); + SignedRulePackageStore second = new SignedRulePackageStore( + directory, trust.verifier(), stage -> { + if (stage == SignedRulePackageStore.WriteStage.BEFORE_CURRENT_REPLACE) { + secondBeforeCurrent.countDown(); + await(releaseSecond); + } + }); + Thread firstThread = installThread(first, 1, firstFailure); + Thread secondThread = installThread(second, 2, secondFailure); + + firstThread.start(); + assertTrue(firstBeforeCurrent.await(5, TimeUnit.SECONDS)); + secondThread.start(); + boolean secondEnteredEarly = secondBeforeCurrent.await(500, TimeUnit.MILLISECONDS); + if (secondEnteredEarly) { + releaseSecond.countDown(); + secondThread.join(5_000); + releaseFirst.countDown(); + } else { + releaseFirst.countDown(); + assertTrue(secondBeforeCurrent.await(5, TimeUnit.SECONDS)); + releaseSecond.countDown(); + } + firstThread.join(5_000); + secondThread.join(5_000); + + assertFalse(firstThread.isAlive()); + assertFalse(secondThread.isAlive()); + if (firstFailure.get() != null) throw new AssertionError(firstFailure.get()); + if (secondFailure.get() != null) throw new AssertionError(secondFailure.get()); + assertEquals(2L, store(directory, trust).load().verifiedPackage().revision()); + } + + private Path directory(String name) throws IOException { + return temporary.newFolder(name).toPath(); + } + + private static SignedRulePackageStore store(Path directory, TestTrust trust) { + return new SignedRulePackageStore(directory, trust.verifier()); + } + + private static byte[] envelope(long revision) throws Exception { + return signedEnvelope(revision, CREATED_AT, EXPIRES_AT, EMPTY_PAYLOAD); + } + + private static byte[] signedEnvelope(long revision, long createdAt, + long expiresAt, String payload) throws Exception { + AudioFingerprintRuleSet ruleSet = AudioFingerprintRuleCodec.fromJson(payload); + String canonicalPayload = AudioFingerprintRuleCodec.toJson(ruleSet); + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(canonicalPayload.getBytes(StandardCharsets.UTF_8)); + byte[] signature = SignedRulePackageTestKeys.signer().sign( + SignedRulePackageCodec.signingInput(PACKAGE_ID, revision, createdAt, + expiresAt, KEY_ID, ALGORITHM, digest)); + String json = "{" + + "\"packageSchemaVersion\":1," + + "\"packageId\":\"" + PACKAGE_ID + "\"," + + "\"revision\":" + revision + ',' + + "\"createdAtEpochMs\":" + createdAt + ',' + + "\"expiresAtEpochMs\":" + expiresAt + ',' + + "\"payloadSha256\":\"" + hex(digest) + "\"," + + "\"payload\":" + canonicalPayload + ',' + + "\"signature\":{" + + "\"keyId\":\"" + KEY_ID + "\"," + + "\"algorithm\":\"" + ALGORITHM + "\"," + + "\"value\":\"" + Base64.getUrlEncoder().withoutPadding() + .encodeToString(signature) + "\"}}"; + return json.getBytes(StandardCharsets.UTF_8); + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) result.append(String.format("%02x", value & 0xff)); + return result.toString(); + } + + private static void corrupt(Path file) throws IOException { + Files.writeString(file, "{"); + } + + private static Thread installThread(SignedRulePackageStore store, long revision, + AtomicReference failure) { + return new Thread(() -> { + try { + store.install(envelope(revision)); + } catch (Throwable e) { + failure.set(e); + } + }); + } + + private static void await(CountDownLatch latch) throws IOException { + try { + if (!latch.await(5, TimeUnit.SECONDS)) throw new IOException("latch timeout"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted", e); + } + } + + private static void assertCode(SignedRulePackageException.Code expected, ThrowingRunnable action) { + try { + action.run(); + fail("Expected " + expected); + } catch (SignedRulePackageException e) { + assertEquals(expected, e.code()); + assertEquals(expected.name(), e.getMessage()); + } catch (Exception e) { + throw new AssertionError("Unexpected exception", e); + } + } + + private static final class TestTrust { + final AtomicLong now = new AtomicLong(NOW); + final AtomicReference status = + new AtomicReference<>(TrustedRuleKeyRegistry.Status.ACTIVE); + + SignedRulePackageVerifier verifier() { + TrustedRuleKeyRegistry registry = keyId -> KEY_ID.equals(keyId) + ? Optional.of(new TrustedRuleKeyRegistry.Entry( + KEY_ID, ALGORITHM, 0, Long.MAX_VALUE, + status.get(), SignedRulePackageTestKeys.verifier())) + : Optional.empty(); + return new SignedRulePackageVerifier(PACKAGE_ID, registry, now::get); + } + } + + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageTestKeys.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageTestKeys.java new file mode 100644 index 0000000000..c1569e17f5 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageTestKeys.java @@ -0,0 +1,42 @@ +package com.fongmi.android.tv.ad.audio; + +import com.google.crypto.tink.Configuration; +import com.google.crypto.tink.KeysetHandle; +import com.google.crypto.tink.PublicKeySign; +import com.google.crypto.tink.PublicKeyVerify; +import com.google.crypto.tink.RegistryConfiguration; +import com.google.crypto.tink.signature.Ed25519PrivateKeyManager; +import com.google.crypto.tink.signature.SignatureConfig; + +import java.security.GeneralSecurityException; + +final class SignedRulePackageTestKeys { + + private static final PublicKeySign SIGNER; + private static final PublicKeyVerify VERIFIER; + + static { + try { + SignatureConfig.register(); + KeysetHandle privateHandle = KeysetHandle.generateNew( + Ed25519PrivateKeyManager.rawEd25519Template()); + Configuration configuration = RegistryConfiguration.get(); + SIGNER = privateHandle.getPrimitive(configuration, PublicKeySign.class); + VERIFIER = privateHandle.getPublicKeysetHandle() + .getPrimitive(configuration, PublicKeyVerify.class); + } catch (GeneralSecurityException e) { + throw new ExceptionInInitializerError(e); + } + } + + static PublicKeySign signer() { + return SIGNER; + } + + static PublicKeyVerify verifier() { + return VERIFIER; + } + + private SignedRulePackageTestKeys() { + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageVerifierTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageVerifierTest.java new file mode 100644 index 0000000000..9267028c24 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageVerifierTest.java @@ -0,0 +1,307 @@ +package com.fongmi.android.tv.ad.audio; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Optional; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class SignedRulePackageVerifierTest { + + private static final long MINUTE_MS = 60_000L; + private static final long DAY_MS = 24 * 60 * MINUTE_MS; + private static final long NOW = 1_786_900_000_000L; + private static final long CREATED_AT = NOW - MINUTE_MS; + private static final long EXPIRES_AT = NOW + DAY_MS; + private static final String PACKAGE_ID = "official.ad-audio"; + private static final String KEY_ID = "release-1"; + private static final String ALGORITHM = "ED25519"; + private static final String EMPTY_PAYLOAD = + "{\"schemaVersion\":2,\"algorithm\":{" + + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," + + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16},\"rules\":[]}"; + + @Test + public void activeKeyVerifiesInstallAndCache() throws Exception { + SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + NOW - DAY_MS, NOW + DAY_MS); + byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + + VerifiedRulePackage installed = verifier.verify(envelope, VerificationMode.INSTALL); + VerifiedRulePackage cached = verifier.verify(envelope, VerificationMode.CACHE); + + assertEquals(PACKAGE_ID, installed.packageId()); + assertEquals(42L, installed.revision()); + assertEquals(CREATED_AT, installed.createdAtEpochMs()); + assertEquals(EXPIRES_AT, installed.expiresAtEpochMs()); + assertEquals(KEY_ID, installed.keyId()); + assertEquals(ALGORITHM, installed.signatureAlgorithm()); + assertEquals(EMPTY_PAYLOAD, installed.canonicalPayload()); + assertEquals(0, installed.ruleSet().rules().size()); + assertArrayEquals(installed.payloadDigest(), cached.payloadDigest()); + } + + @Test + public void tamperingEverySignedFieldIsRejected() throws Exception { + SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + NOW - DAY_MS, NOW + DAY_MS); + String valid = text(signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD)); + String digest = digestHex(EMPTY_PAYLOAD); + + assertCode(SignedRulePackageException.Code.PACKAGE_ID_MISMATCH, + () -> verifier.verify(bytes(valid.replace(PACKAGE_ID, "other.ad-audio")), + VerificationMode.INSTALL)); + assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, + () -> verifier.verify(bytes(valid.replace("\"revision\":42", "\"revision\":43")), + VerificationMode.INSTALL)); + assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, + () -> verifier.verify(bytes(valid.replace(Long.toString(CREATED_AT), + Long.toString(CREATED_AT + 1))), VerificationMode.INSTALL)); + assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, + () -> verifier.verify(bytes(valid.replace(Long.toString(EXPIRES_AT), + Long.toString(EXPIRES_AT + 1))), VerificationMode.INSTALL)); + assertCode(SignedRulePackageException.Code.UNKNOWN_KEY, + () -> verifier.verify(bytes(valid.replace(KEY_ID, "release-2")), + VerificationMode.INSTALL)); + assertCode(SignedRulePackageException.Code.UNSUPPORTED_SIGNATURE_ALGORITHM, + () -> verifier.verify(bytes(valid.replace(ALGORITHM, "ED25518")), + VerificationMode.INSTALL)); + assertCode(SignedRulePackageException.Code.PAYLOAD_DIGEST_MISMATCH, + () -> verifier.verify(bytes(valid.replace("\"rules\":[]", "\"rules\":[{" + + "\"id\":\"ad-1\",\"durationMs\":15000," + + "\"anchorOffsetMs\":0,\"anchorDurationMs\":3000," + + "\"fingerprint\":[\"00000000\",\"00000001\"," + + "\"00000002\",\"00000003\"]}]")), + VerificationMode.INSTALL)); + assertCode(SignedRulePackageException.Code.PAYLOAD_DIGEST_MISMATCH, + () -> verifier.verify(bytes(valid.replace(digest, + (digest.charAt(0) == '0' ? "1" : "0") + digest.substring(1))), + VerificationMode.INSTALL)); + assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, + () -> verifier.verify(bytes(tamperSignature(valid)), VerificationMode.INSTALL)); + } + + @Test + public void correctlySignedUnknownKeyIsRejected() throws Exception { + byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, + "unknown-1", ALGORITHM, EMPTY_PAYLOAD); + + assertCode(SignedRulePackageException.Code.UNKNOWN_KEY, + () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + NOW - DAY_MS, NOW + DAY_MS).verify(envelope, VerificationMode.INSTALL)); + } + + @Test + public void correctlySignedUnsupportedAlgorithmIsRejected() throws Exception { + byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, + KEY_ID, "ECDSA", EMPTY_PAYLOAD); + + assertCode(SignedRulePackageException.Code.UNSUPPORTED_SIGNATURE_ALGORITHM, + () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + NOW - DAY_MS, NOW + DAY_MS).verify(envelope, VerificationMode.INSTALL)); + } + + @Test + public void retiredKeyIsCacheOnly() throws Exception { + SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.RETIRED, + NOW - DAY_MS, NOW + DAY_MS); + byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + + assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, + () -> verifier.verify(envelope, VerificationMode.INSTALL)); + assertEquals(42L, verifier.verify(envelope, VerificationMode.CACHE).revision()); + } + + @Test + public void revokedKeyIsAlwaysRejected() throws Exception { + SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.REVOKED, + NOW - DAY_MS, NOW + DAY_MS); + byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + + assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, + () -> verifier.verify(envelope, VerificationMode.INSTALL)); + assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, + () -> verifier.verify(envelope, VerificationMode.CACHE)); + } + + @Test + public void packageCreationMustFallWithinKeyValidityBounds() throws Exception { + byte[] before = signedEnvelope(PACKAGE_ID, 42, NOW - 2 * DAY_MS, EXPIRES_AT, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + byte[] after = signedEnvelope(PACKAGE_ID, 42, NOW, EXPIRES_AT, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + + assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, + () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + NOW - DAY_MS, NOW + DAY_MS).verify(before, VerificationMode.CACHE)); + assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, + () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + NOW - DAY_MS, NOW - 1).verify(after, VerificationMode.CACHE)); + } + + @Test + public void keyValidityBoundsAreInclusive() throws Exception { + byte[] atNotBefore = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + byte[] atNotAfter = signedEnvelope(PACKAGE_ID, 43, NOW, EXPIRES_AT, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + + assertEquals(42L, verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + CREATED_AT, NOW + DAY_MS).verify(atNotBefore, VerificationMode.CACHE).revision()); + assertEquals(43L, verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + CREATED_AT, NOW).verify(atNotAfter, VerificationMode.CACHE).revision()); + } + + @Test + public void expiredActiveKeyCannotInstallButCanValidateExistingCache() throws Exception { + SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + CREATED_AT - DAY_MS, NOW - 1); + byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + + assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, + () -> verifier.verify(envelope, VerificationMode.INSTALL)); + assertEquals(42L, verifier.verify(envelope, VerificationMode.CACHE).revision()); + } + + @Test + public void packageFutureSkewIsLimitedToFiveMinutes() throws Exception { + byte[] boundary = signedEnvelope(PACKAGE_ID, 42, + NOW + 5 * MINUTE_MS, NOW + DAY_MS, KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + byte[] tooFar = signedEnvelope(PACKAGE_ID, 43, + NOW + 5 * MINUTE_MS + 1, NOW + DAY_MS, KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + NOW - DAY_MS, NOW + 2 * DAY_MS); + + assertEquals(42L, verifier.verify(boundary, VerificationMode.INSTALL).revision()); + assertCode(SignedRulePackageException.Code.PACKAGE_NOT_YET_VALID, + () -> verifier.verify(tooFar, VerificationMode.INSTALL)); + } + + @Test + public void packageExpiryBoundaryIsExclusive() throws Exception { + byte[] expired = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, NOW, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + byte[] valid = signedEnvelope(PACKAGE_ID, 43, CREATED_AT, NOW + 1, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + NOW - DAY_MS, NOW + DAY_MS); + + assertCode(SignedRulePackageException.Code.PACKAGE_EXPIRED, + () -> verifier.verify(expired, VerificationMode.INSTALL)); + assertEquals(43L, verifier.verify(valid, VerificationMode.INSTALL).revision()); + } + + @Test + public void packageLifetimeCannotExceedNinetyDays() throws Exception { + long created = NOW - 1; + byte[] envelope = signedEnvelope(PACKAGE_ID, 42, created, + created + 90 * DAY_MS + 1, KEY_ID, ALGORITHM, EMPTY_PAYLOAD); + + assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, + () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + NOW - DAY_MS, NOW + 100 * DAY_MS) + .verify(envelope, VerificationMode.INSTALL)); + } + + @Test + public void verifiedDigestIsDefensivelyCopied() throws Exception { + VerifiedRulePackage verified = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, + NOW - DAY_MS, NOW + DAY_MS).verify( + signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, + KEY_ID, ALGORITHM, EMPTY_PAYLOAD), VerificationMode.INSTALL); + byte[] expected = verified.payloadDigest(); + + verified.payloadDigest()[0] ^= 1; + + assertArrayEquals(expected, verified.payloadDigest()); + } + + private static SignedRulePackageVerifier verifier(TrustedRuleKeyRegistry.Status status, + long notBefore, long notAfter) { + TrustedRuleKeyRegistry.Entry entry = new TrustedRuleKeyRegistry.Entry( + KEY_ID, ALGORITHM, notBefore, notAfter, status, + SignedRulePackageTestKeys.verifier()); + TrustedRuleKeyRegistry registry = keyId -> KEY_ID.equals(keyId) + ? Optional.of(entry) + : Optional.empty(); + return new SignedRulePackageVerifier(PACKAGE_ID, registry, () -> NOW); + } + + private static byte[] signedEnvelope(String packageId, long revision, + long createdAt, long expiresAt, + String keyId, String algorithm, + String payload) throws Exception { + AudioFingerprintRuleSet ruleSet = AudioFingerprintRuleCodec.fromJson(payload); + String canonicalPayload = AudioFingerprintRuleCodec.toJson(ruleSet); + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(canonicalPayload.getBytes(StandardCharsets.UTF_8)); + byte[] signature = SignedRulePackageTestKeys.signer().sign( + SignedRulePackageCodec.signingInput(packageId, revision, createdAt, + expiresAt, keyId, algorithm, digest)); + String json = "{" + + "\"packageSchemaVersion\":1," + + "\"packageId\":\"" + packageId + "\"," + + "\"revision\":" + revision + ',' + + "\"createdAtEpochMs\":" + createdAt + ',' + + "\"expiresAtEpochMs\":" + expiresAt + ',' + + "\"payloadSha256\":\"" + hex(digest) + "\"," + + "\"payload\":" + canonicalPayload + ',' + + "\"signature\":{" + + "\"keyId\":\"" + keyId + "\"," + + "\"algorithm\":\"" + algorithm + "\"," + + "\"value\":\"" + Base64.getUrlEncoder().withoutPadding() + .encodeToString(signature) + "\"}}"; + return bytes(json); + } + + private static String tamperSignature(String json) { + int start = json.indexOf("\"value\":\"") + "\"value\":\"".length(); + char replacement = json.charAt(start) == 'A' ? 'B' : 'A'; + return json.substring(0, start) + replacement + json.substring(start + 1); + } + + private static String digestHex(String payload) throws Exception { + return hex(MessageDigest.getInstance("SHA-256") + .digest(payload.getBytes(StandardCharsets.UTF_8))); + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) result.append(String.format("%02x", value & 0xff)); + return result.toString(); + } + + private static String text(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static void assertCode(SignedRulePackageException.Code expected, ThrowingRunnable action) { + try { + action.run(); + fail("Expected " + expected); + } catch (SignedRulePackageException e) { + assertEquals(expected, e.code()); + assertEquals(expected.name(), e.getMessage()); + } catch (Exception e) { + throw new AssertionError("Unexpected exception", e); + } + } + + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpectralFingerprintTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpectralFingerprintTest.java new file mode 100644 index 0000000000..7f9382c6be --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpectralFingerprintTest.java @@ -0,0 +1,86 @@ +package com.fongmi.android.tv.ad.audio; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class SpectralFingerprintTest { + + private static final AudioFingerprintConfig CONFIG = AudioFingerprintConfig.standard(); + + @Test + public void silenceProducesZeroHashesForEveryPhase() { + List variants = SpectralFingerprint.extractVariants( + new short[16_000 * 3], 16_000, 1, CONFIG); + + assertEquals(4, variants.size()); + for (int[] sequence : variants) { + for (int hash : sequence) assertEquals(0, hash); + } + } + + @Test + public void stereoDownmixAndResampleMatchMonoReference() { + short[] stereo48k = sine(48_000, 3, 500, 2); + short[] mono16k = sine(16_000, 3, 500, 1); + + assertSequencesEqual( + SpectralFingerprint.extractVariants(mono16k, 16_000, 1, CONFIG), + SpectralFingerprint.extractVariants(stereo48k, 48_000, 2, CONFIG)); + } + + @Test + public void sdkGoldenVectorHasStableHashes() { + int[] expected = hashes( + "32f0007c,35c100e0,3b8b01c0,d30a0380,2e0b0700," + + "0c650600,9c560c00,49db0c00,d9161800,70d41800"); + + assertArrayEquals(expected, + SpectralFingerprint.extract(chirp16k(), 16_000, 1, CONFIG)); + } + + @Test + public void tooShortAudioDoesNotCreateAUsableSequence() { + assertEquals(0, SpectralFingerprint.extractVariants( + new short[CONFIG.windowSamples() - 1], 16_000, 1, CONFIG).size()); + } + + private static void assertSequencesEqual(List expected, List actual) { + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) assertArrayEquals(expected.get(i), actual.get(i)); + } + + private static int[] hashes(String csv) { + return Arrays.stream(csv.split(",")) + .mapToInt(value -> (int) Long.parseLong(value, 16)) + .toArray(); + } + + private static short[] sine(int sampleRate, int seconds, double frequency, int channels) { + short[] output = new short[sampleRate * seconds * channels]; + for (int frame = 0; frame < sampleRate * seconds; frame++) { + short sample = (short) Math.round(16_000 * Math.sin(2 * Math.PI * frequency * frame / sampleRate)); + for (int channel = 0; channel < channels; channel++) output[frame * channels + channel] = sample; + } + return output; + } + + private static short[] chirp16k() { + int sampleRate = 16_000; + int sampleCount = sampleRate * 3; + double startFrequency = 300; + double endFrequency = 3_000; + double rate = (endFrequency - startFrequency) / 3; + short[] output = new short[sampleCount]; + for (int i = 0; i < output.length; i++) { + double time = i / (double) sampleRate; + double phase = 2 * Math.PI * (startFrequency * time + 0.5 * rate * time * time); + output[i] = (short) Math.round(16_000 * Math.sin(phase)); + } + return output; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdConfigTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdConfigTest.java new file mode 100644 index 0000000000..5a0bf9b5bd --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdConfigTest.java @@ -0,0 +1,89 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.*; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.Test; + +public class SpeechAdConfigTest { + + @Test + public void defaultsAreDisabledPromptAndFifteenSeconds() { + SpeechAdConfig config = SpeechAdConfig.defaults(); + assertFalse(config.enabled()); + assertEquals(AdSkipPolicyController.Mode.PROMPT, config.mode()); + assertEquals(15, config.skipSeconds()); + assertEquals(SpeechAdConfig.DEFAULT_KEYWORDS, String.join(",", config.keywords().values())); + assertFalse(config.keywords().isEmpty()); + } + + @Test + public void unsafeDurationIsClamped() { + assertEquals(1, SpeechAdConfig.create(true, "赌场", 0, "PROMPT").skipSeconds()); + assertEquals(120, SpeechAdConfig.create(true, "赌场", 999, "AUTO").skipSeconds()); + } + + @Test + public void unknownOrNullModeFallsBackToPrompt() { + assertEquals(AdSkipPolicyController.Mode.PROMPT, + SpeechAdConfig.create(true, "赌场", 15, "UNKNOWN").mode()); + assertEquals(AdSkipPolicyController.Mode.PROMPT, + SpeechAdConfig.create(true, "赌场", 15, null).mode()); + } + + @Test + public void keywordsAreNormalizedAndDeduplicated() { + SpeechAdConfig config = SpeechAdConfig.create(true, " 赌场,赌场,\uFF27\uFF21\uFF2D\uFF25 ", 15, "AUTO"); + assertEquals(java.util.List.of("赌场", "game"), config.keywords().values()); + assertEquals(AdSkipPolicyController.Mode.AUTO, config.mode()); + } + + @Test + public void snapshotKeywordsAreImmutable() { + SpeechAdConfig config = SpeechAdConfig.create(true, "赌场,棋牌", 15, "PROMPT"); + try { + config.keywords().values().add("下注"); + fail("keyword snapshot must be immutable"); + } catch (UnsupportedOperationException expected) { + // expected + } + } + + @Test + public void settingSnapshotHasSafeDefaultsWithoutAndroidContext() { + SpeechAdConfig config = SpeechAdSetting.snapshot(); + assertFalse(config.enabled()); + assertEquals(AdSkipPolicyController.Mode.PROMPT, config.mode()); + assertEquals(15, config.skipSeconds()); + } + + @Test(expected = NullPointerException.class) + public void settingModeRejectsNull() { + SpeechAdSetting.setMode(null); + } + + @Test + public void settingUsesExactlyFourStablePreferenceKeysAndNormalizesWrites() throws Exception { + String source = readSource("SpeechAdSetting.java"); + assertTrue(source.contains("speech_ad_enabled")); + assertTrue(source.contains("speech_ad_keywords")); + assertTrue(source.contains("speech_ad_skip_seconds")); + assertTrue(source.contains("speech_ad_skip_mode")); + assertTrue(source.contains("Prefers.getBoolean(KEY_ENABLED, false)")); + assertTrue(source.contains("Prefers.getString(KEY_KEYWORDS, SpeechAdConfig.DEFAULT_KEYWORDS)")); + assertTrue(source.contains("Prefers.getInt(KEY_SKIP_SECONDS, 15)")); + assertTrue(source.contains("Prefers.getString(KEY_SKIP_MODE, AdSkipPolicyController.Mode.PROMPT.name())")); + assertTrue(source.contains("SpeechAdKeywordSet.parse(value)")); + assertTrue(source.contains("Math.max(1, Math.min(120, value))")); + assertTrue(source.contains("Objects.requireNonNull(value, \"mode\")")); + } + + private static String readSource(String fileName) throws Exception { + Path moduleRoot = Files.exists(Path.of("src/main/java")) ? Path.of("src/main/java") : Path.of("app/src/main/java"); + Path path = moduleRoot.resolve(Path.of("com", "fongmi", "android", "tv", "ad", "audio", fileName)); + return Files.readString(path, StandardCharsets.UTF_8); + } +} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdKeywordSetTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdKeywordSetTest.java new file mode 100644 index 0000000000..8d9f6a9586 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdKeywordSetTest.java @@ -0,0 +1,87 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.util.List; + +public class SpeechAdKeywordSetTest { + + @Test + public void parseNormalizesMixedSeparatorsAndDeduplicates() { + SpeechAdKeywordSet set = SpeechAdKeywordSet.parse(" 澳门,赌场;澳门; 首充, DOWNLOAD\r\n提现 "); + + assertEquals(List.of("澳门", "赌场", "首充", "download", "提现"), set.values()); + } + + @Test + public void chineseKeywordsUseSubstringMatching() { + SpeechAdKeywordSet set = SpeechAdKeywordSet.parse("首充,提现"); + + assertEquals("首充", set.firstMatch("现在完成首充即可提现").orElseThrow()); + } + + @Test + public void asciiKeywordsRequireWordBoundaries() { + SpeechAdKeywordSet set = SpeechAdKeywordSet.parse("AD,404"); + + assertEquals("ad", set.firstMatch("an ad starts now").orElseThrow()); + assertEquals("404", set.firstMatch("error 404 occurred").orElseThrow()); + assertTrue(set.firstMatch("download finished").isEmpty()); + assertTrue(set.firstMatch("ad2 starts now").isEmpty()); + assertTrue(set.firstMatch("error404 occurred").isEmpty()); + } + + @Test + public void emptyNullAndPunctuationOnlyTokensAreDropped() { + assertTrue(SpeechAdKeywordSet.parse(null).isEmpty()); + assertTrue(SpeechAdKeywordSet.parse(",;\n---").isEmpty()); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsMoreThanConfiguredKeywordCapacity() { + StringBuilder value = new StringBuilder(); + for (int i = 0; i < 129; i++) value.append("词").append(i).append(","); + + SpeechAdKeywordSet.parse(value.toString()); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsKeywordLongerThanConfiguredLimit() { + SpeechAdKeywordSet.parse("a".repeat(65)); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsInputLongerThanConfiguredLimit() { + SpeechAdKeywordSet.parse("a".repeat(8_193)); + } + + @Test + public void equalityAndHashCodeDependOnNormalizedOrderedValues() { + SpeechAdKeywordSet first = SpeechAdKeywordSet.parse(" 澳门,AD,澳门 "); + SpeechAdKeywordSet same = SpeechAdKeywordSet.parse("澳门;ad"); + SpeechAdKeywordSet differentOrder = SpeechAdKeywordSet.parse("ad;澳门"); + + assertEquals(first, same); + assertEquals(first.hashCode(), same.hashCode()); + assertNotEquals(first, differentOrder); + assertNotEquals(first, null); + } + + @Test + public void valuesCannotBeModified() { + SpeechAdKeywordSet set = SpeechAdKeywordSet.parse("澳门"); + + try { + set.values().add("赌场"); + fail("values must be immutable"); + } catch (UnsupportedOperationException expected) { + assertFalse(set.isEmpty()); + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdRuntimeEndToEndTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdRuntimeEndToEndTest.java new file mode 100644 index 0000000000..40603fa451 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdRuntimeEndToEndTest.java @@ -0,0 +1,240 @@ +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 com.fongmi.android.tv.subtitle.SpeechRecognitionFactory; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +public class SpeechAdRuntimeEndToEndTest { + + @Test + public void realHubAndRuntimeHonorPromptAutoUndoAndTimelineIsolation() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); + PlaybackMediaSignalHub.Session session = hub.beginSession(0L); + FakePlaybackPort playback = new FakePlaybackPort(hub); + FakeUiPort ui = new FakeUiPort(); + FakeRecognitionFactory recognition = new FakeRecognitionFactory(); + AtomicReference config = new AtomicReference<>( + SpeechAdConfig.create(true, "赌场", 15, "PROMPT")); + + AdAudioRuntimeController runtime = new AdAudioRuntimeController( + hub, new PlaybackMediaClock(500L), + SpeechAdRuntimeEndToEndTest::emptySnapshot, playback, + Runnable::run, () -> { }, + ignored -> new NoopAdAudioSignalProvider("probe"), + () -> new SpeechAdSignalProvider( + hub, recognition, config::get, Runnable::run, + new AdAudioDiagnostics())); + + runtime.setSpeechConfig(config.get()); + runtime.start(false); + runtime.bindUi(ui); + assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + assertEquals(AdSkipPolicyController.Mode.PROMPT, runtime.skipMode()); + + FakeRecognitionSession promptSession = recognition.current(); + hub.publishPcm(session.frame(new float[] {0.1f}, 16_000, 1_000L)); + promptSession.emit("欢迎来到赌场", promptSession.lastTimelineToken); + + assertEquals(1, ui.candidateShows); + assertTrue(playback.seekTargets.isEmpty()); + ui.confirm(); + assertEquals(List.of(16_000L), playback.seekTargets); + ui.undo(); + assertEquals(List.of(16_000L, 1_000L), playback.seekTargets); + + config.set(SpeechAdConfig.create(true, "赌场", 15, "AUTO")); + runtime.setSpeechConfig(config.get()); + assertEquals(1, promptSession.closeCalls); + assertEquals(AdSkipPolicyController.Mode.PROMPT, runtime.skipMode()); + + promptSession.emit("赌场", promptSession.lastTimelineToken); + assertEquals(List.of(16_000L, 1_000L), playback.seekTargets); + assertEquals(1, ui.candidateShows); + + FakeRecognitionSession autoSession = recognition.current(); + hub.publishPcm(session.frame(new float[] {0.2f}, 16_000, 1_100L)); + autoSession.emit("赌场", autoSession.lastTimelineToken); + + assertEquals(1, ui.candidateShows); + assertEquals(List.of(16_000L, 1_000L, 16_100L), playback.seekTargets); + ui.undo(); + assertEquals(List.of(16_000L, 1_000L, 16_100L, 1_000L), playback.seekTargets); + + int staleTimelineToken = autoSession.lastTimelineToken; + playback.positionMs = 30_000L; + PlaybackMediaSignalHub.Session reset = hub.resetTimeline( + 30_000L, PlaybackMediaSignalHub.ResetReason.SEEK); + assertEquals(1, autoSession.resetCalls); + + autoSession.emit("赌场", staleTimelineToken); + assertEquals(4, playback.seekTargets.size()); + assertEquals(1, ui.candidateShows); + + runtime.refresh(); + // The recognizer is reused across the reset instead of being rebuilt; isolation + // comes from the bumped timeline token plus session.reset(), not from a new session. + FakeRecognitionSession resumedSession = recognition.current(); + assertTrue(autoSession == resumedSession); + assertEquals(0, autoSession.closeCalls); + assertEquals(1, autoSession.resetCalls); + + autoSession.emit("赌场", staleTimelineToken); + assertEquals(4, playback.seekTargets.size()); + assertEquals(1, ui.candidateShows); + + hub.publishPcm(reset.frame(new float[] {0.3f}, 16_000, 30_000L)); + resumedSession.emit("赌场", resumedSession.lastTimelineToken); + + assertEquals(List.of(16_000L, 1_000L, 16_100L, 1_000L, 45_000L), + playback.seekTargets); + ui.undo(); + assertEquals(List.of(16_000L, 1_000L, 16_100L, 1_000L, 45_000L, 30_000L), + playback.seekTargets); + + runtime.close(); + hub.close(); + } + + private static AdAudioRuleSnapshot emptySnapshot() { + return new AdAudioRuleSnapshot( + "test", "", AudioFingerprintRuleSet.empty(), List.of(), ""); + } + + private static final class FakePlaybackPort + implements AdAudioRuntimeController.PlaybackPort { + private final PlaybackMediaSignalHub hub; + private final List seekTargets = new ArrayList<>(); + private long positionMs = 1_000L; + + private FakePlaybackPort(PlaybackMediaSignalHub hub) { + this.hub = hub; + } + + @Override + public boolean isEligible(long sessionId, long generation) { + PlaybackMediaSignalHub.Session current = hub.session(); + return current.id() == sessionId && current.generation() == generation; + } + + @Override + public AdSkipCoordinator.PlaybackSnapshot snapshot( + long sessionId, long generation) { + return new AdSkipCoordinator.PlaybackSnapshot( + sessionId, generation, positionMs, 120_000L, true, false, + new PlaybackMediaClock.Snapshot( + generation, 0L, positionMs, positionMs, true, true)); + } + + @Override + public AdSkipCoordinator.SeekResult seekTo( + long sessionId, long generation, long positionMs) { + PlaybackMediaSignalHub.Session current = hub.session(); + boolean applied = current.id() == sessionId + && current.generation() == generation; + if (applied) seekTargets.add(positionMs); + return new AdSkipCoordinator.SeekResult( + applied, current.id(), current.generation()); + } + } + + private static final class FakeUiPort implements AdSkipCoordinator.UiPort { + private int candidateShows; + private AdSkipCoordinator.Actions candidateActions; + private AdSkipCoordinator.Actions undoActions; + + @Override + public void showCandidate( + AdSkipCoordinator.Prompt prompt, + AdSkipCoordinator.Actions actions) { + candidateShows++; + candidateActions = actions; + } + + @Override + public void showUndo( + AdSkipCoordinator.UndoPrompt prompt, + AdSkipCoordinator.Actions actions) { + undoActions = actions; + } + + @Override + public void dismiss(long sessionId) { + } + + private void confirm() { + candidateActions.confirm(); + } + + private void undo() { + undoActions.undo(); + } + } + + private static final class FakeRecognitionFactory + implements SpeechRecognitionFactory { + private final List sessions = new ArrayList<>(); + + @Override + public boolean isReady() { + return true; + } + + @Override + public Session create(Listener listener) { + FakeRecognitionSession session = new FakeRecognitionSession(listener); + sessions.add(session); + return session; + } + + private FakeRecognitionSession current() { + return sessions.get(sessions.size() - 1); + } + } + + private static final class FakeRecognitionSession + implements SpeechRecognitionFactory.Session { + private final SpeechRecognitionFactory.Listener listener; + private int lastTimelineToken; + private long lastStartUs; + private long lastEndUs; + private int resetCalls; + private int closeCalls; + + private FakeRecognitionSession(SpeechRecognitionFactory.Listener listener) { + this.listener = listener; + } + + @Override + public void accept(float[] samples, long startUs, long endUs, + int timelineToken) { + lastTimelineToken = timelineToken; + lastStartUs = startUs; + lastEndUs = endUs; + } + + @Override + public void reset() { + resetCalls++; + } + + @Override + public void close() { + closeCalls++; + } + + /** Echoes the capture window of the last accepted frame, like the real recognizer. */ + private void emit(String text, int timelineToken) { + listener.onResult(text, lastStartUs, lastEndUs, timelineToken); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProviderTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProviderTest.java new file mode 100644 index 0000000000..99a172c54d --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProviderTest.java @@ -0,0 +1,512 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; +import com.fongmi.android.tv.subtitle.SpeechRecognitionFactory; + +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.Executor; + +public class SpeechAdSignalProviderTest { + + @Test + public void keywordMatchEmitsBoundedSpeechCandidate() { + Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); + fixture.host(10_000L, 20_000L, true, false); + assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, fixture.provider.state()); + assertTrue(fixture.hub.isCaptureRequested( + PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + + fixture.publish(new float[] {0.1f, 0.2f}, 8_000, 10_000L); + FakeSession recognizer = fixture.factory.current(); + assertEquals(4, recognizer.acceptedSamples.get(0).length); + recognizer.emit("\u6b22\u8fce\u6765\u5230\u8d4c\u573a", recognizer.lastTimelineToken()); + + assertEquals(1, fixture.emitted.size()); + AdAudioSignalProvider.AdAudioCandidate candidate = fixture.emitted.get(0); + assertEquals(SpeechAdSignalProvider.ID, candidate.providerId()); + assertEquals(SpeechAdSignalProvider.RULE_ID, candidate.ruleId()); + assertEquals("v1", candidate.ruleVersion()); + assertEquals(10_000L, candidate.startMs()); + assertEquals(25_000L, candidate.endMs()); + assertTrue(candidate.fullMatch()); + assertEquals(1.0d, candidate.similarity(), 0.0d); + assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.SPEECH_MATCHED)); + fixture.close(); + } + + @Test + public void providerDoesNotRunWhenDisabledModelKeywordsOrVodClockAreInvalid() { + Fixture missingModel = new Fixture(false, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); + missingModel.host(1_000L, 20_000L, true, false); + assertEquals(AdAudioSignalProvider.ProviderState.DEGRADED, missingModel.provider.state()); + assertEquals(1L, missingModel.diagnostics.count( + AdAudioDiagnostics.Code.SPEECH_MODEL_UNAVAILABLE)); + assertFalse(missingModel.hub.isCaptureRequested( + PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + missingModel.close(); + + Fixture disabled = new Fixture(true, config(false, "\u8d4c\u573a", 15), Runnable::run, 8); + disabled.host(1_000L, 20_000L, true, false); + assertEquals(AdAudioSignalProvider.ProviderState.DISABLED, disabled.provider.state()); + assertFalse(disabled.hub.isCaptureRequested( + PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + disabled.close(); + + Fixture emptyKeywords = new Fixture(true, config(true, "", 15), Runnable::run, 8); + emptyKeywords.host(1_000L, 20_000L, true, false); + assertNotEquals(AdAudioSignalProvider.ProviderState.RUNNING, + emptyKeywords.provider.state()); + emptyKeywords.close(); + + Fixture live = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); + live.host(1_000L, 20_000L, true, true); + assertNotEquals(AdAudioSignalProvider.ProviderState.RUNNING, live.provider.state()); + live.close(); + + Fixture unseekable = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); + unseekable.host(1_000L, 20_000L, false, false); + assertNotEquals(AdAudioSignalProvider.ProviderState.RUNNING, unseekable.provider.state()); + unseekable.close(); + + Fixture unknownDuration = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); + unknownDuration.host(1_000L, -1L, true, false); + assertNotEquals(AdAudioSignalProvider.ProviderState.RUNNING, + unknownDuration.provider.state()); + unknownDuration.close(); + } + + @Test + public void resetDropsLateWorkAndContinuesWithTheSameRecognizerSession() { + ManualExecutor worker = new ManualExecutor(); + Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), worker, 4); + fixture.host(10_000L, 60_000L, true, false); + FakeSession recognizer = fixture.factory.current(); + fixture.publish(new float[] {0.1f}, 16_000, 10_000L); + worker.runAll(); + int oldTimelineToken = recognizer.lastTimelineToken(); + fixture.publish(new float[] {0.2f}, 16_000, 10_100L); + + PlaybackMediaSignalHub.Session resetSession = fixture.hub.resetTimeline( + 30_000L, PlaybackMediaSignalHub.ResetReason.SEEK); + worker.runAll(); + recognizer.emit("\u8d4c\u573a", oldTimelineToken); + + assertTrue(fixture.emitted.isEmpty()); + assertEquals(1, recognizer.resetCalls); + assertEquals(0, recognizer.closeCalls); + assertEquals(1, recognizer.acceptedSamples.size()); + assertTrue(fixture.diagnostics.count(AdAudioDiagnostics.Code.STALE_GENERATION) >= 1L); + assertTrue(fixture.diagnostics.count( + AdAudioDiagnostics.Code.SPEECH_STALE_CALLBACK) >= 1L); + assertTrue(fixture.hub.isCaptureRequested( + PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + + fixture.provider.onHostPosition(host( + resetSession, 30_000L, 60_000L, true, false)); + fixture.hub.publishPcm(resetSession.frame( + new float[] {0.3f}, 16_000, 30_000L)); + worker.runAll(); + assertEquals(2, recognizer.acceptedSamples.size()); + recognizer.emit("\u8d4c\u573a", recognizer.lastTimelineToken()); + + assertEquals(1, fixture.emitted.size()); + assertEquals(resetSession.generation(), fixture.emitted.get(0).generation()); + assertEquals(30_000L, fixture.emitted.get(0).startMs()); + fixture.close(); + } + @Test + public void cooldownSuppressesPartialAndFinalDuplicates() { + Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); + fixture.host(10_000L, 60_000L, true, false); + fixture.publish(new float[] {0.1f}, 16_000, 10_000L); + FakeSession recognizer = fixture.factory.current(); + + recognizer.emit("\u8d4c\u573a", recognizer.lastTimelineToken()); + fixture.host(20_000L, 60_000L, true, false); + recognizer.emit("\u6b22\u8fce\u6765\u5230\u8d4c\u573a", recognizer.lastTimelineToken()); + + assertEquals(1, fixture.emitted.size()); + assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.SPEECH_COOLDOWN)); + fixture.close(); + } + + @Test + public void candidateTracksSpokenMomentNotTheLastPublishedHostPosition() { + Fixture fixture = new Fixture(true, config(true, "赌场", 15), Runnable::run, 8); + // Host position is published once when playback becomes READY and is not pumped + // again during steady playback. + fixture.host(0L, 2_400_000L, true, false); + + // 15 minutes later the keyword is spoken; the capture clock knows this, the cached + // host position does not. + fixture.publish(new float[] {0.1f}, 16_000, 900_000L); + FakeSession recognizer = fixture.factory.current(); + recognizer.emitAt("赌场", 900_000_000L, 901_000_000L, recognizer.lastTimelineToken()); + + assertEquals(1, fixture.emitted.size()); + assertEquals(900_000L, fixture.emitted.get(0).startMs()); + assertEquals(915_000L, fixture.emitted.get(0).endMs()); + fixture.close(); + } + + @Test + public void ineligiblePositionParksTheProviderWithoutTearingDownTheRecognizer() { + Fixture fixture = new Fixture(true, config(true, "赌场", 15), Runnable::run, 8); + fixture.host(10_000L, 20_000L, true, false); + assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, fixture.provider.state()); + FakeSession running = fixture.factory.current(); + + // Buffering: duration momentarily unknown. This happens repeatedly during normal + // playback, so it must not destroy the recognizer or drop the capture lease -- + // rebuilding sherpa on every buffering blip loses the in-flight utterance and + // forces the audio pipeline to be re-attached. + fixture.host(10_000L, -1L, false, false); + + assertEquals(AdAudioSignalProvider.ProviderState.IDLE, fixture.provider.state()); + assertEquals(0, running.closeCalls); + assertEquals(1, fixture.factory.sessions.size()); + assertTrue(fixture.hub.isCaptureRequested( + PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + + // Recovering must resume the same session rather than start a new one. + fixture.host(11_000L, 20_000L, true, false); + assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, fixture.provider.state()); + assertEquals(1, fixture.factory.sessions.size()); + fixture.close(); + } + + @Test + public void mismatchedHostGenerationIsIgnoredBeforeActivation() { + Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); + + fixture.provider.onHostPosition(new AdAudioSignalProvider.HostPosition( + fixture.session.id(), fixture.session.generation() + 1L, + 1_000L, 20_000L, true, false)); + + assertNotEquals(AdAudioSignalProvider.ProviderState.RUNNING, fixture.provider.state()); + assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.STALE_GENERATION)); + fixture.host(1_000L, 20_000L, true, false); + assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, fixture.provider.state()); + fixture.close(); + } + + @Test + public void candidateUsesCaptureTimeAndLeavesDurationClampingToTheCoordinator() { + Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 120), Runnable::run, 8); + fixture.host(59_500L, 60_000L, true, false); + fixture.publish(new float[] {0.1f}, 16_000, 59_500L); + FakeSession recognizer = fixture.factory.current(); + + recognizer.emit("\u8d4c\u573a", recognizer.lastTimelineToken()); + + // The provider reports the raw capture interval, exactly like the fingerprint + // provider. AdSkipCoordinator.targetFor is the single place that clamps to + // duration, so clamping here would double-apply it. + assertEquals(1, fixture.emitted.size()); + assertEquals(59_500L, fixture.emitted.get(0).startMs()); + assertEquals(179_500L, fixture.emitted.get(0).endMs()); + fixture.close(); + } + + @Test + public void factoryAndRecognizerFailuresAreIsolated() { + Fixture createFailure = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); + createFailure.factory.throwOnCreate = true; + createFailure.host(1_000L, 20_000L, true, false); + assertEquals(AdAudioSignalProvider.ProviderState.DEGRADED, + createFailure.provider.state()); + assertEquals(1L, createFailure.diagnostics.count( + AdAudioDiagnostics.Code.SPEECH_START_FAILED)); + createFailure.close(); + + Fixture acceptFailure = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); + acceptFailure.host(1_000L, 20_000L, true, false); + acceptFailure.factory.current().throwOnAccept = true; + acceptFailure.publish(new float[] {0.1f}, 16_000, 1_000L); + acceptFailure.factory.current().fail(); + assertTrue(acceptFailure.diagnostics.count(AdAudioDiagnostics.Code.MATCHER_ERROR) >= 2L); + acceptFailure.close(); + } + + @Test + public void listenerExceptionsAreIsolatedWithoutLeakingRecognizedText() { + PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); + PlaybackMediaSignalHub.Session session = hub.beginSession(0L); + FakeRecognizerFactory factory = new FakeRecognizerFactory(true); + AdAudioDiagnostics diagnostics = new AdAudioDiagnostics(); + SpeechAdSignalProvider provider = new SpeechAdSignalProvider( + hub, factory, () -> config(true, "\u8d4c\u573a", 15), Runnable::run, diagnostics); + AdAudioSignalProvider.Listener throwing = new AdAudioSignalProvider.Listener() { + @Override + public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { + throw new IllegalStateException("listener failed"); + } + + @Override + public void onProviderError(AdAudioSignalProvider.ProviderError error) { + throw new IllegalStateException("listener failed"); + } + + @Override + public void onTimelineReset(AdAudioSignalProvider.TimelineReset reset) { + throw new IllegalStateException("listener failed"); + } + }; + + provider.setEnabled(true); + provider.start(context(session), rules(), throwing); + provider.onHostPosition(host(session, 1_000L, 20_000L, true, false)); + hub.publishPcm(session.frame(new float[] {0.1f}, 16_000, 1_000L)); + factory.current().emit("\u8d4c\u573a", factory.current().lastTimelineToken()); + provider.onTimelineReset(new AdAudioSignalProvider.TimelineReset( + session.id(), session.generation() + 1L, + AdAudioSignalProvider.ResetReason.SEEK, 2_000L)); + + assertTrue(diagnostics.count(AdAudioDiagnostics.Code.MATCHER_ERROR) >= 2L); + provider.close(); + hub.close(); + } + + @Test + public void mailboxEvictsOldestPcmAndKeepsNewestFrames() { + ManualExecutor worker = new ManualExecutor(); + Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), worker, 2); + fixture.host(1_000L, 20_000L, true, false); + + fixture.publish(new float[] {1.0f}, 16_000, 1_000L); + fixture.publish(new float[] {2.0f}, 16_000, 1_100L); + fixture.publish(new float[] {3.0f}, 16_000, 1_200L); + assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.QUEUE_OVERFLOW)); + worker.runAll(); + + FakeSession recognizer = fixture.factory.current(); + assertEquals(2, recognizer.acceptedSamples.size()); + assertEquals(2.0f, recognizer.acceptedSamples.get(0)[0], 0.0f); + assertEquals(3.0f, recognizer.acceptedSamples.get(1)[0], 0.0f); + fixture.close(); + } + + @Test + public void emptyAndNonMatchingTextDoNotEmitCandidates() { + Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); + fixture.host(1_000L, 20_000L, true, false); + fixture.publish(new float[] {0.1f}, 16_000, 1_000L); + FakeSession recognizer = fixture.factory.current(); + + recognizer.emit(" ", recognizer.lastTimelineToken()); + recognizer.emit("\u6b22\u8fce\u6536\u770b\u8282\u76ee", recognizer.lastTimelineToken()); + + assertTrue(fixture.emitted.isEmpty()); + assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.SPEECH_TEXT_EMPTY)); + fixture.close(); + } + + @Test + public void closeIsIdempotentAndReleasesRegistrationLeaseAndSession() { + Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); + fixture.host(1_000L, 20_000L, true, false); + FakeSession recognizer = fixture.factory.current(); + recognizer.throwOnClose = true; + + fixture.provider.close(); + fixture.provider.close(); + + assertEquals(AdAudioSignalProvider.ProviderState.CLOSED, fixture.provider.state()); + assertEquals(1, recognizer.closeCalls); + assertFalse(fixture.hub.isCaptureRequested( + PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + PlaybackMediaSignalHub.Registration replacement = fixture.hub.register( + "speech-ad", Runnable::run, 1, new PlaybackMediaSignalHub.Consumer() {}); + assertNotNull(replacement); + replacement.close(); + fixture.hub.close(); + } + + private static SpeechAdConfig config(boolean enabled, String keywords, int skipSeconds) { + return SpeechAdConfig.create(enabled, keywords, skipSeconds, "PROMPT"); + } + + private static AdAudioSignalProvider.SessionContext context( + PlaybackMediaSignalHub.Session session) { + return new AdAudioSignalProvider.SessionContext( + session.id(), session.generation(), "media", "https://example.test/video", + Map.of()); + } + + private static AdAudioSignalProvider.HostPosition host( + PlaybackMediaSignalHub.Session session, long positionMs, long durationMs, + boolean seekable, boolean live) { + return new AdAudioSignalProvider.HostPosition( + session.id(), session.generation(), positionMs, durationMs, seekable, live); + } + + private static AdAudioRuleSnapshot rules() { + return new AdAudioRuleSnapshot( + "test", "v1", AudioFingerprintRuleSet.empty(), List.of(), ""); + } + + private static AdAudioSignalProvider.Listener listener( + List emitted, + List errors, + List resets) { + return new AdAudioSignalProvider.Listener() { + @Override + public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { + emitted.add(candidate); + } + + @Override + public void onProviderError(AdAudioSignalProvider.ProviderError error) { + errors.add(error); + } + + @Override + public void onTimelineReset(AdAudioSignalProvider.TimelineReset reset) { + resets.add(reset); + } + }; + } + + private static final class Fixture implements AutoCloseable { + private final PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); + private final PlaybackMediaSignalHub.Session session = hub.beginSession(0L); + private final FakeRecognizerFactory factory; + private final AdAudioDiagnostics diagnostics = new AdAudioDiagnostics(); + private final List emitted = new ArrayList<>(); + private final List errors = new ArrayList<>(); + private final List resets = new ArrayList<>(); + private final SpeechAdSignalProvider provider; + + private Fixture(boolean modelReady, SpeechAdConfig config, + Executor worker, int mailboxCapacity) { + factory = new FakeRecognizerFactory(modelReady); + provider = new SpeechAdSignalProvider( + hub, factory, () -> config, mailboxCapacity, worker, diagnostics); + provider.setEnabled(true); + provider.start(context(session), rules(), listener(emitted, errors, resets)); + } + + private void host(long positionMs, long durationMs, + boolean seekable, boolean live) { + provider.onHostPosition(SpeechAdSignalProviderTest.host( + session, positionMs, durationMs, seekable, live)); + } + + private void publish(float[] samples, int sampleRate, long startMs) { + hub.publishPcm(session.frame(samples, sampleRate, startMs)); + } + + @Override + public void close() { + provider.close(); + hub.close(); + } + } + + private static final class FakeRecognizerFactory implements SpeechRecognitionFactory { + private final boolean ready; + private final List sessions = new ArrayList<>(); + private boolean throwOnCreate; + + private FakeRecognizerFactory(boolean ready) { + this.ready = ready; + } + + @Override + public boolean isReady() { + return ready; + } + + @Override + public Session create(Listener listener) { + if (throwOnCreate) throw new IllegalStateException("create failed"); + FakeSession session = new FakeSession(listener); + sessions.add(session); + return session; + } + + private FakeSession current() { + assertFalse(sessions.isEmpty()); + return sessions.get(sessions.size() - 1); + } + } + + private static final class FakeSession implements SpeechRecognitionFactory.Session { + private final SpeechRecognitionFactory.Listener listener; + private final List acceptedSamples = new ArrayList<>(); + private int lastTimelineToken; + private long lastStartUs; + private long lastEndUs; + private int resetCalls; + private int closeCalls; + private boolean throwOnAccept; + private boolean throwOnClose; + + private FakeSession(SpeechRecognitionFactory.Listener listener) { + this.listener = listener; + } + + @Override + public void accept(float[] samples, long startUs, long endUs, int timelineToken) { + if (throwOnAccept) throw new IllegalStateException("accept failed"); + acceptedSamples.add(samples.clone()); + lastTimelineToken = timelineToken; + lastStartUs = startUs; + lastEndUs = endUs; + } + + @Override + public void reset() { + resetCalls++; + } + + @Override + public void close() { + closeCalls++; + if (throwOnClose) throw new IllegalStateException("close failed"); + } + + private int lastTimelineToken() { + return lastTimelineToken; + } + + /** Echoes the capture window of the last accepted frame, like the real recognizer. */ + private void emit(String text, int timelineToken) { + listener.onResult(text, lastStartUs, lastEndUs, timelineToken); + } + + private void emitAt(String text, long startUs, long endUs, int timelineToken) { + listener.onResult(text, startUs, endUs, timelineToken); + } + + private void fail() { + listener.onError(new IllegalStateException("recognizer failed")); + } + } + + private static final class ManualExecutor implements Executor { + private final Queue tasks = new ArrayDeque<>(); + + @Override + public void execute(Runnable command) { + tasks.add(command); + } + + private void runAll() { + Runnable task; + while ((task = tasks.poll()) != null) task.run(); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/StreamingPcmResamplerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/StreamingPcmResamplerTest.java new file mode 100644 index 0000000000..2ac119804a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/StreamingPcmResamplerTest.java @@ -0,0 +1,55 @@ +package com.fongmi.android.tv.ad.audio; + +import java.util.Arrays; + +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; + +public class StreamingPcmResamplerTest { + + @Test + public void chunkBoundariesDoNotChangeResampledSamples() { + short[] source = ramp(441, 3); + short[] expected = expectedResample(source, 44_100, 16_000); + + StreamingPcmResampler resampler = new StreamingPcmResampler(16_000); + short[] first = resampler.append(Arrays.copyOfRange(source, 0, 127), 44_100, 1); + short[] second = resampler.append(Arrays.copyOfRange(source, 127, 319), 44_100, 1); + short[] tail = resampler.append(Arrays.copyOfRange(source, 319, source.length), 44_100, 1); + short[] flushed = resampler.flush(); + + assertArrayEquals(expected, concat(first, second, tail, flushed)); + } + + private static short[] expectedResample(short[] source, int sourceRate, int targetRate) { + int targetLength = Math.max(1, (int) Math.round(source.length * targetRate / (double) sourceRate)); + short[] expected = new short[targetLength]; + for (int i = 0; i < targetLength; i++) { + double position = i * sourceRate / (double) targetRate; + int left = Math.min(source.length - 1, (int) position); + int right = Math.min(source.length - 1, left + 1); + expected[i] = (short) Math.round(source[left] * (1 - (position - left)) + + source[right] * (position - left)); + } + return expected; + } + + private static short[] ramp(int frames, int step) { + short[] source = new short[frames]; + for (int i = 0; i < frames; i++) source[i] = (short) (i * step); + return source; + } + + private static short[] concat(short[]... arrays) { + int length = 0; + for (short[] array : arrays) length += array.length; + short[] result = new short[length]; + int offset = 0; + for (short[] array : arrays) { + System.arraycopy(array, 0, result, offset, array.length); + offset += array.length; + } + return result; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/TinkEd25519CompatibilityTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/TinkEd25519CompatibilityTest.java new file mode 100644 index 0000000000..fffc1b571a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/TinkEd25519CompatibilityTest.java @@ -0,0 +1,25 @@ +package com.fongmi.android.tv.ad.audio; + +import static org.junit.Assert.assertEquals; + +import com.google.crypto.tink.PublicKeySign; +import com.google.crypto.tink.PublicKeyVerify; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +public class TinkEd25519CompatibilityTest { + + @Test + public void rawEd25519SignatureIsPortableLength() throws Exception { + byte[] message = "webhtv-ed25519-test".getBytes(StandardCharsets.US_ASCII); + PublicKeySign signer = SignedRulePackageTestKeys.signer(); + PublicKeyVerify verifier = SignedRulePackageTestKeys.verifier(); + + byte[] signature = signer.sign(message); + + assertEquals(64, signature.length); + verifier.verify(signature, message); + } +} 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..cfcac83c58 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java @@ -0,0 +1,159 @@ +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, 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 + 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/AdEvidenceCollectorTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollectorTest.java new file mode 100644 index 0000000000..88df901b78 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollectorTest.java @@ -0,0 +1,189 @@ +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 keepsEveryOutsideSegmentForLongPlaylists() { + // 区间外必须完整保留:分类器用它做「区间外不含某特征」的全局否定判断, + // 截断样本会让 playlist 后半段的反例看不见,从而生成命中全站的规则。 + 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(497, 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..d72c2a941d --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java @@ -0,0 +1,437 @@ +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.HlsAdRule; +import com.fongmi.android.tv.bean.M3u8Evidence; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +public class AdFeedbackControllerTest { + + /** 10 个 10s 切片,下标 3-5 来自广告域名,断点在 3 与 6。 */ + /** + * 30 片 playlist,下标 3-5 是广告。片数要够 —— 规则自检会用真实 cleaner + * 跑一遍,10 片里删 3 片已达 30%,逼近 35% 的删除比例上限。 + */ + private static M3u8Evidence adBlockPlaylist() { + List segments = new ArrayList<>(); + List durations = new ArrayList<>(); + List switches = new ArrayList<>(); + for (int i = 0; i < 30; 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); + } + + /** + * 控制器必须把 {@code host.activeHlsRules()} 传给分类器做叠加校验。 + * + *

没有这条测试,把 {@code classify} 里的 activeRules 换成 {@code List.of()} + * 整个套件依然全绿 —— 串接一旦断开不会有任何红灯。 + */ + @Test + public void passesActiveRulesIntoClassifiersForStackingCheck() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + // 先确认这份证据在无已启用规则时能产出方案 + AdFeedbackController baseline = new AdFeedbackController(host); + baseline.onMarkedInterval(30_000, 49_200); + assertTrue("前置条件:无叠加时应有可落地方案", + host.shownSessions.get(1).hasActionablePlan()); + + // 一条已启用规则删掉另外 9 片正片(下标 6-14,各 10s)。与新规则的 3 片 + // 合并后同时越过两道闸门:12/30 = 40% > 35%,且 90 + 19.2 = 109.2s > 90s。 + // 刻意让两道都越界,避免测试卡在单一闸门的边界上。 + FakeHost stacked = new FakeHost(); + stacked.playlist = adBlockPlaylist(); + stacked.activeRules = List.of(HlsAdRule.createUserRule( + "active", "active", List.of("v.example.com"), List.of(), + List.of("^[^?#]*/seg/(?:6|7|8|9|10|11|12|13|14)\\.ts"), + null, null, false, false, 1).compile()); + AdFeedbackController controller = new AdFeedbackController(stacked); + + controller.onMarkedInterval(30_000, 49_200); + + assertFalse("叠加后越过回退闸门,不该再给出可落地方案", + stacked.shownSessions.get(1).hasActionablePlan()); + } + + /** + * {@code activeHlsRules()} 返回 {@code null}(无法确定站内规则)时必须弃权, + * 而不是当成「没有规则」继续放行。 + */ + @Test + public void abstainsWhenHostCannotDetermineActiveRules() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + host.activeRules = null; + AdFeedbackController controller = new AdFeedbackController(host); + + controller.onMarkedInterval(30_000, 49_200); + + assertFalse("规则集未知时不该给出可落地方案", + host.shownSessions.get(1).hasActionablePlan()); + } + + @Test + public void showsPendingSessionBeforeAnalysisThenVerdict() { + FakeHost host = new FakeHost(); + host.playlist = adBlockPlaylist(); + AdFeedbackController controller = new AdFeedbackController(host); + + AdFeedbackSession session = controller.onMarkedInterval(30_000, 49_200); + + 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, 49_200); + + assertTrue(session.skipApplied()); + assertEquals(List.of("30000-49200"), 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, 49_200); + + // 未跳过不影响归因继续 + 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 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, 49_200); + 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(); + 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, 49_200); + + 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 surfacesEnablingDisabledRuleAsAnOption() { + 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"), + ExistingRuleClassifierTest.compiledRule("ad-cdn.other.com"))); + AdFeedbackController controller = new AdFeedbackController(host); + + controller.onMarkedInterval(30_000, 49_200); + + // 启用已有规则的成本最低,但仲裁是置信度 0.7 + 成本 0.3 的加权: + // HLS 与域名通道同类合并后置信度 0.91,压过既有规则的 0.75。 + // 成本优先只在置信度接近时生效,那条约束由 + // AdAttributionArbiterTest.enablingExistingRuleOutranksNewRuleEvenAtLowerConfidence 锁定。 + // 这里只要求它作为可选方案出现,不被丢弃。 + AdAttributionArbiter.Verdict verdict = host.shownSessions.get(1).verdict(); + List all = new ArrayList<>(); + all.add(verdict.preferred()); + all.addAll(verdict.alternatives()); + assertTrue("启用已有规则必须作为可选方案保留", + all.stream().anyMatch(a -> a.remediation() == RemediationKind.ENABLE_EXISTING_RULE)); + } + + @Test + public void fallsBackToSessionSkipWhenNoChannelHasEvidence() { + FakeHost host = new FakeHost(); + // 无 playlist、无基线、无规则:全部通道弃权 + host.legacyActive = true; + AdFeedbackController controller = new AdFeedbackController(host); + + controller.onMarkedInterval(30_000, 49_200); + + 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, 49_200); + + 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, 49_200); + 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(); + private List activeRules = 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 activeHlsRules() { + return activeRules; + } + + @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); + } + } +} 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..0eadbd2275 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSourceTest.java @@ -0,0 +1,85 @@ +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, + ExistingRuleClassifierTest.compiledRule("ad-cdn.other.com")); + + List outside = new java.util.ArrayList<>(); + for (int i = 0; i < 3; i++) { + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = 4; i < 30; i++) { + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, i == 4)); + } + 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)), + outside, + 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()); + } +} 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..8c7dd7dc21 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapterTest.java @@ -0,0 +1,243 @@ +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"); }, + () -> { 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()); + // 唯一的例外:已启用规则读不到时必须是 null 而非空列表 —— 空列表意味着 + // 「站内确无规则生效」,会让自检跳过叠加校验,是 fail-open。 + assertNull("读取失败必须传播为『无法确定』", adapter.activeHlsRules()); + } + + @Test + public void nullConfigValuesBecomeEmptyLists() { + AdFeedbackHostAdapter.Deps nulls = new AdFeedbackHostAdapter.Deps( + () -> null, () -> 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()); + // activeHlsRules 的 null 是有语义的,不能被折成空列表 + assertNull("数据源的『无法确定』必须原样透传", adapter.activeHlsRules()); + } + + @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, 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) { + } + } +} 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()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/CompiledRuleGateTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/CompiledRuleGateTest.java new file mode 100644 index 0000000000..abed517f4c --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/CompiledRuleGateTest.java @@ -0,0 +1,149 @@ +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.HlsAdRule; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * 「启用已有规则」通道的守卫:{@code compiledOf} 必须拒掉那些自检会放行、 + * 但真实运行会误删正片的既有规则。 + * + *

第八轮评审复现的链路:{@code RuleSelfCheck} 的合成 manifest 用 + * {@code SegmentFact.path()} 拼 URL,而那是 {@code URI.getPath()} 的产物, + * query 与 fragment 都被去敏丢弃。于是一条 {@code segmentUrlRegex=["/ads/"]} + * 的既有规则在自检里只匹配到广告片,真实运行却会命中所有 + * {@code ?ref=/ads/} 的正片 —— 超删比例低于 35% 时 {@code fallback=false}, + * 错误不被回退兜住。 + */ +public class CompiledRuleGateTest { + + @Test + public void rejectsRuleWithUnanchoredSegmentRegex() { + String detail = "{\"id\":\"x\",\"playlistHostSuffixes\":[\"v.example.com\"]," + + "\"segmentUrlRegex\":[\"/ads/\"],\"minimumSignals\":1}"; + + assertTrue("未锚定的切片正则必须被识别", + AdFeedbackDataSource.hasUnanchoredSegmentRegex(detail)); + assertNull("不得为这类规则背书", AdFeedbackDataSource.compiledOf(detail)); + } + + @Test + public void acceptsRuleWithAnchoredSegmentRegex() { + String detail = "{\"id\":\"x\",\"playlistHostSuffixes\":[\"v.example.com\"]," + + "\"segmentUrlRegex\":[\"^[^?#]*\\\\Q/ads/\\\\E\"],\"minimumSignals\":1}"; + + assertFalse(AdFeedbackDataSource.hasUnanchoredSegmentRegex(detail)); + assertNotNull("锚定过的正则语义与自检一致,可以放行", + AdFeedbackDataSource.compiledOf(detail)); + } + + @Test + public void acceptsRuleWithoutSegmentRegexAtAll() { + // 只靠 host + 跨域的规则不涉及 query/fragment 语义差异 + String detail = "{\"id\":\"x\",\"playlistHostSuffixes\":[\"v.example.com\"]," + + "\"hostSuffixes\":[\"ad.other.com\"],\"requireCrossDomain\":true," + + "\"minimumSignals\":2}"; + + assertFalse(AdFeedbackDataSource.hasUnanchoredSegmentRegex(detail)); + assertNotNull(AdFeedbackDataSource.compiledOf(detail)); + } + + @Test + public void treatsUnparsableDetailAsUnanchored() { + assertTrue("解析不出来时保守判为未锚定", + AdFeedbackDataSource.hasUnanchoredSegmentRegex("{\"segmentUrlRegex\":[")); + assertNull(AdFeedbackDataSource.compiledOf("not json")); + assertNull(AdFeedbackDataSource.compiledOf(null)); + assertNull(AdFeedbackDataSource.compiledOf(" ")); + } + + /** compiledOf 用本地 Gson,不依赖 Application —— 纯 JVM 下必须可用。 */ + @Test + public void roundTripsWithoutApplicationContext() { + String detail = "{\"id\":\"baofeng\",\"name\":\"暴风\",\"version\":1," + + "\"playlistHostSuffixes\":[\"v.example.com\"]," + + "\"hostSuffixes\":[\"ad.other.com\"]," + + "\"minDuration\":6.3,\"maxDuration\":6.5," + + "\"requireCrossDomain\":true,\"minimumSignals\":2}"; + + HlsManifestCleaner.Rule rule = AdFeedbackDataSource.compiledOf(detail); + + assertNotNull("本地 Gson 必须能在纯 JVM 下还原规则", rule); + // 还原出的规则行为与直接构造的一致 + String manifest = manifest(); + HlsManifestCleaner.Result viaJson = HlsManifestCleaner.clean(BASE, manifest, List.of(rule)); + HlsManifestCleaner.Result direct = HlsManifestCleaner.clean(BASE, manifest, + List.of(HlsAdRule.createUserRule("baofeng", "暴风", + List.of("v.example.com"), List.of("ad.other.com"), List.of(), + 6.3, 6.5, false, true, 2).compile())); + + assertEquals(direct.removedSegments(), viaJson.removedSegments()); + assertEquals(direct.fallback(), viaJson.fallback()); + assertEquals(direct.manifest(), viaJson.manifest()); + } + + private static final String BASE = "https://v.example.com/index.m3u8"; + + private static String manifest() { + StringBuilder text = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + for (int i = 0; i < 30; i++) { + boolean ad = i >= 10 && i < 13; + if (i == 10 || i == 13) text.append("#EXT-X-DISCONTINUITY\n"); + text.append("#EXTINF:").append(ad ? "6.400" : "8.000").append(",\n"); + text.append(ad ? "https://ad.other.com/ads/" + i + ".ts" + : "https://v.example.com/seg/" + i + ".ts").append('\n'); + } + return text.append("#EXT-X-ENDLIST\n").toString(); + } + + /** + * 端到端:带未锚定正则的既有规则不得被建议启用。 + * + *

这条规则在自检里看起来只删广告,真实运行会连 {@code ?ref=/ads/} 的正片 + * 一起删,且超删比例低于 35% 故不触发回退。 + */ + @Test + public void doesNotSuggestEnablingRuleThatOverDeletesViaQuery() { + String detail = "{\"id\":\"loose\",\"playlistHostSuffixes\":[\"v.example.com\"]," + + "\"hostSuffixes\":[\"ad.other.com\"]," + + "\"segmentUrlRegex\":[\"/ads/\"],\"minimumSignals\":1}"; + ExistingRuleClassifier.RuleState loose = new ExistingRuleClassifier.RuleState( + "vod:src:loose", "loose", "宽松规则", false, true, + List.of("ad.other.com"), AdFeedbackDataSource.compiledOf(detail)); + + List inside = new ArrayList<>(); + for (int i = 10; i < 13; i++) { + inside.add(new SegmentFact(i, "ad.other.com", "/ads/" + i + ".ts", 6.4, i == 10)); + } + List outside = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = 13; i < 40; i++) { + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, i == 13)); + } + AdIntervalEvidence evidence = new AdIntervalEvidence( + 80_000, 99_200, 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 attribution = ExistingRuleClassifier.classify(evidence, + new ExistingRuleClassifier.Input(List.of(loose), false, List.of())); + + assertTrue("带未锚定正则的规则不得被建议启用", + attribution == null + || attribution.remediation() != RemediationKind.ENABLE_EXISTING_RULE); + } +} 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..f833aa22a8 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java @@ -0,0 +1,174 @@ +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"; + + /** + * 区间内为给定 host、区间外为足量同域正片。 + * + *

区间外必须够多:cleaner 有 35% 删除比例上限,只放一两片对照时任何规则 + * 都会因超限回退,规则自检会正确拒掉,测不出通道本身的判断。 + */ + 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)); + } + List outside = new java.util.ArrayList<>(); + for (int i = insideHosts.length; i < 30; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, + i == insideHosts.length)); + } + return new AdIntervalEvidence( + 10_000, 40_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + inside, outside, + 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()); + // 域名黑名单只在 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 + 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 rejectsSparseSameDomainSampleBeforeCdnTail() { + // 过去的前 60 片采样会看到一个同域片就放行;完整 outside 后, + // 同域只占 1/299,不能证明区间内 CDN 是广告。 + List outside = new java.util.ArrayList<>(); + outside.add(new SegmentFact(0, PLAYLIST_HOST, "/seg/0.ts", 8.0, false)); + for (int i = 1; i < 300; i++) { + outside.add(new SegmentFact(i, "cdn.site-x.com", "/seg/" + i + ".ts", 8.0, false)); + } + AdIntervalEvidence evidence = new AdIntervalEvidence( + 1_000_000, 1_024_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 100 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + List.of(new SegmentFact(100, "cdn.site-x.com", "/seg/100.ts", 8.0, false)), + outside, false, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + assertNull(DomainReputationClassifier.classify(evidence, + new DomainReputationClassifier.Input( + List.of(), List.of(PLAYLIST_HOST), List.of(), ""))); + } + + @Test + public void abstainsWhenSiteHasNoSameDomainSegmentsAtAll() { + // playlist 在 v.example.com、全部切片在独立 CDN,是常见的架构拆分。 + // 没有同域切片作对照就断言「外域即广告」,生成的规则会命中全站切片 → + // cleaner 因 removedCount == segmentCount 回退原文,而 + // HlsAdblockPipeline 的 fallback 会连带停掉 legacy 启发式,比不加规则更差。 + List inside = List.of( + new SegmentFact(3, "cdn.site-x.com", "/seg/3.ts", 6.4, false)); + List outside = List.of( + new SegmentFact(0, "cdn.site-x.com", "/seg/0.ts", 8.0, false), + new SegmentFact(9, "cdn.site-x.com", "/seg/9.ts", 8.0, false)); + AdIntervalEvidence evidence = new AdIntervalEvidence( + 10_000, 40_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + inside, outside, false, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + assertNull(DomainReputationClassifier.classify(evidence, + new DomainReputationClassifier.Input( + List.of(), List.of(PLAYLIST_HOST), List.of(), ""))); + } + + @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..04eef1eb52 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifierTest.java @@ -0,0 +1,170 @@ +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"; + + /** + * 区间外放足量同域正片:cleaner 有 35% 删除比例上限,只给一两片对照时 + * 任何规则都会因超限回退,「启用已有规则」的自检会正确拒掉。 + */ + private static AdIntervalEvidence evidence(String insideHost, List removedRuleIds) { + List outside = new java.util.ArrayList<>(); + for (int i = 0; i < 3; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = 4; i < 30; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, i == 4)); + } + 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)), + outside, + false, false, removedRuleIds, false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + /** 一条只命中给定切片域名、带作用域的可编译规则。 */ + static com.fongmi.android.tv.utils.HlsManifestCleaner.Rule compiledRule(String hostSuffix) { + return com.fongmi.android.tv.bean.HlsAdRule.createUserRule( + "id", "n", List.of(PLAYLIST_HOST), List.of(hostSuffix), List.of(), + null, null, false, true, 2).compile(); + } + + @Test + public void suggestsEnablingDisabledRuleInsteadOfCreatingNew() { + ExistingRuleClassifier.RuleState disabled = new ExistingRuleClassifier.RuleState( + "builtin:baofeng", "baofeng-preroll", "暴风片头", false, true, + List.of("ad-cdn.other.com"), compiledRule("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())); + } + + /** + * 既有规则过宽时不得建议启用。 + * + *

此前这条路径完全没有守卫 —— 只判断 hostSuffixes 与区间内某片同域, + * 不看规则的其余条件。一条 {@code minimumSignals=1} 且只限定 playlist 作用域的 + * 规则会命中该站每一片切片,启用后 cleaner 因全删而回退,并连带停掉 legacy。 + */ + @Test + public void doesNotSuggestEnablingAnOverbroadRule() { + com.fongmi.android.tv.utils.HlsManifestCleaner.Rule overbroad = + com.fongmi.android.tv.bean.HlsAdRule.createUserRule( + "wide", "wide", List.of(PLAYLIST_HOST), List.of("ad-cdn.other.com"), + List.of(), 0.0, 3600.0, false, false, 1).compile(); + ExistingRuleClassifier.RuleState wide = new ExistingRuleClassifier.RuleState( + "builtin:wide", "wide", "过宽规则", false, true, + List.of("ad-cdn.other.com"), overbroad); + + AdAttribution attribution = ExistingRuleClassifier.classify( + evidence("ad-cdn.other.com", List.of()), + new ExistingRuleClassifier.Input(List.of(wide), false, List.of())); + + // 要么整体弃权,要么只报诊断,绝不能给出 ENABLE_EXISTING_RULE + assertTrue(attribution == null + || attribution.remediation() != RemediationKind.ENABLE_EXISTING_RULE); + } + + /** 拿不到编译产物时无法验证,宁可不建议启用。 */ + @Test + public void doesNotSuggestEnablingWhenCompiledRuleIsUnavailable() { + ExistingRuleClassifier.RuleState noCompiled = new ExistingRuleClassifier.RuleState( + "builtin:x", "x", "无编译产物", false, true, List.of("ad-cdn.other.com")); + + AdAttribution attribution = ExistingRuleClassifier.classify( + evidence("ad-cdn.other.com", List.of()), + new ExistingRuleClassifier.Input(List.of(noCompiled), false, List.of())); + + assertTrue(attribution == null + || attribution.remediation() != RemediationKind.ENABLE_EXISTING_RULE); + } +} 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..e60840e169 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/GenRuleE2ETest.java @@ -0,0 +1,166 @@ +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 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()); + } + + /** + * 整站切片都在独立 CDN(api.site.com + cdn.site-x.com 这种常见拆分)。 + * 没有同域切片作对照时,任何域名条件都命中全站 → cleaner 因 + * removedCount == segmentCount 回退,而 HlsAdblockPipeline 的 fallback + * 会连带停掉 legacy 启发式,比不加规则更差。两个通道都必须弃权。 + */ + @Test + public void siteWideCdnMustNotProduceAnyRule() { + List inside = seg(AD_FROM, AD_COUNT, "cdn.site-x.com", "/seg/", 6.4, true); + List outside = new ArrayList<>( + seg(0, AD_FROM, "cdn.site-x.com", "/seg/", 8.0, false)); + outside.addAll(seg(AD_FROM + AD_COUNT, TOTAL - AD_FROM - AD_COUNT, + "cdn.site-x.com", "/seg/", 8.0, true)); + AdIntervalEvidence evidence = new AdIntervalEvidence( + 80_000, 100_000, StartOrigin.USER_MARKED, + "site", "站点", "剧", "线", "1", "api.site.com", "/play/index.m3u8", true, + inside, outside, true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + AdAttribution hls = HlsSegmentClassifier.classify(evidence); + if (hls != null) { + assertFalse("HLS 通道不得为全站 CDN 生成可落地规则", hls.actionable()); + } + assertNull("域名通道必须弃权", DomainReputationClassifier.classify(evidence, + new DomainReputationClassifier.Input( + List.of(), List.of("api.site.com"), List.of(), ""))); + } + + /** 用户框选覆盖整个 playlist 时无对照可用,同样不得生成规则。 */ + @Test + public void intervalCoveringWholePlaylistMustNotProduceAnyRule() { + AdIntervalEvidence evidence = new AdIntervalEvidence( + 0, 240_000, StartOrigin.USER_MARKED, + "site", "站点", "剧", "线", "1", "v.example.com", "/play/index.m3u8", true, + seg(0, TOTAL, "v.example.com", "/ads/", 8.0, true), List.of(), + true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + AdAttribution hls = HlsSegmentClassifier.classify(evidence); + if (hls != null) { + assertFalse("无对照组时不得生成可落地规则", hls.actionable()); + } + } +} 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..15969e0d33 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifierTest.java @@ -0,0 +1,242 @@ +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 positionSignalAloneCannotProduceARule() { + // 位置信号无法编码进 HlsAdRule,也没有区分度:单它成立时必须弃权 + AdIntervalEvidence evidence = evidence( + segments(0, 3, PLAYLIST_HOST, "/seg/", 8.0, false), + outsideAround(0, 3, 20, PLAYLIST_HOST, 8.0)); + + HlsSegmentClassifier.Signals signals = HlsSegmentClassifier.detect(evidence); + assertTrue(signals.headPosition()); + assertNull(HlsSegmentClassifier.classify(evidence)); + } + + @Test + public void rejectsCrossDomainWhenSameHostIsOnlySparseOutsideSample() { + List outside = new ArrayList<>(); + outside.add(new SegmentFact(0, PLAYLIST_HOST, "/seg/0.ts", 8.0, false)); + for (int i = 1; i < 300; i++) { + outside.add(new SegmentFact(i, "cdn.site-x.com", "/seg/" + i + ".ts", 8.0, false)); + } + AdIntervalEvidence evidence = evidence( + segments(100, 3, "cdn.site-x.com", "/seg/", 6.4, false), outside); + + assertFalse(HlsSegmentClassifier.detect(evidence).crossDomain()); + assertNull(HlsSegmentClassifier.classify(evidence)); + } + + @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()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathFragmentTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathFragmentTest.java new file mode 100644 index 0000000000..fee28d2987 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathFragmentTest.java @@ -0,0 +1,37 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.regex.Pattern; + +/** + * 路径正则必须同时排除 query 与 fragment。 + * + *

证据侧用 {@code URI.getPath()},query 和 fragment 都被丢弃,所以 + * {@code SegmentContrast.pathAbsentOutside} 与 {@code RuleSelfCheck} 都看不见它们。 + * 只挡 {@code ?} 时,正片 URL 形如 {@code /seg/99.ts#/ads/x} 会被跨 {@code #} 命中, + * 实测真实 cleaner 会多删一片正片且 {@code fallback=false},错误不被兜住。 + */ +public class PathFragmentTest { + + @Test + public void patternRejectsBothQueryAndFragment() { + Pattern p = Pattern.compile(HlsSegmentClassifier.pathOnlyPattern("/ads/")); + + assertTrue("真实广告目录段必须命中", p.matcher("https://v.example.com/ads/1.ts").find()); + + assertFalse("query 里的 hint 不得命中", + p.matcher("https://v.example.com/seg/1.ts?ref=/ads/").find()); + assertFalse("fragment 里的 hint 不得命中", + p.matcher("https://v.example.com/seg/99.ts#/ads/x").find()); + assertFalse("query + fragment 组合同样不得命中", + p.matcher("https://v.example.com/seg/9.ts?t=1#/ads/x").find()); + assertFalse("域名含 ads 不得命中", + p.matcher("https://ads.example.com/seg/1.ts").find()); + assertFalse("无斜杠包围不得命中", + p.matcher("https://v.example.com/upload/ads1.ts").find()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathOnlyPatternSafetyTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathOnlyPatternSafetyTest.java new file mode 100644 index 0000000000..4f4040e721 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/PathOnlyPatternSafetyTest.java @@ -0,0 +1,40 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import org.junit.Test; + +import java.util.List; +import java.util.regex.Pattern; + +/** pathOnlyPattern 必须通过 HlsManifestCleaner 的正则安全检查并只匹配 path。 */ +public class PathOnlyPatternSafetyTest { + + @Test + public void patternCompilesInsideCleanerRule() { + // looksDangerous 拒绝 .*.* / .+.+ / 括号量词嵌套;^[^?]*\Q..\E 都不满足 + HlsManifestCleaner.Rule rule = HlsManifestCleaner.Rule.builder() + .playlistHostSuffixes(List.of("v.example.com")) + .segmentUrlPatterns(List.of(HlsSegmentClassifier.pathOnlyPattern("/ads/"))) + .minimumSignals(1) + .build(); + // build() 会预编译正则,未抛异常即通过安全检查 + org.junit.Assert.assertNotNull(rule); + } + + @Test + public void matchesPathButNotQueryOrHost() { + Pattern p = Pattern.compile(HlsSegmentClassifier.pathOnlyPattern("/ads/")); + assertTrue(p.matcher("https://v.example.com/ads/1.ts").find()); + // query 里的 hint 不得命中 —— 这是校验与执行语义一致的关键 + assertFalse(p.matcher("https://v.example.com/seg/1.ts?ref=/ads/").find()); + // 域名含 ads 不命中 + assertFalse(p.matcher("https://ads.example.com/seg/1.ts").find()); + // 无斜杠包围不命中 + assertFalse(p.matcher("https://v.example.com/upload/ads1.ts").find()); + assertFalse(p.matcher("https://v.example.com/downloads/1.ts").find()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/QueryHintMismatchTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/QueryHintMismatchTest.java new file mode 100644 index 0000000000..3e99d73363 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/QueryHintMismatchTest.java @@ -0,0 +1,68 @@ +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; + +/** + * pathPatternsOf 的双向校验用 fact.path()(已去 query),而 + * HlsManifestCleaner.matchesPattern 匹配 resolved.toString()(含 query)。 + * 正片 query 里含 hint 时,校验放行但运行时会被删。 + */ +public class QueryHintMismatchTest { + + private static final String BASE = "https://v.example.com/play/index.m3u8"; + + @Test public void legitSegmentWithHintInQueryMustNotBeDeleted() { + // 区间内 3 片是真广告(路径含 /ads/),区间外正片路径干净但 query 含 /ads/ + List inside = new ArrayList<>(); + for (int i = 10; i < 13; i++) { + inside.add(new SegmentFact(i, "v.example.com", "/ads/" + i + ".ts", 8.0, i == 10)); + } + List outside = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + // path 不含 hint —— 双向校验会认为 outside 干净 + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = 13; i < 30; i++) { + outside.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, i == 13)); + } + AdIntervalEvidence ev = new AdIntervalEvidence( + 80_000, 104_000, StartOrigin.USER_MARKED, + "site", "站点", "剧", "线", "1", "v.example.com", "/play/index.m3u8", true, + inside, outside, true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + AdAttribution plan = HlsSegmentClassifier.classify(ev); + assertNotNull("同域 + 路径特征应产出方案", plan); + System.out.println("payload.regex=" + plan.payload().regex()); + + // 真实 manifest:正片带 ?ref=/ads/ 的 query + StringBuilder m = new StringBuilder("#EXTM3U\n"); + for (int i = 0; i < 30; i++) { + boolean ad = i >= 10 && i < 13; + if (i == 10 || i == 13) m.append("#EXT-X-DISCONTINUITY\n"); + m.append("#EXTINF:8.0,\n"); + m.append(ad ? "https://v.example.com/ads/" + i + ".ts\n" + : "https://v.example.com/seg/" + i + ".ts?ref=/ads/\n"); + } + m.append("#EXT-X-ENDLIST\n"); + + RulePayload p = plan.payload(); + HlsAdRule rule = 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()); + HlsManifestCleaner.Result res = HlsManifestCleaner.clean(BASE, m.toString(), List.of(rule.compile())); + System.out.println("removed=" + res.removedSegments() + " fallback=" + res.fallback()); + assertEquals("只能删 3 片广告,不得误删 query 含 hint 的正片", 3, 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 new file mode 100644 index 0000000000..8b1c227798 --- /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(), + 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()); + } +} 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..ea68f05aee --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java @@ -0,0 +1,215 @@ +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; + +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()); + } + + /** + * 区间外对照切片。数量必须足够 —— cleaner 有 35% 删除比例上限, + * 区间外只放两三片时任何规则都会因超限而回退,规则自检会正确地拒掉它。 + */ + private static List healthyOutside(int insideFrom, int insideCount) { + List facts = new java.util.ArrayList<>(); + for (int i = 0; i < insideFrom; i++) { + facts.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = insideFrom + insideCount; i < 30; i++) { + facts.add(new SegmentFact(i, "v.example.com", "/seg/" + i + ".ts", 8.0, + i == insideFrom + insideCount)); + } + return facts; + } + + @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 = healthyOutside(10, 2); + 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()); + // requireDiscontinuity 只对广告块首片成立,编码进规则会导致只删首片、 + // 留下其余广告,因此刻意不编码 —— 断点只在归因阶段用于定位区间 + assertFalse(payload.requireDiscontinuity()); + assertTrue(payload.requireCrossDomain()); + // 跨域 + 域名 + 时长离群三个信号,门限取 2 + assertEquals(2, payload.minimumSignals()); + 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 = healthyOutside(10, 2); + 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 pathHintPresentOutsideTheIntervalIsNotADistinguishingCondition() { + // 站内正片路径也含 /ads/ 时,该特征无区分度:规则按切片独立匹配, + // 没有「只在这段区间内生效」的概念,编码它会连正片一起删。 + List inside = List.of( + new SegmentFact(10, "v.example.com", "/ads/10.ts", 8.0, true), + new SegmentFact(11, "v.example.com", "/ads/11.ts", 8.0, false)); + List outside = List.of( + new SegmentFact(0, "v.example.com", "/ads/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, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + // 同域 + 时长一致 + 路径无区分度 → 无任何可编码条件,必须弃权 + assertNull(HlsSegmentClassifier.classify(evidence)); + } + + @Test + public void pathHintAppearingInLaterOutsideSegmentIsRejected() { + List inside = List.of( + new SegmentFact(10, "v.example.com", "/ads/10.ts", 8.0, false), + new SegmentFact(11, "v.example.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", "/ads/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, false, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + + // 后半段的正常切片含 hint,完整 outside 检查后不能生成路径规则 + assertFalse(HlsSegmentClassifier.detect(evidence).pathHint()); + assertFalse(HlsSegmentClassifier.classify(evidence) != null + && !HlsSegmentClassifier.classify(evidence).payload().regex().isEmpty()); + } + + @Test + public void existingRuleAttributionCarriesRuleKey() { + ExistingRuleClassifier.RuleState disabled = new ExistingRuleClassifier.RuleState( + "builtin|pkg|baofeng", "baofeng", "暴风片头", false, true, + List.of("ad-cdn.other.com"), + ExistingRuleClassifierTest.compiledRule("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)), + healthyOutside(3, 1), + 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()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckAliasTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckAliasTest.java new file mode 100644 index 0000000000..70fe7b7e8a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckAliasTest.java @@ -0,0 +1,187 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * 第六轮评审用随机对抗找出的假阳性:{@code cleaned.contains(uri)} 是子串查找, + * 被删片的 URI 若是某个保留片 URI 的子串,判据就恒为真。 + * + *

配合 {@code removedSegments() == inside.size()} 只比总数, + * 「漏删一片区间内 + 误删一片区间外」会互相抵消而放行。 + */ +public class RuleSelfCheckAliasTest { + + private static final String PLAYLIST_HOST = "v.example.com"; + + private static AdIntervalEvidence evidence(List inside, List outside) { + return new AdIntervalEvidence( + 160_000, 190_000, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + inside, outside, true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + /** + * 无扩展名的递增序号切片:删掉 {@code /s/1} 后,保留的 {@code /s/12} + * 让子串判据看不见这次误删。载荷同时命中区间外那片(duration + crossDomain)。 + */ + @Test + public void rejectsWhenRemovedOutsideUriIsSubstringOfKeptOne() { + List inside = new ArrayList<>(); + for (int i = 20; i <= 22; i++) { + inside.add(new SegmentFact(i, "ad.other.com", "/ads/" + (i - 19), 6.4, i == 20)); + } + // 用户多框了一片同域正片,制造「漏删」以抵消下面的「误删」 + inside.add(new SegmentFact(23, PLAYLIST_HOST, "/s/23", 8.0, false)); + + List outside = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/s/" + (100 + i), 8.0, false)); + } + // 会被 duration+crossDomain 命中的正片,其 URI 是下一片的前缀 + outside.add(new SegmentFact(24, "cdn2.net", "/s/1", 6.4, false)); + outside.add(new SegmentFact(25, "cdn2.net", "/s/12", 8.0, false)); + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad.other.com"), List.of(), + 6.3, 6.5, false, true, 2); + + assertFalse("区间外正片被误删时必须拒绝,不能因子串遮蔽而放行", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } + + /** + * 同一 URI 同时出现在区间内与区间外时,两道判据会互相抵消。 + * + *

规则漏删区间内那片、却删掉区间外那片:URI 次数比对因两者同名而仍然相等, + * {@code removedSegments() == inside.size()} 也仍然成立,于是放行 —— 真实运行 + * 删正片且 {@code fallback=false},错误不被回退兜住。真实 URL 靠 query 区分 + * ({@code /hls/seg.ts?i=10} 与 {@code ?i=25}),而证据按去敏要求丢掉 query, + * 合成 manifest 无法区分,只能弃权。 + */ + @Test + public void abstainsWhenTheSameUriAppearsInsideAndOutside() { + // 区间内 1 片广告,URI 为 X,时长 8.0(只满足 host 一个信号,会被漏删) + List inside = List.of( + new SegmentFact(12, "ad.other.com", "/ads/x.ts", 8.0, true)); + + List outside = new ArrayList<>(); + // 区间外同一个 URI X,时长 6.4(host + duration 两个信号,会被误删) + outside.add(new SegmentFact(5, "ad.other.com", "/ads/x.ts", 6.4, false)); + for (int i = 0; i < 10; i++) { + outside.add(new SegmentFact(20 + i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, false)); + } + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad.other.com"), List.of(), + 6.3, 6.5, false, false, 2); + + assertFalse("删反了(留区间内、删区间外)却因 URI 重名使判据抵消,必须拒绝", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } + + /** 代理式内嵌 URL 的 path 会原样保留,同样能遮蔽真实删除。 */ + @Test + public void rejectsWhenProxyStylePathShadowsRemovedSegment() { + List inside = List.of( + new SegmentFact(2, "ad.other.com", "/seg/1.ts", 8.0, true), + new SegmentFact(3, "edge7." + PLAYLIST_HOST, "/ads/1.ts", 8.0, false)); + List outside = List.of( + new SegmentFact(0, "ad.other.com", "/a.ts", 8.0, false), + new SegmentFact(1, "edge7." + PLAYLIST_HOST, "/x/ads/1.ts", 6.4, false), + // 内嵌完整 URL:pathOf 会原样返回,成为上面 index=0 的遮蔽者 + new SegmentFact(4, "edge7." + PLAYLIST_HOST, + "/p/https://ad.other.com/a.ts", 10.0, false), + new SegmentFact(5, "shared-cdn.net", "/seg/1.ts", 10.0, false)); + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad.other.com"), List.of(), + Double.NaN, Double.NaN, false, true, 2); + + assertFalse("内嵌 URL 的 path 不得遮蔽区间外的误删", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } + + /** + * 同一 URI 在 playlist 中复用(片头片尾同一 promo)不得造成误判。 + * + *

现在由 {@code hasUriOverlap} 前置拦下,不再走到 URI 次数比对。保留这条 + * 测试是因为它钉的是**结果**(必须拒绝),而拒绝的理由换成更早的判据仍然正确; + * 次数比对本身由 {@link #rejectsWhenRemovedOutsideUriIsSubstringOfKeptOne} + * 等区间外不重名的用例继续覆盖。 + */ + @Test + public void rejectsWhenDuplicateUriAppearsInsideAndOutside() { + List inside = List.of( + new SegmentFact(10, "ad.other.com", "/promo.ts", 6.4, true), + new SegmentFact(11, "ad.other.com", "/promo.ts", 6.4, false)); + List outside = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/s/" + i + ".ts", 8.0, false)); + } + // 片尾复用同一个 promo 切片:它会被同一条规则删掉 + outside.add(new SegmentFact(29, "ad.other.com", "/promo.ts", 6.4, false)); + for (int i = 12; i < 29; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/s/" + i + ".ts", 8.0, i == 12)); + } + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad.other.com"), List.of(), + Double.NaN, Double.NaN, false, true, 2); + + assertFalse("区间外同 URI 的切片会被一并删除,必须拒绝", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } + + /** 规模预判:真实 manifest 行密度更高,接近上限时必须提前拒绝。 */ + @Test + public void rejectsWhenRealManifestWouldExceedLineLimit() + { + List inside = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + inside.add(new SegmentFact(i, "ad.other.com", "/ads/" + i + ".ts", 6.4, i == 0)); + } + List outside = new ArrayList<>(); + // 合成 2 行/片时不超 20000,真实 3 行/片时会超 + for (int i = 3; i < 7_000; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/s/" + i + ".ts", 8.0, i == 3)); + } + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad.other.com"), List.of(), + Double.NaN, Double.NaN, false, true, 2); + + assertFalse("真实 manifest 会因行数超限回退,规则不该保存", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } + + /** 健康场景仍须放行,否则自检等于把功能关掉。 */ + @Test + public void stillAcceptsCleanlySeparableAdBlock() { + List inside = new ArrayList<>(); + for (int i = 10; i < 13; i++) { + inside.add(new SegmentFact(i, "ad-cdn.other.com", "/ads/" + i + ".ts", 6.4, i == 10)); + } + List outside = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, false)); + } + for (int i = 13; i < 30; i++) { + outside.add(new SegmentFact(i, PLAYLIST_HOST, "/seg/" + i + ".ts", 8.0, i == 13)); + } + + RulePayload payload = RulePayload.ofHlsRule( + List.of(PLAYLIST_HOST), List.of("ad-cdn.other.com"), List.of(), + Double.NaN, Double.NaN, false, true, 2); + + assertTrue("干净可分的广告块必须放行", + RuleSelfCheck.isSafe(evidence(inside, outside), payload)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckTest.java new file mode 100644 index 0000000000..46698e7763 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleSelfCheckTest.java @@ -0,0 +1,131 @@ +package com.fongmi.android.tv.ad.feedback; + +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.ArrayList; +import java.util.List; + +/** + * 规则自检的边界回归:第五轮评审实测出的五类有害规则,必须全部被拒。 + * + *

这些场景的共同点是「逐条件的对照校验都通过,但规则的实际效果有害」—— + * 正是 {@link RuleSelfCheck} 存在的理由。 + */ +public class RuleSelfCheckTest { + + private static final String PLAYLIST_HOST = "v.example.com"; + + private static AdIntervalEvidence evidence(List inside, List outside) { + long start = inside.isEmpty() ? 0 : Math.round(inside.get(0).index() * 8_000d); + double span = inside.stream().mapToDouble(SegmentFact::durationSec).sum(); + return new AdIntervalEvidence( + start, start + Math.round(span * 1000d), StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + inside, outside, true, false, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + private static List seg(int from, int count, String host, String path, + double duration, boolean discFirst) { + List facts = new ArrayList<>(); + for (int i = 0; i < count; i++) { + facts.add(new SegmentFact(from + i, host, path + (from + i) + ".ts", + duration, discFirst && i == 0)); + } + return facts; + } + + /** 正片切片同域、时长与广告一致:duration + crossDomain 组合会误删它们。 */ + @Test + public void rejectsWhenOutsideSegmentsFallInsideTheDurationWindow() { + List inside = seg(10, 3, "ad-cdn.other.com", "/seg/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + // 边缘节点上的正片,时长恰好落在广告的 ±0.1s 窗口内 + outside.addAll(seg(13, 5, "legit-edge.fastly-cdn.net", "/seg/", 6.4, false)); + outside.addAll(seg(18, 12, PLAYLIST_HOST, "/seg/", 8.0, false)); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence(inside, outside)); + if (plan != null) assertFalse(plan.actionable()); + } + + /** 广告与正片共用同一第三方 CDN,只有路径不同:host 条件会误删正片。 */ + @Test + public void rejectsWhenAdAndContentShareTheSameCdn() { + List inside = seg(10, 3, "shared-cdn.net", "/ads/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + outside.addAll(seg(13, 5, "shared-cdn.net", "/seg/", 8.0, false)); + outside.addAll(seg(18, 12, PLAYLIST_HOST, "/seg/", 8.0, false)); + + AdIntervalEvidence ev = evidence(inside, outside); + AdAttribution hls = HlsSegmentClassifier.classify(ev); + if (hls != null) assertFalse(hls.actionable()); + assertNull(DomainReputationClassifier.classify(ev, + new DomainReputationClassifier.Input( + List.of(), List.of(PLAYLIST_HOST), List.of(), ""))); + } + + /** 删除比例超过 cleaner 的 35% 上限:规则注定失效,不该保存。 */ + @Test + public void rejectsWhenRemovalRatioExceedsCleanerLimit() { + List inside = seg(0, 19, "ad-cdn.other.com", "/ads/", 6.4, true); + List outside = seg(19, 1, PLAYLIST_HOST, "/seg/", 8.0, false); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence(inside, outside)); + if (plan != null) assertFalse(plan.actionable()); + } + + /** 删除总时长超过 90s:即使比例只有 20%,cleaner 仍会整体回退。 */ + @Test + public void rejectsWhenRemovedDurationExceedsCleanerLimit() { + List inside = seg(10, 20, "ad-cdn.other.com", "/ads/", 6.0, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + outside.addAll(seg(30, 70, PLAYLIST_HOST, "/seg/", 8.0, false)); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence(inside, outside)); + if (plan != null) assertFalse(plan.actionable()); + } + + /** + * 正片在 playlist 的子域上,且时长落入广告的 duration 窗口。 + * + *

分类器的 {@code hostEndsWith} 视子域为同域(计入对照分母帮助放行), + * 而 cleaner 的 {@code requireCrossDomain} 用 {@code !equals} 判定,视其为跨域 + * 并给 1 分;再叠加 duration 的 1 分就够 2 分被删。两侧对「同域」的定义相反。 + * + *

区间外多数为同域 8.0s 正片,使 duration 众数为 8.0 从而让时长离群成立、 + * 时长条件被编码进规则 —— 这是该场景成立的前提。 + */ + @Test + public void rejectsWhenContentSitsOnPlaylistSubdomainInsideDurationWindow() { + List inside = seg(10, 3, "ad-cdn.other.com", "/seg/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + outside.addAll(seg(13, 12, PLAYLIST_HOST, "/seg/", 8.0, false)); + // 子域上的正片,时长与广告相同 + outside.addAll(seg(25, 5, "edge7." + PLAYLIST_HOST, "/seg/", 6.4, false)); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence(inside, outside)); + if (plan != null) assertFalse(plan.actionable()); + } + + /** 健康场景必须放行,否则自检等于把功能关掉。 */ + @Test + public void acceptsCleanlySeparableAdBlock() { + List inside = seg(10, 3, "ad-cdn.other.com", "/ads/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + outside.addAll(seg(13, 17, PLAYLIST_HOST, "/seg/", 8.0, true)); + + AdAttribution plan = HlsSegmentClassifier.classify(evidence(inside, outside)); + assertTrue("干净可分的广告块必须能产出可落地规则", + plan != null && plan.actionable()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleStackingTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleStackingTest.java new file mode 100644 index 0000000000..cdbcca2f3a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RuleStackingTest.java @@ -0,0 +1,170 @@ +package com.fongmi.android.tv.ad.feedback; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +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; + +/** + * 叠加校验:一条单独安全的规则,与站内已启用规则合并后不得越过 cleaner 的 + * 回退闸门。 + * + *

{@code HlsManifestCleaner.matches} 是「任一规则命中即删」,而三道闸门 + * (全删 / 删除比例 > 35% / 删除时长 > 90s)作用于合并后的删除总量。越过闸门 + * 会让整份 manifest 回退 —— 不只新规则无效,连原有的结构化净化也一起丢掉。 + */ +public class RuleStackingTest { + + private static final String PLAYLIST_HOST = "v.example.com"; + + private static List seg(int from, int count, String host, String path, + double duration, boolean discFirst) { + List facts = new ArrayList<>(); + for (int i = 0; i < count; i++) { + facts.add(new SegmentFact(from + i, host, path + (from + i) + ".ts", + duration, discFirst && i == 0)); + } + return facts; + } + + private static AdIntervalEvidence evidence(List inside, List outside) { + return new AdIntervalEvidence( + 80_000, 99_200, StartOrigin.USER_MARKED, + "site", "站点", "剧名", "线路", "第 1 集", + PLAYLIST_HOST, "/play/index.m3u8", true, + inside, outside, true, true, List.of(), false, List.of(), + AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable()); + } + + private static HlsManifestCleaner.Rule rule(String hostSuffix, int minimumSignals) { + return HlsAdRule.createUserRule("id-" + hostSuffix, "n", + List.of(PLAYLIST_HOST), List.of(hostSuffix), List.of(), + null, null, false, true, minimumSignals).compile(); + } + + /** + * 30 片:0-9 正片、10-12 广告 A、13-21 由已启用规则负责的广告 B、22-29 正片。 + * + *

广告 B 的时长刻意不同于广告 A:分类器生成的规则会带上 + * {@code duration 6.3–6.5} 条件,若两者时长相同,「时长 + 跨域」就凑够 + * minimumSignals=2,新规则**自己**就删掉了区间外的广告 B —— 单独校验先失败, + * 测不到叠加逻辑。这个混淆由 {@link #abstainsWhenDurationWindowAlsoMatchesOutside} + * 单独覆盖。 + */ + private static AdIntervalEvidence stackedEvidence() { + List inside = seg(10, 3, "ad-a.other.com", "/ads/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + // 已启用规则会删掉这 9 片 + outside.addAll(seg(13, 9, "ad-b.other.com", "/adb/", 4.0, true)); + outside.addAll(seg(22, 8, PLAYLIST_HOST, "/seg/", 8.0, true)); + return evidence(inside, outside); + } + + @Test + public void rejectsNewRuleWhenCombinedRemovalCrossesRatioGate() { + // 单独删 3/30 = 10%,与已启用规则的 9 片合并后 12/30 = 40% > 35% + AdIntervalEvidence evidence = stackedEvidence(); + HlsManifestCleaner.Rule active = rule("ad-b.other.com", 2); + + assertTrue("新规则单独是安全的", + RuleSelfCheck.isSafe(evidence, rule("ad-a.other.com", 2), List.of())); + assertFalse("与已启用规则叠加后越过 35% 闸门,必须拒绝", + RuleSelfCheck.isSafe(evidence, rule("ad-a.other.com", 2), List.of(active))); + } + + @Test + public void acceptsNewRuleWhenCombinedRemovalStaysUnderGate() { + // 已启用规则只负责 2 片,合并后 5/30 = 16.7% + List inside = seg(10, 3, "ad-a.other.com", "/ads/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + outside.addAll(seg(13, 2, "ad-b.other.com", "/adb/", 6.4, true)); + outside.addAll(seg(15, 15, PLAYLIST_HOST, "/seg/", 8.0, true)); + AdIntervalEvidence evidence = evidence(inside, outside); + + assertTrue("合并后仍在闸门内,应放行", + RuleSelfCheck.isSafe(evidence, rule("ad-a.other.com", 2), + List.of(rule("ad-b.other.com", 2)))); + } + + /** + * 已启用规则**自己**就让 manifest 回退时,依然拒绝。 + * + *

曾经在这里放行,理由是「那时生产上已整体不净化、没有原有净化需要保护」。 + * 那是错的:判据只对证据里这一份 manifest 成立,而规则一旦保存就对该站所有 + * 剧集长期生效 —— 同一站点另一集里已启用规则可能并未越界,叠加新规则后越界 + * 回退,原本正常工作的删除全丢。更根本的是,合并后回退时新规则在这份 manifest + * 上删 0 片,对它是否有效没有任何证据,谈不上背书。 + */ + @Test + public void rejectsEvenWhenActiveRulesThemselvesAlreadyCauseFallback() { + AdIntervalEvidence evidence = stackedEvidence(); + // 一条命中全站同域切片的已启用规则:18/30 越过 35% 闸门,单独就会回退 + HlsManifestCleaner.Rule overbroad = HlsAdRule.createUserRule( + "wide", "wide", List.of(PLAYLIST_HOST), List.of(PLAYLIST_HOST), + List.of(), null, null, false, false, 1).compile(); + + assertFalse("合并后回退即拒绝,不追究是谁造成的", + RuleSelfCheck.isSafe(evidence, rule("ad-a.other.com", 2), List.of(overbroad))); + } + + /** + * {@code activeRules} 为 {@code null} 表示「无法确定站内有哪些规则生效」, + * 必须拒绝 —— 与空列表(确无规则生效)语义相反。 + * + *

数据源在遇到模拟不了的已启用规则、或读配置失败时返回 null。若把它当成 + * 空列表处理,叠加校验就被整体跳过,是 fail-open。 + */ + @Test + public void rejectsWhenActiveRulesAreUnknown() { + assertFalse("规则集未知时不能为新规则背书", + RuleSelfCheck.isSafe(stackedEvidence(), rule("ad-a.other.com", 2), null)); + } + + /** + * 分类器生成的时长窗口会把区间外同时长的跨域切片一起删掉时,必须弃权。 + * + *

这不是叠加问题、也不钉本次改动 —— 单独一条规则就已经越界: + * {@code duration 6.3–6.5} 与 {@code requireCrossDomain} 凑够 minimumSignals=2, + * 另一段广告(本应交给别的规则或保持原样)落进同一窗口就被误删。刻意传 + * {@code List.of()} 并让删除比例停在闸门内(5/30),从而钉住判据来自 + * {@code isSafeAlone} 的区间外 URI 次数比对,而不是回退闸门。 + * + *

留在这个文件里是因为它解释了 {@link #stackedEvidence} 为何要把广告 B 的 + * 时长与广告 A 错开 —— 否则叠加用例会先被这条判据拦掉,测不到闸门。 + */ + @Test + public void abstainsWhenDurationWindowAlsoMatchesOutside() { + List inside = seg(10, 3, "ad-a.other.com", "/ads/", 6.4, true); + List outside = new ArrayList<>( + seg(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false)); + // 与区间内同时长、同为跨域,但域名与路径都不同 + outside.addAll(seg(13, 2, "ad-b.other.com", "/adb/", 6.4, true)); + outside.addAll(seg(15, 15, PLAYLIST_HOST, "/seg/", 8.0, true)); + + assertNull("时长窗口会连带删除区间外切片,必须弃权", + HlsSegmentClassifier.classify(evidence(inside, outside), List.of())); + } + + /** 端到端:分类器在叠加会越界时整体弃权。 */ + @Test + public void classifierAbstainsWhenStackingCrossesGate() { + AdIntervalEvidence evidence = stackedEvidence(); + List active = List.of(rule("ad-b.other.com", 2)); + + AdAttribution alone = HlsSegmentClassifier.classify(evidence, List.of()); + AdAttribution stacked = HlsSegmentClassifier.classify(evidence, active); + + assertTrue("无叠加时应能产出方案", alone != null && alone.actionable()); + assertTrue("叠加越界时必须弃权或不可落地", + stacked == null || !stacked.actionable()); + } +} 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()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/api/CatSourceConfigTest.java b/app/src/test/java/com/fongmi/android/tv/api/CatSourceConfigTest.java index dea859fe80..1aa65d86c5 100644 --- a/app/src/test/java/com/fongmi/android/tv/api/CatSourceConfigTest.java +++ b/app/src/test/java/com/fongmi/android/tv/api/CatSourceConfigTest.java @@ -4,8 +4,6 @@ import com.github.catvod.utils.Json; import org.junit.Test; -import java.util.Locale; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -55,18 +53,6 @@ public class CatSourceConfigTest { CatSource.isConfig("{\"sites\":[{\"key\":\"csp_Demo\"}]}")); } - @Test - public void bundleSuffixDetectionIsLocaleIndependent() { - Locale previous = Locale.getDefault(); - try { - Locale.setDefault(new Locale("tr", "TR")); - assertTrue("bundle 地址判定不能受默认 Locale 影响", - CatSource.isBundle("HTTPS://HOST/INDEX.JS")); - } finally { - Locale.setDefault(previous); - } - } - @Test public void rejectsEmptyFlatArray() { assertFalse("空数组说明这个端口没在服务站点,应继续探下一个", CatSource.isConfig("[]")); diff --git a/app/src/test/java/com/fongmi/android/tv/api/DanmakuApiSourceTest.java b/app/src/test/java/com/fongmi/android/tv/api/DanmakuApiSourceTest.java index df9e4cff77..86556eb0ac 100644 --- a/app/src/test/java/com/fongmi/android/tv/api/DanmakuApiSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/api/DanmakuApiSourceTest.java @@ -26,21 +26,6 @@ public class DanmakuApiSourceTest { aiFallback > cleaned); } - @Test - public void manualKeywordPrecedesResolverAndSiteMemoryPrecedesTmdbMemory() throws Exception { - Path sourcePath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "api", "DanmakuApi.java")); - String source = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8); - int method = source.indexOf("public static void search(MediaTitleRequest request"); - int manual = source.indexOf("manualSearchTitles(request, cache)", method); - int titles = source.indexOf("resolver.queryTitles(request, 3)", method); - int siteTitle = source.indexOf("cache.findSeriesSearchTitle(request.getSiteKey(), request.getVodId())", method); - int tmdbTitle = source.indexOf("cache.findTmdbSeasonSearchTitle(request.getTmdbId(), request.getTmdbSeasonNumber())", method); - - assertTrue("danmaku auto search must read manual memory", manual >= 0); - assertTrue("site-level manual keyword must precede TMDB memory", siteTitle >= 0 && tmdbTitle > siteTitle); - assertTrue("manual keyword must precede resolver titles", manual < titles); - } - private static Path findMainJavaPath() { Path moduleRelative = Path.of("src", "main", "java"); if (Files.exists(moduleRelative)) return moduleRelative; diff --git a/app/src/test/java/com/fongmi/android/tv/api/SiteApiT3TmdbDetailTest.java b/app/src/test/java/com/fongmi/android/tv/api/SiteApiT3TmdbDetailTest.java deleted file mode 100644 index 987192bed6..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/SiteApiT3TmdbDetailTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.fongmi.android.tv.api; - -import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.utils.VodDetailCache; -import com.google.gson.Gson; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -public class SiteApiT3TmdbDetailTest { - - private static final Gson GSON = new Gson(); - private static final String JSON = """ - {"list":[{"vod_id":"t3-movie-1","vod_name":"T3 Example","vod_play_from":"line-a", - "vod_play_url":"正片$https://media.test/movie.m3u8", - "tmdb":{"schema":1,"id":550,"media_type":"movie","season_number":0,"complete":["core","credits"], - "detail":{"id":550,"title":"Fight Club","overview":"Overview","poster_path":"/poster.jpg"}}}]} - """; - - @Test - public void type3DetailJsonKeepsTmdbContract() { - Result result = GSON.fromJson(JSON, Result.class); - TmdbSourcePayload payload = result.getVod().getTmdb(); - - assertNotNull(payload); - assertEquals(550, payload.getTmdbId()); - assertEquals("movie", payload.getMediaType()); - assertEquals("Fight Club", payload.getDetail().title()); - assertTrue(payload.hasCapability("core")); - assertTrue(payload.hasCapability("credits")); - } - - @Test - public void type3DetailJsonKeepsTmdbAcrossDetailCacheRoundTrip() { - Result parsed = GSON.fromJson(JSON, Result.class); - String source = "t3-cache-" + System.nanoTime(); - String id = "t3-movie-1"; - - VodDetailCache.putContent(source, id, GSON.toJson(parsed)); - Result restored = GSON.fromJson(VodDetailCache.getContent(source, id), Result.class); - - assertNotNull(restored.getVod().getTmdb()); - assertEquals(550, restored.getVod().getTmdb().getTmdbId()); - assertEquals("Fight Club", restored.getVod().getTmdb().getDetail().title()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/SiteApiT4TmdbDetailTest.java b/app/src/test/java/com/fongmi/android/tv/api/SiteApiT4TmdbDetailTest.java deleted file mode 100644 index 84efed7e2e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/SiteApiT4TmdbDetailTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.fongmi.android.tv.api; - -import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.utils.VodDetailCache; -import com.google.gson.Gson; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -public class SiteApiT4TmdbDetailTest { - - private static final Gson GSON = new Gson(); - private static final String JSON = """ - {"code":0,"msg":"ok","list":[{"vod_id":"t4-tv-1","vod_name":"T4 Example", - "vod_play_from":"line-a","vod_play_url":"S01E01$https://media.test/episode1.m3u8", - "tmdb":{"schema":1,"id":1399,"media_type":"tv","season_number":1,"language":"zh-CN", - "complete":["core","images","season:1"],"detail":{"id":1399,"season_number":1, - "name":"Example Show","overview":"Overview","poster_path":"/poster.jpg", - "seasons":[{"season_number":1,"episode_count":1,"episodes":[{"episode_number":1,"name":"Pilot"}]}]}}}]} - """; - - @Test - public void type4DetailJsonKeepsTmdbContract() { - Result result = GSON.fromJson(JSON, Result.class); - TmdbSourcePayload payload = result.getVod().getTmdb(); - - assertNotNull(payload); - assertEquals(1399, payload.getTmdbId()); - assertEquals("tv", payload.getMediaType()); - assertEquals(1, payload.getSeasonNumber()); - assertEquals("Example Show", payload.getDetail().name()); - assertTrue(payload.hasCapability("season:1")); - assertEquals(1, payload.getDetail().seasons().size()); - } - - @Test - public void type4DetailJsonKeepsTmdbAcrossDetailCacheRoundTrip() { - Result parsed = GSON.fromJson(JSON, Result.class); - String source = "t4-cache-" + System.nanoTime(); - String id = "t4-tv-1"; - - VodDetailCache.putContent(source, id, GSON.toJson(parsed)); - Result restored = GSON.fromJson(VodDetailCache.getContent(source, id), Result.class); - - assertNotNull(restored.getVod().getTmdb()); - assertEquals(1399, restored.getVod().getTmdb().getTmdbId()); - assertEquals(1, restored.getVod().getTmdb().getDetail().seasons().size()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/TmdbSourceCredentialContractTest.java b/app/src/test/java/com/fongmi/android/tv/api/TmdbSourceCredentialContractTest.java deleted file mode 100644 index 0f6cba5ad8..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/TmdbSourceCredentialContractTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.fongmi.android.tv.api; - -import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.TmdbSourcePayload; -import com.fongmi.android.tv.utils.VodDetailCache; -import com.google.gson.Gson; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; - -public class TmdbSourceCredentialContractTest { - - private static final String KEY = "0123456789abcdef0123456789abcdef"; - private static final Gson GSON = new Gson(); - private static final String TMDB = """ - "tmdb":{"schema":1,"id":1399,"media_type":"tv","season_number":1,"complete":["core","credits","images"], - "detail":{"id":1399,"name":"Example","overview":"Overview","first_air_date":"2024-01-01","vote_average":8, - "number_of_seasons":1,"number_of_episodes":1,"genres":[],"credits":{"cast":[{"id":1,"name":"Actor"}]}, - "images":{"posters":[{"file_path":"/p.jpg"}],"backdrops":[{"file_path":"/b.jpg"}]}}} - """; - - @Test - public void legacyDetailRootCredentialIsStrippedForType3AndType4() { - Result t3 = parse("{\"tmdb_api_key\":\"" + KEY + "\",\"list\":[{\"vod_id\":\"t3\",\"vod_name\":\"T3\"," + TMDB + "}]}"); - Result t4 = parse("{\"code\":0,\"msg\":\"ok\",\"tmdb_api_key\":\"" + KEY + "\",\"list\":[{\"vod_id\":\"t4\",\"vod_name\":\"T4\"," + TMDB + "}]}"); - - assertEquals(t3.getVod().getTmdb().getTmdbId(), t4.getVod().getTmdb().getTmdbId()); - assertEquals(t3.getVod().getTmdb().getMediaType(), t4.getVod().getTmdb().getMediaType()); - assertEquals(t3.getVod().getTmdb().getDetailJson(), t4.getVod().getTmdb().getDetailJson()); - assertFalse(GSON.toJson(t3).contains(KEY)); - assertFalse(GSON.toJson(t4).contains(KEY)); - } - - @Test - public void sanitizedT3AndT4PayloadsAreSafeForDetailCache() { - String t3Raw = "{\"tmdb_api_key\":\"" + KEY + "\",\"list\":[{\"vod_id\":\"t3-cache\",\"vod_name\":\"T3\"," + TMDB + "}]}"; - String t4Raw = "{\"code\":0,\"tmdb_api_key\":\"" + KEY + "\",\"list\":[{\"vod_id\":\"t4-cache\",\"vod_name\":\"T4\"," + TMDB + "}]}"; - - cacheRoundTrip("c16-t3-" + System.nanoTime(), "t3-cache", t3Raw); - cacheRoundTrip("c16-t4-" + System.nanoTime(), "t4-cache", t4Raw); - } - - @Test - public void credentialFieldIsAbsentFromPersistedDetailModels() throws Exception { - for (String path : new String[]{ - "src/main/java/com/fongmi/android/tv/bean/Vod.java", - "src/main/java/com/fongmi/android/tv/bean/Result.java", - "src/main/java/com/fongmi/android/tv/bean/TmdbSourcePayload.java", - "src/main/java/com/fongmi/android/tv/utils/VodDetailCache.java" - }) { - String source = Files.readString(Path.of(path)); - assertFalse(path, source.contains(TmdbSourceCredentialIngress.ROOT_FIELD)); - } - } - - private static Result parse(String raw) { - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(raw); - // The ingress API still reports a candidate so callers can sanitize it. SiteApi must not accept this candidate. - assertEquals(KEY, ingress.getCandidateKey()); - assertFalse(ingress.getSanitizedJson().contains(KEY)); - Result result = GSON.fromJson(ingress.getSanitizedJson(), Result.class); - assertNotNull(result.getVod().getTmdb()); - return result; - } - - private static void cacheRoundTrip(String sourceKey, String id, String raw) { - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(raw); - Result result = GSON.fromJson(ingress.getSanitizedJson(), Result.class); - VodDetailCache.putContent(sourceKey, id, GSON.toJson(result)); - - String cached = VodDetailCache.getContent(sourceKey, id); - assertNotNull(cached); - assertFalse(cached.contains(KEY)); - TmdbSourcePayload payload = GSON.fromJson(cached, Result.class).getVod().getTmdb(); - assertNotNull(payload); - assertEquals(1399, payload.getTmdbId()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/TmdbSourceCredentialIngressTest.java b/app/src/test/java/com/fongmi/android/tv/api/TmdbSourceCredentialIngressTest.java deleted file mode 100644 index 5c74c23d6f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/TmdbSourceCredentialIngressTest.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.fongmi.android.tv.api; - -import com.fongmi.android.tv.bean.Result; -import com.google.gson.Gson; -import com.google.gson.JsonObject; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbSourceCredentialIngressTest { - - private static final String KEY = "0123456789abcdef0123456789abcdef"; - private static final Gson GSON = new Gson(); - - @Test - public void extractsValidRootKeyAndRemovesItFromSanitizedJson() { - String raw = "{\"tmdb_api_key\":\"" + KEY + "\",\"list\":[{\"vod_id\":\"1\",\"vod_name\":\"Example\"}]}"; - - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(raw); - - assertEquals(KEY, ingress.getCandidateKey()); - assertFalse(ingress.getSanitizedJson().contains(KEY)); - assertFalse(ingress.getSanitizedJson().contains(TmdbSourceCredentialIngress.ROOT_FIELD)); - assertEquals("1", GSON.fromJson(ingress.getSanitizedJson(), Result.class).getVod().getId()); - } - - @Test - public void missingRootKeyLeavesLegacyJsonUntouched() { - String raw = "{\"list\":[{\"vod_id\":\"1\",\"vod_name\":\"Legacy\"}]}"; - - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(raw); - - assertEquals("", ingress.getCandidateKey()); - assertEquals(raw, ingress.getSanitizedJson()); - } - - @Test - public void extractsSubscriptionConfigRootKeyAndPreservesSites() { - String raw = "{\"tmdb_api_key\":\"" + KEY + "\",\"sites\":[{\"key\":\"demo\",\"api\":\"https://source.example/api\"}]}"; - - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(raw); - JsonObject sanitized = GSON.fromJson(ingress.getSanitizedJson(), JsonObject.class); - - assertEquals(KEY, ingress.getCandidateKey()); - assertFalse(sanitized.has(TmdbSourceCredentialIngress.ROOT_FIELD)); - assertEquals("demo", sanitized.getAsJsonArray("sites").get(0).getAsJsonObject().get("key").getAsString()); - } - - @Test - public void invalidRootKeysAreRemovedButNeverAccepted() { - String[] values = { - "\"short\"", - "\"has space 1234567890\"", - "\"line\\nbreak_1234567890\"", - "12345678901234567890", - "null", - "{\"nested\":true}" - }; - - for (String value : values) { - String raw = "{\"tmdb_api_key\":" + value + ",\"list\":[{\"vod_id\":\"1\"}]}"; - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(raw); - - assertEquals("", ingress.getCandidateKey()); - assertFalse(ingress.getSanitizedJson().contains(TmdbSourceCredentialIngress.ROOT_FIELD)); - assertEquals("1", GSON.fromJson(ingress.getSanitizedJson(), Result.class).getVod().getId()); - } - } - - @Test - public void duplicateRootKeyIsRemovedAndRejectedAsAmbiguous() { - String raw = "{\"tmdb_api_key\":\"" + KEY + "\",\"tmdb_api_key\":\"fedcba9876543210fedcba9876543210\",\"list\":[{\"vod_id\":\"1\"}]}"; - - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(raw); - - assertEquals("", ingress.getCandidateKey()); - assertFalse(ingress.getSanitizedJson().contains(KEY)); - assertFalse(ingress.getSanitizedJson().contains("fedcba9876543210")); - } - - @Test - public void oversizedKeyIsRemovedButOrdinaryDetailStillParses() { - String oversized = "a".repeat(257); - String raw = "{\"tmdb_api_key\":\"" + oversized + "\",\"list\":[{\"vod_id\":\"1\",\"vod_name\":\"Still valid\"}]}"; - - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(raw); - - assertEquals("", ingress.getCandidateKey()); - assertEquals("Still valid", GSON.fromJson(ingress.getSanitizedJson(), Result.class).getVod().getName()); - } - - @Test - public void malformedJsonContainingRootKeyIsNeverEchoedToCallers() { - String raw = "{\"tmdb_api_key\":\"" + KEY + "\",\"list\":["; - - TmdbSourceCredentialIngress.Ingress ingress = TmdbSourceCredentialIngress.extractRootAndStrip(raw); - - assertEquals("", ingress.getCandidateKey()); - assertEquals("{}", ingress.getSanitizedJson()); - assertFalse(ingress.getSanitizedJson().contains(KEY)); - } - - @Test - public void sanitizedResultAndStringNeverContainKey() { - Result result = GSON.fromJson(TmdbSourceCredentialIngress.extractRootAndStrip( - "{\"tmdb_api_key\":\"" + KEY + "\",\"list\":[{\"vod_id\":\"1\",\"vod_name\":\"Example\"}]}").getSanitizedJson(), Result.class); - - assertFalse(GSON.toJson(result).contains(KEY)); - assertFalse(GSON.toJson(result.getVod()).contains(KEY)); - assertTrue(TmdbSourceCredentialIngress.normalizeApiKey(KEY).equals(KEY)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/TmdbSourceCredentialWiringTest.java b/app/src/test/java/com/fongmi/android/tv/api/TmdbSourceCredentialWiringTest.java deleted file mode 100644 index 56d5c0dbf1..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/TmdbSourceCredentialWiringTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.fongmi.android.tv.api; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertTrue; - -public class TmdbSourceCredentialWiringTest { - - @Test - public void siteApiStripsCredentialBeforeLoggingParsingAndCaching() throws Exception { - String source = Files.readString(Path.of("src/main/java/com/fongmi/android/tv/api/SiteApi.java")); - int ingress = source.indexOf("TmdbSourceCredentialIngress.extractRootAndStrip"); - int firstLog = source.indexOf("SpiderDebug.log(\"detail\", ingress.getSanitizedJson())"); - int parse = source.indexOf("Result.fromJson(ingress.getSanitizedJson())"); - int cache = source.indexOf("VodDetailCache.putContent(sourceKey, id, content)"); - - assertTrue(ingress > 0); - assertTrue(firstLog > ingress); - assertTrue(parse > ingress); - assertTrue(cache > parse); - assertTrue(!source.contains("SubscriptionTmdbCredentialStore.accept(")); - } - - @Test - public void vodConfigAcceptsCredentialOnlyAfterSubscriptionConfigLoadSucceeds() throws Exception { - String source = Files.readString(Path.of("src/main/java/com/fongmi/android/tv/api/config/VodConfig.java")); - int config = source.indexOf("public VodConfig config(Config config)"); - int begin = source.indexOf("SubscriptionTmdbCredentialStore.beginSubscription", config); - int assign = source.indexOf("this.config = config", config); - int load = source.indexOf("protected void load(Config config)"); - int ingress = source.indexOf("TmdbSourceCredentialIngress.extractRootAndStrip", load); - int loaded = source.indexOf("if (!isLoaded()) throw new Exception", ingress); - int accept = source.indexOf("acceptSubscriptionCredential(ingress.getCandidateKey(), config)", loaded); - int normalize = source.indexOf("CatSource.normalize(url, Json.parse(ingress.getSanitizedJson()))", ingress); - - assertTrue(begin > config); - assertTrue(assign > begin); - assertTrue(ingress > load); - assertTrue(normalize > ingress); - assertTrue(loaded > normalize); - assertTrue(accept > loaded); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/VodConfigSubscriptionCredentialWiringTest.java b/app/src/test/java/com/fongmi/android/tv/api/VodConfigSubscriptionCredentialWiringTest.java deleted file mode 100644 index 7d35d04823..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/VodConfigSubscriptionCredentialWiringTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.fongmi.android.tv.api; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class VodConfigSubscriptionCredentialWiringTest { - - @Test - public void subscriptionConfigRootIsTheOnlyCredentialSource() throws Exception { - String config = Files.readString(Path.of("src/main/java/com/fongmi/android/tv/api/config/VodConfig.java")); - String site = Files.readString(Path.of("src/main/java/com/fongmi/android/tv/api/SiteApi.java")); - int load = config.indexOf("protected void load(Config config)"); - int ingress = config.indexOf("TmdbSourceCredentialIngress.extractRootAndStrip", load); - int normalized = config.indexOf("CatSource.normalize(url, Json.parse(ingress.getSanitizedJson()))", ingress); - int accepted = config.indexOf("acceptSubscriptionCredential(ingress.getCandidateKey(), config)", normalized); - - assertTrue(ingress > load); - assertTrue(normalized > ingress); - assertTrue(accepted > normalized); - assertTrue(!site.contains("SubscriptionTmdbCredentialStore.accept(")); - } - - @Test - public void detailRootKeyIsStillStrippedButIgnored() throws Exception { - String site = Files.readString(Path.of("src/main/java/com/fongmi/android/tv/api/SiteApi.java")); - - assertTrue(site.contains("TmdbSourceCredentialIngress.extractRootAndStrip(detailContent)")); - assertFalse(site.contains("getCandidateKey()")); - assertFalse(site.contains("acceptSubscriptionCredential(")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/config/AdBlockStatsStoreSourceTest.java b/app/src/test/java/com/fongmi/android/tv/api/config/AdBlockStatsStoreSourceTest.java deleted file mode 100644 index 7144bb6c97..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/config/AdBlockStatsStoreSourceTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.fongmi.android.tv.api.config; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -/** Keeps site-health aggregation keyed by the stable site key rather than its display label. */ -public class AdBlockStatsStoreSourceTest { - - @Test - public void aggregateUsesStableSiteKeyAndRetainsDomainFallback() throws Exception { - String source = Files.readString(Path.of("src/main/java/com/fongmi/android/tv/api/config/AdBlockStatsStore.java")); - - assertTrue(source.contains("stats.incrementBlocks(identity.siteKey(), pipeline, ruleId, 1)")); - assertTrue(source.contains("stats.incrementBlocks(identity.siteKey(), pipeline, entry.getKey(), count)")); - assertTrue(source.contains("stats.incrementBlocks(identity.siteKey(), pipeline, \"hls.legacy-fallback\", safeFallbackCount)")); - assertTrue(source.contains("fallbackKey = TextUtils.isEmpty(siteKeyOrDomain) ? siteDomain : siteKeyOrDomain")); - assertFalse(source.contains("stats.incrementBlocks(identity.siteName()")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/config/BaseConfigLifecycleTest.java b/app/src/test/java/com/fongmi/android/tv/api/config/BaseConfigLifecycleTest.java index a964549dff..d2b5acd0d4 100644 --- a/app/src/test/java/com/fongmi/android/tv/api/config/BaseConfigLifecycleTest.java +++ b/app/src/test/java/com/fongmi/android/tv/api/config/BaseConfigLifecycleTest.java @@ -11,15 +11,6 @@ import java.nio.file.Path; public class BaseConfigLifecycleTest { - @Test - public void loadCapturesCurrentConfigBeforeAsyncTaskStarts() throws Exception { - Path sourcePath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "api", "config", "BaseConfig.java")); - String source = Files.readString(sourcePath); - assertTrue(source.contains("Config loadingConfig = getConfig();")); - assertTrue(source.contains("loadConfig(id, loadingConfig, callback)")); - assertFalse(source.contains("loadConfig(id, config, callback)")); - } - @Test public void ensureLoadedWaitsForCleanupBeforeTakingConfigMonitor() throws Exception { Path sourcePath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "api", "config", "BaseConfig.java")); diff --git a/app/src/test/java/com/fongmi/android/tv/api/config/HlsRuleConfigTest.java b/app/src/test/java/com/fongmi/android/tv/api/config/HlsRuleConfigTest.java deleted file mode 100644 index 5c8f64e6bb..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/config/HlsRuleConfigTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.fongmi.android.tv.api.config; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.bean.Rule; -import com.fongmi.android.tv.bean.RuleHitRecord; -import com.fongmi.android.tv.bean.UserAdRule; -import com.fongmi.android.tv.utils.HlsAdblockPipeline; -import com.fongmi.android.tv.utils.HlsManifestCleaner; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -public class HlsRuleConfigTest { - - @Test - public void legacyHostsScopePlaylistAndExcludeMatchesAdSegmentUrl() { - List compiled = new ArrayList<>(); - HlsRuleConfig.compileLegacyRules( - List.of(Rule.create("优质", List.of(".vvvip-plays33.cc"), List.of(), List.of("16.4666"))), - compiled); - String manifest = manifest(); - - HlsAdblockPipeline.Outcome matched = HlsAdblockPipeline.apply( - "https://foo.vvvip-plays33.cc/index.m3u8", manifest, compiled, false); - HlsAdblockPipeline.Outcome outsideHost = HlsAdblockPipeline.apply( - "https://video.example.com/index.m3u8", manifest, compiled, false); - - assertTrue(matched.structured()); - assertFalse(matched.manifest().contains("16.4666-ad.ts")); - assertFalse(outsideHost.structured()); - assertTrue(outsideHost.manifest().contains("16.4666-ad.ts")); - } - - @Test - public void legacyCustomRuleHitResolvesToCustomRuleName() { - UserAdRule custom = UserAdRule.createManual("我的自定义广告规则"); - custom.setHosts(List.of(".vvvip-plays33.cc")); - custom.setRegex(List.of("16\\.4666-ad\\.ts")); - List compiled = new ArrayList<>(); - HlsRuleConfig.compileLegacyRules(List.of(custom.toRule()), compiled); - - HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( - "https://foo.vvvip-plays33.cc/index.m3u8", manifest(), compiled, true); - String ruleId = outcome.removedSegmentDetails().get(0).ruleId(); - RuleHitRecord record = new RuleHitRecord(); - - assertTrue(outcome.structured()); - assertFalse(outcome.legacy()); - assertFalse(HlsRuleConfig.LEGACY_FALLBACK_KEY.equals(ruleId)); - assertTrue(AdBlockStatsStore.fillConfiguredRuleInfo( - record, ruleId, List.of(custom), List.of())); - assertEquals("我的自定义广告规则", record.getRuleName()); - assertEquals("手动", record.getRuleSource()); - } - - @Test - public void legacyRuleSourcesInvalidateCompiledCacheWhenConfigChanges() throws Exception { - String userRules = readSource("UserAdRuleStore.java"); - String disabledRules = readSource("DisabledDefaultRuleStore.java"); - - assertTrue(userRules.contains("RuleConfig.get().invalidate();")); - assertTrue(userRules.contains("HlsRuleConfig.invalidate();")); - assertTrue(disabledRules.contains("RuleConfig.get().invalidate();")); - assertTrue(disabledRules.contains("HlsRuleConfig.invalidate();")); - } - - private static String readSource(String fileName) throws Exception { - return Files.readString(Path.of("src/main/java/com/fongmi/android/tv/api/config", fileName), StandardCharsets.UTF_8); - } - - private static String manifest() { - return "#EXTM3U\n" - + "#EXTINF:7.0,\nhttps://cdn.example.com/16.4666-ad.ts\n" - + "#EXTINF:8.0,\nhttps://cdn.example.com/main-1.ts\n" - + "#EXTINF:8.0,\nhttps://cdn.example.com/main-2.ts\n" - + "#EXT-X-ENDLIST\n"; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/config/ImportedAdRuleCandidateStoreTest.java b/app/src/test/java/com/fongmi/android/tv/api/config/ImportedAdRuleCandidateStoreTest.java index 6b82614c57..17ba8e1e95 100644 --- a/app/src/test/java/com/fongmi/android/tv/api/config/ImportedAdRuleCandidateStoreTest.java +++ b/app/src/test/java/com/fongmi/android/tv/api/config/ImportedAdRuleCandidateStoreTest.java @@ -55,13 +55,6 @@ public class ImportedAdRuleCandidateStoreTest { assertEquals(ImportedAdRuleCandidate.STATUS_IGNORED, ignored.getStatus()); } - @Test - public void emptyBatchAndNullSingleImportAreStorageFreeNoOps() { - assertFalse(ImportedAdRuleCandidateStore.importCandidate(null)); - assertEquals(0, ImportedAdRuleCandidateStore.importCandidates(List.of(), true)); - assertEquals(0, ImportedAdRuleCandidateStore.ignoreCandidates(List.of())); - } - private static ImportedAdRuleCandidate candidate(String id, String status) { ImportedAdRuleCandidate candidate = new ImportedAdRuleCandidate(); candidate.setId(id); diff --git a/app/src/test/java/com/fongmi/android/tv/api/config/SubscriptionTmdbCredentialStoreTest.java b/app/src/test/java/com/fongmi/android/tv/api/config/SubscriptionTmdbCredentialStoreTest.java deleted file mode 100644 index 42c03efa55..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/config/SubscriptionTmdbCredentialStoreTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.fongmi.android.tv.api.config; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -public class SubscriptionTmdbCredentialStoreTest { - - private static final String KEY_A = "0123456789abcdef0123456789abcdef"; - private static final String KEY_B = "fedcba9876543210fedcba9876543210"; - - @Before - @After - public void clearStore() { - SubscriptionTmdbCredentialStore.clear(); - } - - @Test - public void acceptsOnlyCurrentSubscriptionAndEpoch() { - SubscriptionTmdbCredentialStore.Scope scope = SubscriptionTmdbCredentialStore.beginSubscription(1, "https://source.a/config/", "test"); - - assertTrue(SubscriptionTmdbCredentialStore.accept(KEY_A, 1, "https://source.a/config", scope.getEpoch(), "site-a", "vod-1")); - assertEquals(KEY_A, SubscriptionTmdbCredentialStore.snapshot(scope).getApiKey()); - assertTrue(SubscriptionTmdbCredentialStore.isCurrent(scope)); - assertFalse(SubscriptionTmdbCredentialStore.accept(KEY_B, 2, "https://source.a/config", scope.getEpoch(), "site-b", "vod-2")); - assertFalse(SubscriptionTmdbCredentialStore.accept(KEY_B, 1, "https://source.b/config", scope.getEpoch(), "site-b", "vod-2")); - assertFalse(SubscriptionTmdbCredentialStore.accept(KEY_B, 1, "https://source.a/config", scope.getEpoch() + 1, "site-b", "vod-2")); - assertEquals(KEY_A, SubscriptionTmdbCredentialStore.snapshot(1, "https://source.a/config", scope.getEpoch()).getApiKey()); - } - - @Test - public void sameSubscriptionAndNormalizedUrlPreserveCredential() { - SubscriptionTmdbCredentialStore.Scope first = SubscriptionTmdbCredentialStore.beginSubscription(7, " https://source.example/config/ ", "first"); - assertTrue(SubscriptionTmdbCredentialStore.accept(KEY_A, 7, "https://source.example/config", first.getEpoch(), "site-a", "vod-1")); - - SubscriptionTmdbCredentialStore.Scope reloaded = SubscriptionTmdbCredentialStore.beginSubscription(7, "https://source.example/config", "reload"); - - assertEquals(first.getEpoch(), reloaded.getEpoch()); - assertEquals(KEY_A, SubscriptionTmdbCredentialStore.snapshot(7, "https://source.example/config", reloaded.getEpoch()).getApiKey()); - } - - @Test - public void switchingSubscriptionClearsKeyAndReturningDoesNotRestoreIt() { - SubscriptionTmdbCredentialStore.Scope a = SubscriptionTmdbCredentialStore.beginSubscription(1, "https://source.a/config", "a"); - assertTrue(SubscriptionTmdbCredentialStore.accept(KEY_A, 1, "https://source.a/config", a.getEpoch(), "site-a", "vod-1")); - - SubscriptionTmdbCredentialStore.Scope b = SubscriptionTmdbCredentialStore.beginSubscription(2, "https://source.b/config", "b"); - assertNotEquals(a.getEpoch(), b.getEpoch()); - assertFalse(SubscriptionTmdbCredentialStore.snapshot(1, "https://source.a/config", a.getEpoch()).isPresent()); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(2, "https://source.b/config", b.getEpoch()).isEmpty()); - - SubscriptionTmdbCredentialStore.Scope aAgain = SubscriptionTmdbCredentialStore.beginSubscription(1, "https://source.a/config", "a-again"); - assertNotEquals(a.getEpoch(), aAgain.getEpoch()); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(1, "https://source.a/config", aAgain.getEpoch()).isEmpty()); - } - - @Test - public void clearRejectsOldEpochAndFutureAcceptUsesNewEpoch() { - SubscriptionTmdbCredentialStore.Scope old = SubscriptionTmdbCredentialStore.beginSubscription(3, "https://source.c/config", "old"); - assertTrue(SubscriptionTmdbCredentialStore.accept(KEY_A, 3, "https://source.c/config", old.getEpoch(), "site-c", "vod-1")); - - SubscriptionTmdbCredentialStore.clear(); - SubscriptionTmdbCredentialStore.Scope current = SubscriptionTmdbCredentialStore.currentScope(); - - assertNotEquals(old.getEpoch(), current.getEpoch()); - assertFalse(SubscriptionTmdbCredentialStore.isCurrent(old)); - assertFalse(SubscriptionTmdbCredentialStore.accept(KEY_B, 3, "https://source.c/config", old.getEpoch(), "site-c", "vod-2")); - assertTrue(SubscriptionTmdbCredentialStore.accept(KEY_B, 3, "https://source.c/config", current.getEpoch(), "site-c", "vod-2")); - assertEquals(KEY_B, SubscriptionTmdbCredentialStore.snapshot(3, "https://source.c/config", current.getEpoch()).getApiKey()); - assertFalse(SubscriptionTmdbCredentialStore.snapshot(3, "https://source.c/config", current.getEpoch()).getFingerprint().contains(KEY_B)); - } - - @Test - public void replacingCredentialInsideSameSubscriptionAdvancesEpoch() { - SubscriptionTmdbCredentialStore.Scope first = SubscriptionTmdbCredentialStore.beginSubscription(8, "https://source.rotate/config", "first"); - assertTrue(SubscriptionTmdbCredentialStore.accept(KEY_A, 8, "https://source.rotate/config", first.getEpoch(), "site", "vod-a")); - - SubscriptionTmdbCredentialStore.Scope current = SubscriptionTmdbCredentialStore.currentScope(); - assertTrue(SubscriptionTmdbCredentialStore.accept(KEY_B, 8, "https://source.rotate/config", current.getEpoch(), "site", "vod-b")); - SubscriptionTmdbCredentialStore.Scope rotated = SubscriptionTmdbCredentialStore.currentScope(); - - assertNotEquals(current.getEpoch(), rotated.getEpoch()); - assertFalse(SubscriptionTmdbCredentialStore.isCurrent(current)); - assertEquals(KEY_B, SubscriptionTmdbCredentialStore.snapshot(rotated).getApiKey()); - assertFalse(SubscriptionTmdbCredentialStore.accept(KEY_A, 8, "https://source.rotate/config", current.getEpoch(), "site", "vod-a")); - } - - @Test - public void discardCredentialReleasesKeyWithoutRepeatedEpochChurn() { - SubscriptionTmdbCredentialStore.Scope scope = SubscriptionTmdbCredentialStore.beginSubscription(6, "https://source.d/config", "test"); - assertTrue(SubscriptionTmdbCredentialStore.accept(KEY_A, 6, "https://source.d/config", scope.getEpoch(), "site-d", "vod-1")); - - SubscriptionTmdbCredentialStore.discardCredential(); - SubscriptionTmdbCredentialStore.Scope current = SubscriptionTmdbCredentialStore.currentScope(); - SubscriptionTmdbCredentialStore.discardCredential(); - - assertTrue(SubscriptionTmdbCredentialStore.snapshot(current).isEmpty()); - assertEquals(current.getEpoch(), SubscriptionTmdbCredentialStore.currentScope().getEpoch()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/config/VodConfigSiteKeyTest.java b/app/src/test/java/com/fongmi/android/tv/api/config/VodConfigSiteKeyTest.java deleted file mode 100644 index a0c6156ace..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/config/VodConfigSiteKeyTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.fongmi.android.tv.api.config; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.lang.reflect.Method; -import java.util.List; - -/** Keeps saved following/history source routes compatible with configuration key-case changes. */ -public class VodConfigSiteKeyTest { - - @Test - public void getSiteFallsBackToCaseInsensitiveKey() throws Exception { - Method method = VodConfig.class.getDeclaredMethod("getSite", String.class); - method.setAccessible(true); - VodConfig config = new VodConfig(); - setSites(config, List.of(site("Wogg"), site("Libvio"))); - - assertEquals("Wogg", siteKey(method, config, "wogg")); - assertEquals("Libvio", siteKey(method, config, "LIBVIO")); - assertEquals("Wogg", siteKey(method, config, "Wogg")); - assertTrue(((com.fongmi.android.tv.bean.Site) method.invoke(config, "missing")).isEmpty()); - } - - private static String siteKey(Method method, VodConfig config, String key) throws Exception { - return ((com.fongmi.android.tv.bean.Site) method.invoke(config, key)).getKey(); - } - - private static void setSites(VodConfig config, List sites) throws Exception { - Method setter = VodConfig.class.getDeclaredMethod("setSites", List.class); - setter.setAccessible(true); - setter.invoke(config, sites); - } - - private static com.fongmi.android.tv.bean.Site site(String key) { - com.fongmi.android.tv.bean.Site site = new com.fongmi.android.tv.bean.Site(); - site.setKey(key); - return site; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/loader/CatSpiderResolveTest.java b/app/src/test/java/com/fongmi/android/tv/api/loader/CatSpiderResolveTest.java deleted file mode 100644 index 3797b8b919..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/loader/CatSpiderResolveTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.fongmi.android.tv.api.loader; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 猫源请求地址重建的契约:只有本机回环 api 才跟随 Node 运行时当前端口重建, - * 远端(非本机)T4 猫源 api 必须原样请求。 - * - *

背景:站点 api 在配置加载时被 rebase 成 {@code http://127.0.0.1:/spider/...} - * 并驻留内存。:node 子进程崩溃自动重启后端口可能变化,固化的旧端口会一直白等超时。 - * 但远端猫源(如 {@code https://…/spider/…})与本地 bundle 的 api 形状相同——若重建逻辑 - * 只看“是否含 /spider/ + 本地端口非 0”,本地 node 在跑时远端站点会被错误改写到 127.0.0.1, - * 表现为“切换订阅后远端源全部失效”。 - * - *

resolve 依赖 NodeRuntime/OkHttp 等运行时,单测不易直接构造;这里钉死它的地址判定内核 - * {@link CatSpider#isLoopbackBase(String)}(包内可见),覆盖远端/回环/userinfo/无 host 各形态。 - */ -public class CatSpiderResolveTest { - - private static final String[] REMOTE_BASES = { - "https://catpaw.example.me", - "https://user:pass@catpaw.example.me", - "http://192.168.50.10:9988", - "https://example.me:8443", - }; - - private static final String[] LOOPBACK_BASES = { - "http://127.0.0.1:9988", - "http://localhost:9988", - "http://127.0.0.1", - }; - - @Test - public void remoteBasesAreNotLoopback() { - for (String base : REMOTE_BASES) { - assertFalse("远端 base 不得被判定为本机:" + base, CatSpider.isLoopbackBase(base)); - } - } - - @Test - public void loopbackBasesAreLoopback() { - for (String base : LOOPBACK_BASES) { - assertTrue("回环 base 必须判定为本机:" + base, CatSpider.isLoopbackBase(base)); - } - } - - @Test - public void userinfoDoesNotHideHost() { - // userinfo 里带 @ 和 : 时不能把凭据段误当 host——那会把远端站点误判成本机。 - assertFalse(CatSpider.isLoopbackBase("https://user:pass@127.0.0.1@evil.example.me")); - assertTrue("userinfo 含 127.0.0.1 文本但 host 是远端域名时仍应判远端(lastIndexOf@ 取真 host)", - CatSpider.isLoopbackBase("https://pass@127.0.0.1")); - } - - @Test - public void patternOnlyAcceptsSpiderRoutes() { - // BUNDLE_BASE 的形状契约:api 形如 [scheme://host]/spider//[/...],方法名接在后面。 - assertTrue(CatSpider.BUNDLE_BASE.matcher("http://127.0.0.1:9988/spider/douban/3").matches()); - assertTrue(CatSpider.BUNDLE_BASE.matcher("https://user:pass@catpaw.example.me/spider/muou/3").matches()); - assertTrue(CatSpider.BUNDLE_BASE.matcher("/spider/muou/3").matches()); - assertFalse("非 /spider/ 路由不参与重建", CatSpider.BUNDLE_BASE.matcher("http://127.0.0.1:9988/config").matches()); - assertFalse("缺站点段不参与重建", CatSpider.BUNDLE_BASE.matcher("http://127.0.0.1:9988/spider/").matches()); - } - - @Test - public void matchesRequiresAbsoluteHttp() { - assertTrue(CatSpider.matches("https://user:pass@catpaw.example.me/spider/muou/3")); - assertTrue(CatSpider.matches("http://127.0.0.1:9988/spider/douban/3")); - assertFalse("相对路径不匹配", CatSpider.matches("/spider/muou/3")); - assertFalse("空串不匹配", CatSpider.matches("")); - } - - @Test - public void apiTrailingSlashStripped() { - assertEquals("构造时去掉尾部斜杠,避免拼接出双斜杠路由", - "http://127.0.0.1:9988/spider/douban/3", new CatSpider("http://127.0.0.1:9988/spider/douban/3/").apiForTest()); - assertEquals("http://127.0.0.1:9988/spider/douban/3", new CatSpider("http://127.0.0.1:9988/spider/douban/3").apiForTest()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/loader/LoaderNamespaceSlotTest.java b/app/src/test/java/com/fongmi/android/tv/api/loader/LoaderNamespaceSlotTest.java deleted file mode 100644 index 9d35f04c1f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/loader/LoaderNamespaceSlotTest.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.fongmi.android.tv.api.loader; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.bean.Site; -import com.github.catvod.crawler.Spider; -import com.github.catvod.utils.Util; - -import org.junit.Test; - -import java.lang.reflect.Field; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.concurrent.ConcurrentHashMap; - -/** - * 锁定桥接解析的缓存隔离:桥接出来的 Site 必须带着 configKey 作为命名空间进 loader 槽位, - * 否则两条配置里的同名 siteKey 会共用同一个 spider 实例(搜索出别人的结果、播放地址错源)。 - * - *

这里走的是真实的 loader 槽位解析({@link JarLoader#getSpider(String, String, String, String, String)}), - * 不是源码文本断言:JarLoader 的构造器不碰 native,能在纯 JVM 单测里直接构造。 - * {@code BaseLoader.get()} 仍然构造不了(会 new PyLoader/JsLoader 拉起 Python 与 QuickJS), - * 所以「Site 的命名空间确实被送进 loader」这一段由源码契约固定在下面第三个用例里。 - */ -public class LoaderNamespaceSlotTest { - - @Test - public void sameSiteKeyUnderDifferentConfigKeysHitsIndependentLoaderSlots() throws Exception { - JarLoader loader = new JarLoader(); - ConcurrentHashMap spiders = map(loader, "spiders"); - String jar = ""; - - // 三条配置里的同名 siteKey:命名空间取自真实 Site(桥接时由 VodSiteCatalog 打上 configKey)。 - Site activeSite = site("same", ""); - Site alphaSite = site("same", "cfgAlpha"); - Site betaSite = site("same", "cfgBeta"); - Spider active = new Spider() {}; - Spider alpha = new Spider() {}; - Spider beta = new Spider() {}; - - // 三个实例分别占住「激活配置(空命名空间)」与两条桥接配置的槽位。 - spiders.put(Util.md5(jar) + BaseLoader.slot(activeSite.getNamespace(), activeSite.getKey()), active); - spiders.put(Util.md5(jar) + BaseLoader.slot(alphaSite.getNamespace(), alphaSite.getKey()), alpha); - spiders.put(Util.md5(jar) + BaseLoader.slot(betaSite.getNamespace(), betaSite.getKey()), beta); - - assertSame("空命名空间必须仍然命中原来的键(激活配置路径逐字节不变)", - active, loader.getSpider(activeSite.getNamespace(), activeSite.getKey(), "csp_Demo", "", jar)); - assertSame("cfgAlpha 必须命中自己的槽位", - alpha, loader.getSpider(alphaSite.getNamespace(), alphaSite.getKey(), "csp_Demo", "", jar)); - assertSame("cfgBeta 必须命中自己的槽位", - beta, loader.getSpider(betaSite.getNamespace(), betaSite.getKey(), "csp_Demo", "", jar)); - assertNotSame("两条配置的同名 siteKey 绝不能拿到同一个实例", - loader.getSpider(alphaSite.getNamespace(), alphaSite.getKey(), "csp_Demo", "", jar), - loader.getSpider(betaSite.getNamespace(), betaSite.getKey(), "csp_Demo", "", jar)); - assertNotSame("桥接配置不能与激活配置共用实例", - loader.getSpider(alphaSite.getNamespace(), alphaSite.getKey(), "csp_Demo", "", jar), - loader.getSpider(activeSite.getNamespace(), activeSite.getKey(), "csp_Demo", "", jar)); - } - - @Test - public void siteNamespaceDrivesTheLoaderSlotWhileActiveSiteStaysUnchanged() throws Exception { - Site active = site("same", ""); - Site alpha = site("same", "cfgAlpha"); - Site beta = site("same", "cfgBeta"); - - assertEquals("原生(非桥接)Site 的命名空间必须为空", "", active.getNamespace()); - assertEquals("空命名空间必须落到原键", "same", BaseLoader.slot(active.getNamespace(), active.getKey())); - assertEquals("桥接 Site 必须落到带 configKey 的键", "cfgAlpha@@@same", - BaseLoader.slot(alpha.getNamespace(), alpha.getKey())); - assertNotEquals("两条配置的槽键必须不同", - BaseLoader.slot(alpha.getNamespace(), alpha.getKey()), - BaseLoader.slot(beta.getNamespace(), beta.getKey())); - assertEquals("命名空间只做 trim,不改变 siteKey 语义", "cfgAlpha", site("same", " cfgAlpha ").getNamespace()); - assertEquals("空串命名空间等价于空", "", site("same", " ").getNamespace()); - } - - @Test - public void bridgePlumbingPassesTheNamespaceAllTheWayToTheLoader() { - String site = read("com/fongmi/android/tv/bean/Site.java"); - assertTrue("Site.spider() 必须把命名空间透传给五参 getSpider", - site.contains("getSpider(getNamespace(), getKey(), getApi(), getExt(), getJar())")); - assertTrue("命名空间必须是纯运行时标记,不进 Room/Gson/Parcel", - site.contains("private transient String namespace;")); - - String catalog = read("com/fongmi/android/tv/api/config/VodSiteCatalog.java"); - assertTrue("按 configKey 解析出来的 Catalog 必须以该 configKey 作命名空间", - catalog.contains("catalogOf(findConfig(id), id)")); - assertTrue("桥接清单里的每个站源都要打上命名空间(含合并进来的自定义 CSP 站源)", - catalog.contains("site.setNamespace(namespace)")); - assertTrue("空命名空间(激活配置)不得打标", - catalog.contains("if (!isBlank(namespace))")); - - // /vod/api 的两条主路径(proxyKey 判定与 type==3 请求分发)都用 site.spider(), - // 因此由 Catalog 打的命名空间自动生效,VodApi 不需要额外参数。 - String vodApi = read("com/fongmi/android/tv/server/process/VodApi.java"); - assertFalse("VodApi 不得再自己拼空命名空间调用", vodApi.contains("getSpider(site.getKey()")); - assertTrue("proxyKey 判定必须走命名空间感知的 spider()", vodApi.contains("supportsDirectProxy(site.spider())")); - assertTrue("type==3 主路径必须走命名空间感知的 spider()", vodApi.contains("Spider spider = site.spider();")); - } - - private static Site site(String key, String namespace) { - Site site = new Site(); - site.setKey(key); - site.setNamespace(namespace); - return site; - } - - @SuppressWarnings("unchecked") - private static ConcurrentHashMap map(JarLoader loader, String name) throws Exception { - Field field = JarLoader.class.getDeclaredField(name); - field.setAccessible(true); - return (ConcurrentHashMap) field.get(loader); - } - - private static String read(String relative) { - Path dir = Paths.get("").toAbsolutePath(); - for (int i = 0; i < 6 && dir != null; i++, dir = dir.getParent()) { - Path candidate = dir.resolve(Paths.get("app", "src", "main", "java")).resolve(relative.replace('/', java.io.File.separatorChar)); - if (Files.isRegularFile(candidate)) { - try { - return new String(Files.readAllBytes(candidate), StandardCharsets.UTF_8); - } catch (java.io.IOException e) { - throw new AssertionError("无法读取 " + candidate, e); - } - } - } - throw new IllegalStateException("app/src/main/java 下找不到 " + relative); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/loader/LoaderRecentNamespaceTest.java b/app/src/test/java/com/fongmi/android/tv/api/loader/LoaderRecentNamespaceTest.java deleted file mode 100644 index 5dd62c3355..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/loader/LoaderRecentNamespaceTest.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.fongmi.android.tv.api.loader; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; - -import com.github.catvod.crawler.Spider; - -import org.junit.Test; - -import java.lang.reflect.Field; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** - * {@code ?do=js} / {@code ?do=py} 子路由的 **真实行为** 覆盖。 - * - *

这两个子路由的请求由站源脚本自己生成(quickjs 的 {@code ?do=js}、chaquo 的 {@code ?do=py} - * 都不带 siteKey/configKey),消费端 {@code BaseLoader.proxy} 只能靠 loader 的 {@code recent} - * 定位 spider。{@code spiders} 表按 {@link BaseLoader#slot(String, String)} 建键,所以 recent - * 也必须是槽位键;否则桥接配置(非激活配置)的脚本请求要么查不到自己(proxy 返回 null → - * HTTP INVALID_RESPONSE),要么命中激活配置里同名站源的实例(B2 同类串源)。 - * - *

这里断言的是真实代码路径,不是源码字符串:预置的 fake Spider 放进真实 {@code spiders} 表, - * 调用真实的 {@code setRecent(namespace, key)} 与真实的 {@code proxy(params)},断言取回的正是 - * 该 fake。{@code JsLoader}/{@code PyLoader} 的构造器会初始化 QuickJS / 启动 Python,纯 JVM 单测 - * 不能执行它,所以实例用 {@code sun.misc.Unsafe.allocateInstance} 分配(不跑构造器), - * 只有 {@code spiders} 表与 {@code recent} 两个字段是测试写入的,被测代码本身没有任何替身。 - */ -public class LoaderRecentNamespaceTest { - - @Test - public void jsProxyResolvesRecentToTheNamespacedSlot() throws Exception { - JsLoader loader = allocate(JsLoader.class); - ConcurrentHashMap spiders = seedSpiders(loader, JsLoader.class); - - spiders.put(BaseLoader.slot("", "same"), fake("active")); - spiders.put(BaseLoader.slot("cfgAlpha", "same"), fake("alpha")); - spiders.put(BaseLoader.slot("cfgBeta", "same"), fake("beta")); - - // 桥接配置 cfgAlpha 的 ?do=js:必须命中自己的槽位。 - loader.setRecent("cfgAlpha", "same"); - assertEquals("桥接配置的 ?do=js 必须命中自己的槽位,不能命中激活配置的同名站源", "alpha", proxy(loader)); - // 桥接配置 cfgBeta 的 ?do=js:必须命中自己的槽位。 - loader.setRecent("cfgBeta", "same"); - assertEquals("不同命名空间下的同名 siteKey 必须互不干扰", "beta", proxy(loader)); - // 激活配置(空命名空间)走单参 setRecent:与改动前逐字节等价。 - loader.setRecent("same"); - assertEquals("空命名空间的 recent 必须仍是裸 siteKey(激活配置路径逐字节不变)", "active", proxy(loader)); - loader.setRecent(null, "same"); - assertEquals("null 命名空间等价于空命名空间", "active", proxy(loader)); - } - - @Test - public void pyProxyResolvesRecentToTheNamespacedSlot() throws Exception { - PyLoader loader = allocate(PyLoader.class); - ConcurrentHashMap spiders = seedSpiders(loader, PyLoader.class); - - spiders.put(BaseLoader.slot("", "same"), fake("active")); - spiders.put(BaseLoader.slot("cfgAlpha", "same"), fake("alpha")); - spiders.put(BaseLoader.slot("cfgBeta", "same"), fake("beta")); - - loader.setRecent("cfgAlpha", "same"); - assertEquals("桥接配置的 ?do=py 必须命中自己的槽位,不能命中激活配置的同名站源", "alpha", proxy(loader)); - loader.setRecent("cfgBeta", "same"); - assertEquals("不同命名空间下的同名 siteKey 必须互不干扰", "beta", proxy(loader)); - loader.setRecent("same"); - assertEquals("空命名空间的 recent 必须仍是裸 siteKey(激活配置路径逐字节不变)", "active", proxy(loader)); - loader.setRecent(null, "same"); - assertEquals("null 命名空间等价于空命名空间", "active", proxy(loader)); - } - - /** - * 反证用例:recent 若退回裸 siteKey,本用例必然变红。 - * - *

只预置激活配置(裸键)的 fake,然后把 recent 指向另一条配置的同名站源: - * 槽位键正确时 {@code spiders.get("cfgBeta@@@same")} 落空 → proxy 返回 null; - * 一旦最近记录退回裸键 {@code "same"},就会命中激活配置的 fake 并返回非 null, - * 即「用激活配置的 spider 服务非激活配置的请求」这一串源现场。 - */ - @Test - public void jsProxyReturnsNullWhenRecentBelongsToAnotherNamespace() throws Exception { - JsLoader loader = allocate(JsLoader.class); - ConcurrentHashMap spiders = seedSpiders(loader, JsLoader.class); - - spiders.put(BaseLoader.slot("", "same"), fake("active")); - loader.setRecent("cfgBeta", "same"); - - assertNull("recent 属于另一条配置时必须落空;命中激活配置的实例即为串源", loader.proxy(params())); - loader.setRecent("same"); - assertNotNull("同一张表的裸键必须仍然可取(对照,证明上面的 null 不是表本身空)", loader.proxy(params())); - } - - @Test - public void pyProxyReturnsNullWhenRecentBelongsToAnotherNamespace() throws Exception { - PyLoader loader = allocate(PyLoader.class); - ConcurrentHashMap spiders = seedSpiders(loader, PyLoader.class); - - spiders.put(BaseLoader.slot("", "same"), fake("active")); - loader.setRecent("cfgBeta", "same"); - - assertNull("recent 属于另一条配置时必须落空;命中激活配置的实例即为串源", loader.proxy(params())); - loader.setRecent("same"); - assertNotNull("同一张表的裸键必须仍然可取(对照,证明上面的 null 不是表本身空)", loader.proxy(params())); - } - - private static Map params() { - Map params = new HashMap<>(); - params.put("do", "js"); - params.put("url", "https://example.invalid/v"); - return params; - } - - private static Spider fake(String tag) { - return new Spider() { - @Override - public Object[] proxy(Map params) { - return new Object[]{tag, params.get("url")}; - } - }; - } - - private static Object proxy(Object loader) throws Exception { - Map params = params(); - if (loader instanceof JsLoader) return ((JsLoader) loader).proxy(params)[0]; - return ((PyLoader) loader).proxy(params)[0]; - } - - @SuppressWarnings("unchecked") - private static ConcurrentHashMap seedSpiders(Object loader, Class type) throws Exception { - ConcurrentHashMap spiders = new ConcurrentHashMap<>(); - set(type, loader, "spiders", spiders); - return spiders; - } - - private static void set(Class type, Object target, String name, Object value) throws Exception { - Field field = type.getDeclaredField(name); - field.setAccessible(true); - field.set(target, value); - } - - /** - * 分配实例但不执行构造器:{@code JsLoader}/{@code PyLoader} 的构造器会 - * {@code QuickJSLoader.init()} / {@code Python.start(...)},在纯 JVM 单测里必然失败。 - * 用反射按名取 {@code sun.misc.Unsafe},避免编译期依赖 JDK 内部类型。 - */ - @SuppressWarnings("unchecked") - private static T allocate(Class type) throws Exception { - Class unsafeType = Class.forName("sun.misc.Unsafe"); - Field field = unsafeType.getDeclaredField("theUnsafe"); - field.setAccessible(true); - Object unsafe = field.get(null); - return (T) unsafeType.getMethod("allocateInstance", Class.class).invoke(unsafe, type); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/loader/NativeBridgeGuardTest.java b/app/src/test/java/com/fongmi/android/tv/api/loader/NativeBridgeGuardTest.java deleted file mode 100644 index 145510665e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/loader/NativeBridgeGuardTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.fongmi.android.tv.api.loader; - -import org.junit.Test; - -import java.util.List; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class NativeBridgeGuardTest { - - @Test - public void nativeBridgePropertyMarksTranslatedRuntime() { - assertTrue(NativeBridgeGuard.isTranslatedRuntime("libnb.so", List.of("x86_64", "x86"), "x86_64")); - assertFalse(NativeBridgeGuard.isTranslatedRuntime("", List.of("x86_64", "x86"), "x86_64")); - assertFalse(NativeBridgeGuard.isTranslatedRuntime(null, List.of("x86_64", "x86"), "x86_64")); - } - - @Test - public void foreignAbiFamilyWithoutBridgePropertyAlsoCounts() { - // Emulators sometimes expose the translated ABI list before the system - // property becomes readable through reflection. - assertTrue(NativeBridgeGuard.isTranslatedRuntime("", List.of("x86_64", "x86", "arm64-v8a", "armeabi-v7a"), "x86_64")); - assertFalse(NativeBridgeGuard.isTranslatedRuntime("", List.of("x86_64", "x86"), "x86_64")); - assertFalse(NativeBridgeGuard.isTranslatedRuntime("", List.of("arm64-v8a", "armeabi-v7a"), "arm64-v8a")); - } - - @Test - public void armPrimaryWithX86SupportIsAlsoTreatedAsTranslated() { - assertTrue(NativeBridgeGuard.isTranslatedRuntime("", List.of("arm64-v8a", "x86_64"), "arm64-v8a")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/loader/PyLoaderDiagnosticsWiringTest.java b/app/src/test/java/com/fongmi/android/tv/api/loader/PyLoaderDiagnosticsWiringTest.java deleted file mode 100644 index 9460398741..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/loader/PyLoaderDiagnosticsWiringTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.fongmi.android.tv.api.loader; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -/** - * PyLoader 的初始化失败必须同时留在「诊断日志」和「Logcat」两条通道上。 - * - *

{@code SpiderDebug.log(tag, …)} 的每一条重载先是 {@code DebugLogStore.acceptsTag(tag)}, - * 而它等于 {@code enabled && …};{@code enabled} 默认是 false({@code Prefers.getBoolean} - * 的默认值),只有用户在设置里打开「调试日志」才为真。因此只用 SpiderDebug 记录初始化失败, - * 在默认设置下等同于「完全静默」——而这里正是「站点全空却没有任何线索」的唯一解释点。

- * - *

兄弟实现 {@code JsLoader} 同时保留了两条通道,本测试把 PyLoader 拉回同一口径。

- */ -public class PyLoaderDiagnosticsWiringTest { - - private static final Path SOURCE = Path.of("src/main/java/com/fongmi/android/tv/api/loader/PyLoader.java"); - - private static String source() throws Exception { - return code(SOURCE); - } - - /** - * 去掉注释行后再断言:否则 {@code // e.printStackTrace();} 这种注释也能满足「有兜底日志」的断言, - * 守卫就退化成恒真检查。 - */ - private static String code(Path file) throws Exception { - StringBuilder result = new StringBuilder(); - for (String line : Files.readString(file).replace("\r\n", "\n").split("\n", -1)) { - if (!line.trim().startsWith("//") && !line.trim().startsWith("*")) result.append(line).append('\n'); - } - return result.toString(); - } - - @Test - public void initFailureKeepsAnUnconditionalLogcatChannel() throws Exception { - String source = source(); - int diagnose = source.indexOf("SpiderDebug.log(PY_TAG, e);"); - int unconditional = source.indexOf("e.printStackTrace();", diagnose); - - assertTrue("init failure must still be recorded in the diagnostic log", diagnose > 0); - assertTrue("init failure must also reach Logcat without 调试日志 enabled", unconditional > diagnose); - assertEquals("the unconditional channel must live in the same catch block", - -1, source.indexOf("e.printStackTrace();", unconditional + 1)); - } - - @Test - public void initFailureStillGoesThroughTheDiagnosticStore() throws Exception { - String source = source(); - int named = source.indexOf("SpiderDebug.log(PY_TAG, \"init failed key=%s api=%s error=%s:%s\""); - - // 两条通道缺一不可:诊断日志带 key/api/异常类型,Logcat 无条件可见。 - assertTrue("init failure must name the site and the failing callable", named > 0); - assertTrue(source.contains("SpiderDebug.log(PY_TAG, e);")); - } - - @Test - public void siblingJsLoaderUsesTheSameTwoChannels() throws Exception { - String source = code(Path.of("src/main/java/com/fongmi/android/tv/api/loader/JsLoader.java")); - - // 这个断言的意义是:一旦别的 loader 改了口径,PyLoader 的「两条通道」也就不再是少数派特例。 - assertTrue(source.contains("SpiderDebug.log(\"JsLoader\", e);")); - assertTrue(source.contains("e.printStackTrace();")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/loader/PythonSpiderDiagnosticsWiringTest.java b/app/src/test/java/com/fongmi/android/tv/api/loader/PythonSpiderDiagnosticsWiringTest.java deleted file mode 100644 index 31fac5c5d6..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/api/loader/PythonSpiderDiagnosticsWiringTest.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.fongmi.android.tv.api.loader; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -/** - * Python 蜘蛛的依赖取回失败必须同时留在「诊断日志」和「Logcat」两条通道上。 - * - *

{@code SpiderDebug.log(tag, …)} 的每一条重载先是 {@code DebugLogStore.acceptsTag(tag)}, - * 而它等于 {@code enabled && …};{@code enabled} 默认是 false({@code Prefers.getBoolean} - * 的默认值),只有用户在设置里打开「调试日志」才为真。因此只用 SpiderDebug 记录依赖取回失败, - * 在默认设置下等同于「静默跳过」——用户只会看到某个蜘蛛少了功能却没有任何线索。

- * - *

{@code PyLoader}/{@code JsLoader} 的同类口径由 {@code PyLoaderDiagnosticsWiringTest} - * 守着;{@code com.fongmi.chaquo.Spider} 属于 chaquo 模块,app 侧单元测试此前完全没有覆盖, - * 所以这条修复可以被静默回退而 0 个测试失败。本测试把它拉回同一口径。

- */ -public class PythonSpiderDiagnosticsWiringTest { - - private static final String SPIDER = "chaquo/src/main/java/com/fongmi/chaquo/Spider.java"; - - private static final String STRUCTURED = - "SpiderDebug.log(TAG, \"depend download failed name=%s error=%s\", item, e);"; - - private static String source() throws Exception { - return code(projectRoot().resolve(SPIDER)); - } - - private static Path projectRoot() { - return Files.exists(Path.of("chaquo")) ? Path.of("") : Path.of(".."); - } - - /** - * 去掉注释后再断言:否则 {@code // e.printStackTrace();} 这种注释行也能满足断言, - * 守卫就退化成恒真检查(与 {@code PyLoaderDiagnosticsWiringTest#code} 同一手法)。 - * 这里比它多一步:先剥掉块注释,因为 {@code /* e.printStackTrace(); *}{@code /} 这种 - * 行内块注释既不以 {@code //} 也不以 {@code *} 开头,却同样能让断言看到那串文本。 - */ - private static String code(Path file) throws Exception { - String text = Files.readString(file, StandardCharsets.UTF_8).replace("\r\n", "\n"); - StringBuilder plain = new StringBuilder(); - int at = 0; - while (true) { - int open = text.indexOf("/*", at); - if (open < 0) { - plain.append(text, at, text.length()); - break; - } - plain.append(text, at, open); - int close = text.indexOf("*/", open + 2); - if (close < 0) { - // 未闭合的块注释在 Java 里本身就是编译错误;这里保留余下文本而不是丢弃, - // 以免断言因为「看不到代码」而不明不白地红掉。 - plain.append(text, open, text.length()); - break; - } - at = close + 2; - } - StringBuilder result = new StringBuilder(); - for (String line : plain.toString().split("\n", -1)) { - if (!line.trim().startsWith("//") && !line.trim().startsWith("*")) result.append(line).append('\n'); - } - return result.toString(); - } - - @Test - public void dependencyFailureKeepsAnUnconditionalLogcatChannel() throws Exception { - String source = source(); - int diagnose = source.indexOf(STRUCTURED); - - assertTrue("a failed dependency download must still be named in the diagnostic log", diagnose > 0); - int unconditional = source.indexOf("e.printStackTrace();", diagnose); - assertTrue("a failed dependency download must also reach Logcat without 调试日志 enabled", - unconditional > diagnose); - // 两条通道必须在同一个 catch 里:一旦 printStackTrace 挪到别处,上面的 in-order 断言依然成立—— - // getHeader() 的 catch 里同样有 e 在作用域内,挪过去也能通过 in-order 检查。 - // 所以这里再钉两点:与结构化日志相距 < 200 字符(同一代码块),以及全文只有一处。 - assertTrue("both channels must live in the same catch block", - unconditional - diagnose < 200); - assertEquals("the unconditional channel must be the only one in the file", - -1, source.indexOf("e.printStackTrace();", unconditional + 1)); - } - - @Test - public void dependencyFailureKeepsTheBridgeCallGuarded() throws Exception { - String source = source(); - - // 这条断言守的是「catch 住谁」:改回 catch (Exception) 会让 JVM 级 Error 重新逃逸成整站降级, - // 而 Spider 的其它 catch 都不是这个形状,所以直接在依赖循环里定位。 - int download = source.indexOf("download(item + \".py\");"); - int caught = source.indexOf("catch (Throwable e)", download); - - assertTrue("the dependency download call must still be present", download > 0); - assertTrue("a failed dependency download must not abort init(), including on JVM-level Error", - caught > download); - assertTrue("the catch must be adjacent to the download call", caught - download < 200); - assertEquals("only the dependency loop should log and rethrow nothing", 1, countOf(source, STRUCTURED)); - } - - private static int countOf(String source, String needle) { - int count = 0; - for (int at = source.indexOf(needle); at >= 0; at = source.indexOf(needle, at + 1)) count++; - return count; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/AdBlockLogFilterTest.java b/app/src/test/java/com/fongmi/android/tv/bean/AdBlockLogFilterTest.java deleted file mode 100644 index fb8fd3b8f8..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/AdBlockLogFilterTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.fongmi.android.tv.bean; - -import org.junit.Test; - -import java.util.EnumMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -public class AdBlockLogFilterTest { - - @Test - public void filtersEachColumnIndependentlyAndCombinesConditions() { - AdBlockLog first = new AdBlockLog(1L, "site-a", "站点 A", "a.example", - "HLS", "ad-a.example", "rule-a", 10, 2) - .setPlaybackContext("墨雨云间", "LZ源", "第 2 集"); - AdBlockLog second = new AdBlockLog(2L, "site-b", "站点 B", "b.example", - "MPV", "ad-b.example", "rule-b", 20, 3) - .setPlaybackContext("庆余年", "备用线路", "第 1 集"); - List logs = List.of(first, second); - - EnumMap filters = new EnumMap<>(AdBlockLogFilter.Column.class); - filters.put(AdBlockLogFilter.Column.VOD_NAME, "墨雨"); - assertEquals(List.of(first), AdBlockLogFilter.filter(logs, filters)); - - filters.put(AdBlockLogFilter.Column.LINE_NAME, "lz"); - filters.put(AdBlockLogFilter.Column.EPISODE_NAME, "第 2"); - assertEquals(List.of(first), AdBlockLogFilter.filter(logs, filters)); - - filters.put(AdBlockLogFilter.Column.SITE_DOMAIN, "not-found"); - assertTrue(AdBlockLogFilter.filter(logs, filters).isEmpty()); - } - - @Test - public void emptyFiltersAndBlankFieldsAreHandledSafely() { - AdBlockLog log = new AdBlockLog(); - EnumMap filters = new EnumMap<>(AdBlockLogFilter.Column.class); - filters.put(AdBlockLogFilter.Column.VOD_NAME, " "); - assertEquals(1, AdBlockLogFilter.filter(List.of(log), filters).size()); - - filters.put(AdBlockLogFilter.Column.VOD_NAME, "missing"); - assertTrue(AdBlockLogFilter.filter(List.of(log), filters).isEmpty()); - } - - @Test - public void deduplicatedSelectionsUseOrWithinAColumnAndAndAcrossColumns() { - AdBlockLog first = new AdBlockLog().setPlaybackContext("剧一", "线路 A", "第一集"); - AdBlockLog second = new AdBlockLog().setPlaybackContext("剧二", "线路 A", "第二集"); - AdBlockLog third = new AdBlockLog().setPlaybackContext("剧三", "线路 B", "第三集"); - EnumMap> selections = - new EnumMap<>(AdBlockLogFilter.Column.class); - selections.put(AdBlockLogFilter.Column.VOD_NAME, - new LinkedHashSet<>(List.of("剧一", "剧二"))); - selections.put(AdBlockLogFilter.Column.LINE_NAME, - new LinkedHashSet<>(List.of("线路 A"))); - - assertEquals(List.of(first, second), AdBlockLogFilter.filter( - List.of(first, second, third), Map.of(), selections, AdBlockLogFilter::values)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/AdBlockStatsTest.java b/app/src/test/java/com/fongmi/android/tv/bean/AdBlockStatsTest.java deleted file mode 100644 index 5b5bcf63c5..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/AdBlockStatsTest.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.fongmi.android.tv.bean; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import java.util.List; - -import org.junit.Test; - -public class AdBlockStatsTest { - - @Test - public void incrementBlocksUpdatesAllDimensionsAtomically() { - AdBlockStats stats = new AdBlockStats(); - - stats.incrementBlocks("demo", "EXO", "rule-a", 3); - stats.incrementBlocks("demo", "EXO", "hls.legacy-fallback", 2); - - assertEquals(5, stats.getTotalBlocked()); - assertEquals(5, stats.getSiteBlockedCount("demo")); - assertEquals(3, stats.getRuleBlockedCount("rule-a")); - assertEquals(2, stats.getRuleBlockedCount("hls.legacy-fallback")); - assertEquals(5, stats.getPipelineBlockedCount("EXO")); - } - - @Test - public void nonPositiveCountsAreIgnoredAndResetClearsEveryDimension() { - AdBlockStats stats = new AdBlockStats(); - - stats.incrementBlocks("demo", "MPV", "rule-a", 4); - stats.incrementBlocks("demo", "MPV", "rule-a", 0); - stats.incrementBlocks("demo", "MPV", "rule-a", -2); - stats.reset(); - - assertEquals(0, stats.getTotalBlocked()); - assertEquals(0, stats.getSiteBlockedCount("demo")); - assertEquals(0, stats.getRuleBlockedCount("rule-a")); - assertEquals(0, stats.getPipelineBlockedCount("MPV")); - } - @Test - public void recordBlockLogKeepsEverySegmentAndAllRequestedFields() { - AdBlockStats stats = new AdBlockStats(); - - stats.recordBlockLog(1000L, "采集源A", "线路一", "ads.example.com", "rule-a", 6.5); - stats.recordBlockLog(2000L, "采集源A", "线路一", "ads.example.com", "rule-a", 4.0); - - List logs = stats.getBlockLogs(); - assertEquals(2, logs.size()); - AdBlockLog latest = logs.get(0); - assertEquals(2000L, latest.getBlockedAt()); - assertEquals("采集源A", latest.getSourceName()); - assertEquals("线路一", latest.getPipelineName()); - assertEquals("ads.example.com", latest.getAdDomain()); - assertEquals("rule-a", latest.getRuleId()); - assertEquals(4.0, latest.getSegmentDurationSeconds(), 0.001); - } - - @Test - public void blockLogsCanBeGroupedBySourceRuleAndPipeline() { - AdBlockStats stats = new AdBlockStats(); - stats.recordBlockLog(1000L, "采集源A", "线路一", "a.example", "rule-a", 3.0); - stats.recordBlockLog(2000L, "采集源A", "线路二", "b.example", "rule-b", 5.0); - stats.recordBlockLog(3000L, "采集源B", "线路一", "a.example", "rule-a", 7.0); - - assertEquals(2, stats.getBlockLogsBySource("采集源A").size()); - assertEquals(2, stats.getBlockLogsByRule("rule-a").size()); - assertEquals(2, stats.getBlockLogsByPipeline("线路一").size()); - assertTrue(stats.getSourceNamesWithBlocks().contains("采集源A")); - assertTrue(stats.getSourceNamesWithBlocks().contains("采集源B")); - } - - @Test - public void resetAlsoClearsDetailedBlockLogs() { - AdBlockStats stats = new AdBlockStats(); - stats.recordBlockLog(1000L, "采集源A", "线路一", "a.example", "rule-a", 3.0); - - stats.reset(); - - assertTrue(stats.getBlockLogs().isEmpty()); - } - - @Test - public void recordBlockLogsUsesPerRuleCountsAndDistributesDuration() { - AdBlockStats stats = new AdBlockStats(); - java.util.Map ruleCounts = new java.util.LinkedHashMap<>(); - ruleCounts.put("rule-a", 2L); - ruleCounts.put("rule-b", 1L); - - stats.recordBlockLogs(1000L, "采集源A", "EXO", "ads.example.com", ruleCounts, 12.0); - - assertEquals(3, stats.getBlockLogs().size()); - assertEquals(2, stats.getBlockLogsByRule("rule-a").size()); - assertEquals(1, stats.getBlockLogsByRule("rule-b").size()); - assertEquals(4.0, stats.getBlockLogs().get(0).getSegmentDurationSeconds(), 0.001); - } - - @Test - public void recordBlockLogsKeepsZeroDurationVisibleAsNumericData() { - AdBlockStats stats = new AdBlockStats(); - java.util.Map ruleCounts = new java.util.LinkedHashMap<>(); - ruleCounts.put("rule-a", 1L); - - stats.recordBlockLogs(1000L, "site-a", "HLS", "ads.example.com", ruleCounts, 0.0); - - assertEquals(1, stats.getBlockLogs().size()); - assertEquals(0.0, stats.getBlockLogs().get(0).getSegmentDurationSeconds(), 0.001); - } - - @Test - public void blockLogStoresSegmentStartAndDuration() { - AdBlockStats stats = new AdBlockStats(); - stats.recordBlockLog(1000L, "采集源A", "EXO", "ads.example.com", "rule-a", 12.5, 6.0); - - AdBlockLog log = stats.getBlockLogs().get(0); - assertEquals(12.5, log.getSegmentStartSeconds(), 0.001); - assertEquals(6.0, log.getSegmentDurationSeconds(), 0.001); - } - - @Test - public void blockLogKeepsSiteIdentitySeparateFromDomainsAndComputesEndTime() { - AdBlockStats stats = new AdBlockStats(); - stats.recordBlockLog(1000L, "dytt", "电影天堂[采]", "vip.dytt-tvs.com", - "EXO", "ads.cdn.example", "hls.legacy-fallback", 12.5, 6.0); - - AdBlockLog log = stats.getBlockLogs().get(0); - assertEquals("dytt", log.getSiteKey()); - assertEquals("电影天堂[采]", log.getSiteName()); - assertEquals("vip.dytt-tvs.com", log.getSiteDomain()); - assertEquals("ads.cdn.example", log.getAdDomain()); - assertEquals(18.5, log.getSegmentEndSeconds(), 0.001); - assertTrue(log.hasSegmentTiming()); - } - - @Test - public void siteLogLookupUsesStableKeyAndAcceptsLegacyDisplayName() { - AdBlockStats stats = new AdBlockStats(); - stats.recordBlockLog(1000L, "dytt", "电影天堂[采]", "vip.dytt-tvs.com", - "EXO", "ads.cdn.example", "rule-a", 0, 6.0); - - assertEquals(1, stats.getBlockLogsBySite("dytt").size()); - assertEquals(1, stats.getBlockLogsBySite("电影天堂[采]").size()); - } - - @Test - public void detailedLogsKeepOnlyTheMostRecentEntries() { - AdBlockStats stats = new AdBlockStats(); - for (int index = 0; index < 1005; index++) { - stats.recordBlockLog(index, "采集源A", "EXO", "ads.example.com", "rule-a", 1.0); - } - - assertEquals(1000, stats.getBlockLogs().size()); - assertEquals(1004L, stats.getBlockLogs().get(0).getBlockedAt()); - assertEquals(5L, stats.getBlockLogs().get(999).getBlockedAt()); - } - -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/BackupFollowingTest.java b/app/src/test/java/com/fongmi/android/tv/bean/BackupFollowingTest.java deleted file mode 100644 index 0c58b12732..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/BackupFollowingTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.fongmi.android.tv.bean; - -import com.fongmi.android.tv.following.Following; -import com.fongmi.android.tv.following.FollowingSource; -import com.google.gson.Gson; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class BackupFollowingTest { - - @Test - public void oldBackupWithoutFollowingPayloadPreservesLocalData() { - Backup backup = Backup.objectFrom("{\"config\":[]}"); - - assertFalse(backup.hasFollowingPayload()); - } - - @Test - public void followingPayloadRoundTripsWithSchemaVersion() { - Backup backup = new Backup(); - Following item = new Following(); - item.identityKey = "tmdb:tv:1:s1"; - item.vodName = "Example"; - FollowingSource source = new FollowingSource(); - source.followingKey = item.identityKey; - source.siteKey = "site"; - source.vodId = "vod"; - backup.setFollowing(java.util.List.of(item)); - backup.setFollowingSource(java.util.List.of(source)); - backup.setFollowingSchemaVersion(1); - - Backup parsed = Backup.objectFrom(new Gson().toJson(backup)); - - assertTrue(parsed.hasFollowingPayload()); - assertEquals(1, parsed.getFollowing().size()); - assertEquals("Example", parsed.getFollowing().get(0).vodName); - assertEquals("vod", parsed.getFollowingSource().get(0).vodId); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/BackupPreferenceFilterTest.java b/app/src/test/java/com/fongmi/android/tv/bean/BackupPreferenceFilterTest.java index 31be3cc311..c321aeaafc 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/BackupPreferenceFilterTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/BackupPreferenceFilterTest.java @@ -47,37 +47,6 @@ public class BackupPreferenceFilterTest { assertFalse(Backup.include("episode_history", spiderOnly)); } - @Test - public void touchOptimizationFollowsSettingsOption() { - SyncOptions settingsOnly = new SyncOptions().config(false).spider(false).webHome(false).settings(true); - SyncOptions spiderOnly = new SyncOptions().config(false).spider(true).webHome(false).settings(false); - - assertTrue(Backup.include("touch_optimized", settingsOnly)); - assertFalse(Backup.include("touch_optimized", spiderOnly)); - } - - @Test - public void playbackOverlayFollowsSettingsOption() { - SyncOptions settingsOnly = new SyncOptions().config(false).spider(false).webHome(false).settings(true); - SyncOptions spiderOnly = new SyncOptions().config(false).spider(true).webHome(false).settings(false); - - assertTrue(Backup.include("playback_overlay_enabled", settingsOnly)); - assertFalse(Backup.include("playback_overlay_enabled", spiderOnly)); - } - - @Test - public void appearanceModeAndThemeProfileFollowSettingsOption() { - SyncOptions settingsOnly = new SyncOptions().config(false).spider(false).webHome(false).settings(true); - SyncOptions spiderOnly = new SyncOptions().config(false).spider(true).webHome(false).settings(false); - - for (String key : new String[]{ - "theme_mode", "theme_profile_v2_json", - "theme_profile_v2_last_good", "theme_profile_v2_schema"}) { - assertTrue(key, Backup.include(key, settingsOnly)); - assertFalse(key, Backup.include(key, spiderOnly)); - } - } - @Test public void githubProxyPreferencesFollowSettingsOption() { SyncOptions settingsOnly = new SyncOptions().config(false).spider(false).webHome(false).settings(true); @@ -85,10 +54,8 @@ public class BackupPreferenceFilterTest { assertTrue(Backup.include("github_proxy", settingsOnly)); assertTrue(Backup.include("github_proxy_enabled", settingsOnly)); - assertTrue(Backup.include("github_proxy_mode", settingsOnly)); assertFalse(Backup.include("github_proxy", webHomeOnly)); assertFalse(Backup.include("github_proxy_enabled", webHomeOnly)); - assertFalse(Backup.include("github_proxy_mode", webHomeOnly)); } @Test @@ -170,39 +137,4 @@ public class BackupPreferenceFilterTest { assertFalse(Backup.include( "perf_exo_single_rate_rescue_enabled_v1", everything)); } - - @Test - public void themeProfilePreferencesFollowSettingsOption() { - SyncOptions settingsOnly = new SyncOptions().config(false).spider(false).webHome(false).settings(true); - SyncOptions webHomeOnly = new SyncOptions().config(false).spider(false).webHome(true).settings(false); - - // The B-safe profile is persisted under the v2 keys only: the legacy v1 - // `theme_profile_*` keys are deliberately never reused, so an old TweakCN - // profile can never be misread as a current one. - assertTrue(Backup.include("theme_profile_v2_json", settingsOnly)); - assertTrue(Backup.include("theme_profile_v2_last_good", settingsOnly)); - assertTrue(Backup.include("theme_profile_v2_schema", settingsOnly)); - assertTrue(Backup.include("theme_mode", settingsOnly)); - assertFalse(Backup.include("theme_profile_v2_json", webHomeOnly)); - assertFalse("the legacy v1 profile key must not come back", - Backup.include("theme_profile_json", settingsOnly)); - } - - @Test - public void updateDownloadSettingsFollowAppSettingsSync() { - SyncOptions settings = new SyncOptions().config(false).spider(false).settings(true); - - assertTrue(Backup.include("interface_failover_mode", settings)); - assertTrue(Backup.include("interface_order_vod", settings)); - assertTrue(Backup.include("update_source", settings)); - // 旧键仍要备份:恢复到新版后 Setting.migrateLegacyGithubProxy() 靠它们把 - // 用户当年的代理选择迁成 github_proxy 多源列表,剔掉就等于丢弃而非迁移。 - assertTrue(Backup.include("update_github_proxy", settings)); - assertTrue(Backup.include("update_github_proxy_url", settings)); - assertTrue(Backup.include("update_github_proxy_mode", settings)); - assertTrue(Backup.include("update_oci_mirror", settings)); - assertTrue(Backup.include("update_oci_mirror_url", settings)); - assertFalse(Backup.include("update_channel", settings)); - assertFalse(Backup.include("update_fallback", settings)); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/bean/BuiltinHlsRuleFixtureTest.java b/app/src/test/java/com/fongmi/android/tv/bean/BuiltinHlsRuleFixtureTest.java index a9590c1ffa..2c2b8af309 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/BuiltinHlsRuleFixtureTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/BuiltinHlsRuleFixtureTest.java @@ -1,25 +1,60 @@ package com.fongmi.android.tv.bean; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + import org.junit.Test; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class BuiltinHlsRuleFixtureTest { @Test - public void builtinPackageParsesAndContainsNoExperimentalInterceptionRules() throws Exception { + public void builtinRulesAreDefaultOffAndHavePositiveAndNegativeFixtures() throws Exception { HlsRulePackage rulePackage = HlsRulePackage.parse(Files.readString( projectRoot().resolve("app/src/main/assets/rules/hls_rules.json"), StandardCharsets.UTF_8)); - assertEquals(2, rulePackage.getSchemaVersion()); - assertEquals("webhtv-builtin-hls", rulePackage.getPackageId()); - assertEquals(1, rulePackage.getVersion()); - assertTrue(rulePackage.getRules().isEmpty()); + assertEquals(3, rulePackage.getRules().size()); + verify(rulePackage, "builtin.hls.baofeng.preroll", "https://s5.bfzycdn.example/video/index.m3u8", 3.0); + verify(rulePackage, "builtin.hls.liangzi.preroll", "https://vip.lz-cdn.example/video/index.m3u8", 6.433333); + verify(rulePackage, "builtin.hls.feifan.preroll", "https://vip.ffzy-play.example/video/index.m3u8", 6.666667); + } + + private static void verify(HlsRulePackage rulePackage, String id, String playlistUrl, double duration) { + HlsAdRule rule = rulePackage.getRules().stream().filter(item -> id.equals(item.getId())).findFirst().orElseThrow(); + assertFalse(rule.isEnabledByDefault()); + + String positive = manifest(duration, true); + HlsManifestCleaner.Result removed = HlsManifestCleaner.clean(playlistUrl, positive, List.of(rule.compile())); + assertTrue(id, removed.changed()); + assertFalse(id, removed.manifest().contains("ad.ts")); + + String negative = manifest(duration, false); + HlsManifestCleaner.Result retained = HlsManifestCleaner.clean(playlistUrl, negative, List.of(rule.compile())); + assertFalse(id, retained.changed()); + assertEquals(id, negative, retained.manifest()); + + HlsManifestCleaner.Result wrongHost = HlsManifestCleaner.clean( + "https://video.example.com/index.m3u8", positive, List.of(rule.compile())); + assertFalse(id, wrongHost.changed()); + assertEquals(id, positive, wrongHost.manifest()); + } + + private static String manifest(double duration, boolean discontinuity) { + return "#EXTM3U\n" + + (discontinuity ? "#EXT-X-DISCONTINUITY\n" : "") + + "#EXTINF:" + duration + ",\n" + + "ad.ts\n" + + "#EXTINF:8.0,\nmain-1.ts\n" + + "#EXTINF:8.0,\nmain-2.ts\n" + + "#EXTINF:8.0,\nmain-3.ts\n" + + "#EXT-X-ENDLIST\n"; } private static Path projectRoot() { diff --git a/app/src/test/java/com/fongmi/android/tv/bean/DanmakuMatchCacheTest.java b/app/src/test/java/com/fongmi/android/tv/bean/DanmakuMatchCacheTest.java index f0f11d58a5..40a7aed1e9 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/DanmakuMatchCacheTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/DanmakuMatchCacheTest.java @@ -38,74 +38,4 @@ public class DanmakuMatchCacheTest { assertEquals("庆余年", example.getExpectedTitle()); assertEquals(MediaTitleLearningExample.SOURCE_DANMAKU_MANUAL, example.getSource()); } - - @Test - public void seriesMemory_keepsManualKeywordForLaterEpisodes() { - Danmaku item = danmaku("https://example.test/current.xml"); - - DanmakuMatchCache cache = new DanmakuMatchCache(); - cache.putSeries("site", "vod-1", "qyn 第二季", "庆余年", item); - - assertEquals("qyn 第二季", cache.findSeriesSearchTitle("site", "vod-1")); - assertEquals("", cache.findSeriesSearchTitle("site", "vod-2")); - assertEquals("", cache.findSeriesSearchTitle("other", "vod-1")); - } - - @Test - public void seriesMemory_overwritesPreviousManualKeyword() { - DanmakuMatchCache cache = new DanmakuMatchCache(); - cache.putSeries("site", "vod-1", "旧关键词", "旧标题", danmaku("https://example.test/old.xml")); - cache.putSeries("site", "vod-1", "新关键词", "新标题", danmaku("https://example.test/new.xml")); - - assertEquals("新关键词", cache.findSeriesSearchTitle("site", "vod-1")); - } - - @Test - public void tmdbSeasonMemory_reusesKeywordAndIsolatedFromSeriesMemory() { - DanmakuMatchCache cache = new DanmakuMatchCache(); - cache.putTmdbSeason(123, 2, "qyn 第二季", "庆余年", danmaku("https://example.test/current.xml")); - - assertEquals("qyn 第二季", cache.findTmdbSeasonSearchTitle(123, 2)); - assertEquals("", cache.findTmdbSeasonSearchTitle(123, 3)); - assertEquals("", cache.findTmdbSeasonSearchTitle(456, 2)); - assertEquals("", cache.findSeriesSearchTitle("site", "vod-1")); - } - - @Test - public void manualMemory_rejectsInvalidIdentityAndBlankKeyword() { - DanmakuMatchCache cache = new DanmakuMatchCache(); - Danmaku item = danmaku("https://example.test/current.xml"); - - cache.putSeries("", "vod-1", "keyword", "title", item); - cache.putSeries("site", "", "keyword", "title", item); - cache.putSeries("site", "vod-1", "", "title", null); - cache.putTmdbSeason(0, 1, "keyword", "title", item); - cache.putTmdbSeason(123, 0, "keyword", "title", null); - - assertEquals(0, cache.getSeriesItems().size()); - assertEquals(0, cache.getTmdbSeasonItems().size()); - } - - @Test - public void manualMemory_fallsBackToRawTitleWhenSearchTitleIsBlank() { - DanmakuMatchCache cache = new DanmakuMatchCache(); - - cache.putSeries("site", "vod-1", "", "庆余年 第二季", danmaku("https://example.test/current.xml")); - - assertEquals("庆余年 第二季", cache.findSeriesSearchTitle("site", "vod-1")); - } - - @Test - public void objectFrom_acceptsLegacyJsonWithoutSeriesOrTmdbMaps() { - DanmakuMatchCache cache = DanmakuMatchCache.objectFrom("{\"items\":{}}"); - - assertEquals(0, cache.getSeriesItems().size()); - assertEquals(0, cache.getTmdbSeasonItems().size()); - } - - private static Danmaku danmaku(String url) { - Danmaku item = Danmaku.from(url); - item.setName("庆余年(2024) - 第5集 from B站"); - return item; - } } diff --git a/app/src/test/java/com/fongmi/android/tv/bean/DeviceIdentityCacheTest.java b/app/src/test/java/com/fongmi/android/tv/bean/DeviceIdentityCacheTest.java deleted file mode 100644 index b85336bc9d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/DeviceIdentityCacheTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.fongmi.android.tv.bean; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * {@code Device.get()} 每次都会重新探测 MAC、Android ID、型号,其中 {@code Util.getMac} - * 还会遍历网络接口。它被 {@code PlaybackRecord.clientKey()} 调用,而后者在播放中每秒 - * 至少跑一次(进度 tick),相当于每秒在主线程做一遍设备探测。 - * 这些测试锁住「身份信息进程内只探测一次」这条约束,同时锁住每次调用仍需返回新的可变实体。 - */ -public class DeviceIdentityCacheTest { - - @Test - public void identityIsProbedOncePerProcess() throws Exception { - String source = readSource("bean/Device.java"); - String body = methodBody(source, "public static Device get()"); - assertTrue("Device.get() must go through the cached identity probe", body.contains("ensureIdentity();")); - assertFalse("Device.get() must not re-read the MAC addresses on every call", body.contains("Util.getMac(")); - assertFalse("Device.get() must not re-read the Android ID on every call", body.contains("Util.getAndroidId(")); - assertFalse("Device.get() must not re-read the build model on every call", body.contains("Util.getDeviceName(")); - } - - @Test - public void theProbeIsGuardedByADoubleCheckedLock() throws Exception { - String body = methodBody(readSource("bean/Device.java"), "private static void ensureIdentity()"); - assertTrue("the probe must check the ready flag before locking", body.contains("if (identityReady) return;")); - assertTrue("the probe must re-check inside the lock", body.contains("synchronized (IDENTITY_LOCK)")); - assertTrue("the probe must publish the ready flag last", body.indexOf("identityReady = true;") > body.indexOf("synchronized (IDENTITY_LOCK)")); - } - - @Test - public void theHotCallerUsesTheCheapAccessor() throws Exception { - String source = readSource("playback/PlaybackRecord.java"); - String body = stripComments(methodBody(source, "private static String clientKey()")); - assertTrue("clientKey must use the cached identity accessor", body.contains("Device.identityUuid()")); - assertFalse("clientKey must not build a whole Device on the progress tick", body.contains("Device.get()")); - String deviceSource = readSource("bean/Device.java"); - assertTrue("the cached accessor must exist", deviceSource.contains("public static String identityUuid()")); - assertTrue("the accessor must never return null", deviceSource.contains("identityUuid == null ? \"\" : identityUuid")); - } - - @Test - public void deviceStillReturnsAFreshInstanceWithLiveTimeAndAddress() throws Exception { - String body = methodBody(readSource("bean/Device.java"), "public static Device get()"); - assertTrue("callers mutate and persist the entity, so a new instance is still required", - body.contains("Device device = new Device();")); - assertTrue("the address can change while the app runs", body.contains("setIp(")); - assertTrue("the store timestamp must stay live", body.contains("setTime(")); - } - - private static String stripComments(String body) { - // 断言针对真实代码,不能被解释性注释命中(注释里会提到被删除的调用)。 - StringBuilder out = new StringBuilder(body.length()); - for (String line : body.split("\\n", -1)) { - String trimmed = line.trim(); - if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) continue; - out.append(line).append('\n'); - } - return out.toString(); - } - - private static String readSource(String file) throws Exception { - Path moduleRelative = Path.of("src", "main", "java", "com", "fongmi", "android", "tv", file); - Path path = Files.exists(moduleRelative) ? moduleRelative : Path.of("app").resolve(moduleRelative); - return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("source is missing " + signature, start >= 0); - int open = source.indexOf('{', start + signature.length()); - assertTrue("source is missing the body of " + signature, open > start); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char current = source.charAt(i); - if (current == '{') depth++; - else if (current == '}' && --depth == 0) return source.substring(start, i + 1); - } - throw new AssertionError("unclosed body for " + signature); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/EpisodeVersionIdentityTest.java b/app/src/test/java/com/fongmi/android/tv/bean/EpisodeVersionIdentityTest.java deleted file mode 100644 index 898ac3ec9f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/EpisodeVersionIdentityTest.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.fongmi.android.tv.bean; - -import org.junit.Test; - - - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; - -/** - * 同一 TMDB 集存在多个版本(同名但不同 URL 的源站条目)时的版本身份判定。 - *

- * 场景来源:炫彩详情模式点击选集卡片跳到播放页后,用户点击“第二版本”却播放/选中“第一版本”。 - * 根因是 `matchesPlayback`/`matches` 在双方都绑定 TMDB 季集号时直接以“集号相同”返回同集, - * 版本差异(URL)根本没参与比较。 - */ -public class EpisodeVersionIdentityTest { - - private static Episode version(String name, String url, int season, int number) { - Episode episode = Episode.create(name, url); - episode.setTmdbEpisode(new TmdbEpisode(number, "", "", "", "", 0, 0, 0, season)); - return episode; - } - - @Test - public void sameTmdbEpisodeWithDifferentUrlsIsADifferentVersionInOneFlag() { - // 同一线路内的同集多版本(历史 URL 仍能定位到本线路条目时,调用方传 versionAware=true): - // URL 不同 → 不同版本,否则“点第二版本却被当作第一版本”。 - Episode first = version("正片", "url-v1", 1, 3); - Episode second = version("正片", "url-v2", 1, 3); - - assertFalse(second.matchesPlayback(first, true)); - assertFalse(first.matchesPlayback(second, true)); - assertFalse(second.matches(first)); - assertFalse(first.matches(second)); - } - - @Test - public void crossFlagOrRefreshedUrlsKeepTolerantNumberMatch() { - // 换线路/换源或源站刷新后,同一集的 URL 必然变化,单参 matchesPlayback 必须保留集号容错, - // 否则跨线路续播会丢失进度、选中回落第一集(1fd83c6f53 跨线路续播契约)。 - Episode lineA = version("正片", "url-line-a", 1, 3); - Episode lineB = version("正片", "url-line-b", 1, 3); - - assertTrue(lineB.matchesPlayback(lineA)); - assertTrue(lineA.matchesPlayback(lineB)); - // 调用方未确认同线路上下文时,带参调用同样回落容错。 - assertTrue(lineB.matchesPlayback(lineA, false)); - } - - @Test - public void sameTmdbEpisodeAndSameUrlIsTheSameVersion() { - Episode first = version("正片", "url-v1", 1, 3); - Episode again = version("正片", "url-v1", 1, 3); - - assertTrue(again.matchesPlayback(first)); - assertTrue(again.matchesPlayback(first, true)); - assertTrue(again.matches(first)); - } - - @Test - public void unknownUrlOnOneSideMustNotFallBackToTheFirstVersion() { - Episode known = version("正片", "url-v2", 1, 3); - Episode unknown = version("正片", "", 1, 3); - - assertFalse(known.matchesPlayback(unknown, true)); - assertFalse(unknown.matchesPlayback(known, true)); - } - - @Test - public void sameTmdbEpisodeWithoutAnyVersionInfoStaysTheSameEpisode() { - Episode first = version("", "", 1, 3); - Episode second = version("", "", 1, 3); - - assertTrue(second.matchesPlayback(first)); - assertTrue(second.matchesPlayback(first, true)); - } - - @Test - public void sameTmdbEpisodeUsesSourceNameWhenNoUrlIsAvailable() { - Episode first = version("[1080P] 第3集", "", 1, 3); - Episode sameName = version("[1080P] 第3集", "", 1, 3); - Episode otherName = version("[4K] 第3集", "", 1, 3); - - assertTrue(sameName.matchesPlayback(first)); - assertTrue(sameName.matchesPlayback(first, true)); - assertFalse(otherName.matchesPlayback(first, true)); - } - - @Test - public void sameTmdbEpisodeUsesSourceDescriptionAsVersionEvidence() { - Episode first = Episode.create("[720P] 第3集", "1080P", ""); - first.setTmdbEpisode(new TmdbEpisode(3, "", "", "", "", 0, 0, 0, 1)); - Episode second = Episode.create("[720P] 第3集", "4K", ""); - second.setTmdbEpisode(new TmdbEpisode(3, "", "", "", "", 0, 0, 0, 1)); - - assertFalse(second.matchesPlayback(first, true)); - } - - @Test - public void differentTmdbEpisodesStillRejectEachOther() { - Episode third = version("正片", "url-3", 1, 3); - Episode fourth = version("正片", "url-4", 1, 4); - - assertFalse(fourth.matchesPlayback(third)); - } - - @Test - public void differentTmdbSeasonWithSameNumberStillRejects() { - Episode firstSeason = version("正片", "url-s1e3", 1, 3); - Episode secondSeason = version("正片", "url-s2e3", 2, 3); - - assertFalse(secondSeason.matchesPlayback(firstSeason)); - } - - @Test - public void legacyUnboundEpisodesKeepTolerantUrlFallback() { - Episode saved = Episode.create("第9集", "old-url"); - Episode refreshed = Episode.create("第9集", "new-url"); - - assertTrue(refreshed.matchesPlayback(saved)); - } - - @Test - public void findWithUrlSelectsTheRequestedVersionAmongDuplicates() { - Flag flag = new Flag("线路1"); - Episode first = Episode.create("正片", "url-v1"); - Episode second = Episode.create("正片", "url-v2"); - flag.getEpisodes().add(first); - flag.getEpisodes().add(second); - - assertSame(second, flag.find(Episode.create("正片", "url-v2"), true)); - assertSame(first, flag.find(Episode.create("正片", "url-v1"), true)); - } - - @Test - public void findWithUrlStillFallsBackToEpisodeNumberWhenUrlIsUnknown() { - Flag flag = new Flag("线路1"); - Episode first = Episode.create("第1集", "url-v1"); - Episode second = Episode.create("第2集", "url-v2"); - flag.getEpisodes().add(first); - flag.getEpisodes().add(second); - - // 定位请求不带 URL 时,仍按集名/集号解析,不返回 null。 - assertSame(second, flag.find(Episode.create("第2集", ""), true)); - } - - @Test - public void findWithoutUrlKeepsStrictMissSemantics() { - Flag flag = new Flag("线路1"); - flag.getEpisodes().add(Episode.create("第1集", "url-v1")); - flag.getEpisodes().add(Episode.create("第2集", "url-v2")); - - // 多集线路才存在「严格未命中」语义:单集线路的 find(Episode, true) 会无条件返回该集。 - assertNull(flag.find(Episode.create("另一个名字", ""), true)); - } - - @Test - public void unknownSeasonKeepsTolerantTmdbNumberMatch() { - // 季号一侧未知时不得做版本消歧,否则跨源续播(同集号、换源、换 URL)会被误判为不同集。 - Episode known = version("第3集", "url-v2", -1, 3); - Episode other = version("源站第三集", "url-v9", 2, 3); - - assertTrue(other.matchesPlayback(known)); - assertTrue(known.matchesPlayback(other)); - } - - @Test - public void matchesPlaybackTreatsHistoryRebuiltFromVersionAsSameVersion() { - // 播放页把“用户点到的版本”写进 History(remarks + episodeUrl), - // 再次比较时应认出同一版本,而不是同集的第一个版本。 - // 调用方(同线路内,历史 URL 仍能定位到本线路条目)传 versionAware=true 启用消歧。 - Episode second = version("正片", "url-v2", 1, 3); - History history = new History(); - history.setVodRemarks(second.getName()); - history.setEpisodeUrl(second.getUrl()); - history.setTmdbEpisodePosition(second); - - Episode rebuilt = history.getEpisode(); - assertTrue(rebuilt.matchesPlayback(second)); - assertTrue(rebuilt.matchesPlayback(second, true)); - assertFalse(rebuilt.matchesPlayback(version("正片", "url-v1", 1, 3), true)); - } - - @Test - public void flagFindPrefersRequestedVariantOverTmdbNumberShortcut() { - Flag flag = new Flag("线路1"); - Episode first = version("正片", "url-v1", 1, 3); - Episode second = version("正片", "url-v2", 1, 3); - flag.getEpisodes().add(first); - flag.getEpisodes().add(second); - - Episode resolved = flag.find(version("正片", "url-v2", 1, 3), true); - assertSame(second, resolved); - } - - @Test - public void containsEpisodeUrlMarksVersionAwareOnlyWhenUrlStillResolves() { - Flag flag = new Flag("线路1"); - Episode first = version("正片", "url-v1", 1, 3); - Episode second = version("正片", "url-v2", 1, 3); - flag.getEpisodes().add(first); - flag.getEpisodes().add(second); - - // 历史 URL 仍能定位到本线路条目:同集多版本并存,可启用版本消歧。 - assertTrue(flag.containsEpisodeUrl(version("正片", "url-v1", 1, 3))); - assertTrue(flag.containsEpisodeUrl(version("正片", "url-v2", 1, 3))); - // TMDB 位置冲突的同 URL 条目不得算命中(与 find 的 URL 优先口径一致)。 - assertFalse(flag.containsEpisodeUrl(version("正片", "url-v1", 2, 3))); - // 换线路/换源/源站刷新:旧 URL 已不在本线路,必须保留集号容错。 - assertFalse(flag.containsEpisodeUrl(version("正片", "url-gone", 1, 3))); - assertFalse(flag.containsEpisodeUrl(version("正片", "", 1, 3))); - assertFalse(flag.containsEpisodeUrl(null)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/GroupDisplayNameTest.java b/app/src/test/java/com/fongmi/android/tv/bean/GroupDisplayNameTest.java deleted file mode 100644 index d54eb9542d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/GroupDisplayNameTest.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.fongmi.android.tv.bean; - -import com.github.catvod.utils.Trans; -import com.google.gson.Gson; - -import org.junit.After; -import org.junit.Test; - -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -/** - * 直播分组的“持久化身份”与“界面显示名”必须分离。 - *

- * 场景来源:用户把界面语言切到繁体中文后,“上次播放频道”静默回落到第一组第一个频道。 - * 根因是 {@link Group#trans()} 旧实现直接改写 {@link Group#name},而分组名同时参与 - * keep 复合键({@code 组名@@@频道名@@@线路})、浏览树 id({@code LG:组名})与 - * {@link Group#equals}——切一次语言,这些键就全部漂移一次。 - *

- * 因此这里锁死:简繁转换只允许写 displayName,绝不允许改 name;旧版本已经写进 keep 键与 - * 浏览树 id 的译文组名,由 {@link Group#matchesName(String)} 的归一回退兼容。 - */ -public class GroupDisplayNameTest { - - @After - public void tearDown() { - Trans.setTraditional(null); - } - - @Test - public void traditionalConversionKeepsIdentityName() { - Group group = new Group("线路"); - Trans.setTraditional(true); - group.trans(); - - // 身份名保持简体原文,否则 keep 键/浏览树 id/equals 会随语言漂移。 - assertEquals("线路", group.getName()); - // 展示名走繁体,界面照旧显示繁体分组名。 - assertEquals("線路", group.getDisplayName()); - } - - @Test - public void displayNameFallsBackToIdentityNameWhenNotTranslated() { - Group group = new Group("线路"); - assertEquals("线路", group.getDisplayName()); - - // 跟随系统/简体时 trans() 直接短路,displayName 保持为空。 - Trans.setTraditional(false); - group.trans(); - assertEquals("线路", group.getDisplayName()); - } - - @Test - public void keepKeyIsIdenticalInBothLanguages() { - assertEquals(keepKey(false), keepKey(true)); - // 同一套身份名拼出的键在两种语言下必须逐字相同。 - assertEquals("线路@@@频道@@@http://example.com/1.m3u8", keepKey(true)); - } - - @Test - public void legacyTranslatedGroupNameStillMatches() { - Group group = new Group("线路"); - Trans.setTraditional(true); - group.trans(); - - // 新格式:持久化身份名直接命中。 - assertTrue(group.matchesName("线路")); - // 旧格式:keep 键/浏览树 id 里存的是旧版本写进去的译文组名,靠归一回退命中。 - assertTrue(group.matchesName("線路")); - assertFalse(group.matchesName("别的组")); - assertFalse(group.matchesName("")); - assertFalse(group.matchesName(null)); - } - - @Test - public void normalizingComparisonDoesNotWidenIdentity() { - // 不同的分组即使做简繁归一也不能互相匹配,否则频道会被塞进错误的组。 - assertFalse(new Group("线路").matchesName("頻道")); - assertFalse(new Group("频道").matchesName("線路")); - } - - @Test - public void equalityIgnoresDisplayName() { - Group simplified = new Group("线路"); - Group traditional = new Group("线路"); - - Trans.setTraditional(false); - simplified.trans(); - Trans.setTraditional(true); - traditional.trans(); - - assertEquals(simplified, traditional); - assertEquals("线路", traditional.getName()); - assertNotEquals(simplified.getDisplayName(), traditional.getDisplayName()); - } - - @Test - public void displayNameIsNotPartOfSerialization() throws Exception { - // transient 同时意味着:gson 不会把译文组名写进导出的直播源 JSON, - // 本次修复因此不需要任何数据迁移或格式升级。 - Field field = Group.class.getDeclaredField("displayName"); - assertTrue("displayName 必须是 transient", Modifier.isTransient(field.getModifiers())); - assertFalse("displayName 不能是静态字段", Modifier.isStatic(field.getModifiers())); - - Group group = new Group("线路"); - Trans.setTraditional(true); - group.trans(); - String json = new Gson().toJson(group); - assertTrue(json.contains("线路")); - assertFalse("译文不应进入序列化结果", json.contains("線路")); - assertEquals("线路", new Gson().fromJson(json, Group.class).getName()); - } - - @Test - public void browseTreeUsesDisplayNameForTitleButIdentityNameForId() throws Exception { - // 浏览树的 id 必须用身份名(要能长期持久化、并和 Live.keep 写的那一套对齐), - // 标题必须用展示名,否则 Group.trans() 改为只写 displayName 后,繁体界面下的 - // 直播分组名会回退成未翻译的原文(可见回退)。 - java.nio.file.Path moduleRelative = java.nio.file.Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "browse", "LiveBrowse.java"); - java.nio.file.Path path = java.nio.file.Files.exists(moduleRelative) ? moduleRelative : java.nio.file.Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "browse", "LiveBrowse.java"); - String source = new String(java.nio.file.Files.readAllBytes(path), java.nio.charset.StandardCharsets.UTF_8); - - assertTrue("浏览树 id 必须继续用身份名 group.getName()", - source.contains("BrowseTree.folder(LIVE_GROUP + group.getName(), group.getDisplayName())")); - } - - /** - * 用与真实调用链一致的顺序复现“记住上次频道”:解析期 {@code Group.trans()} 先跑, - * 播放期再 {@code Live.keep()} 拼键。 - */ - private static String keepKey(boolean traditional) { - Trans.setTraditional(traditional); - Group group = new Group("线路"); - group.trans(); - Channel channel = Channel.create("频道"); - channel.getUrls().add("http://example.com/1.m3u8"); - channel.group(group); - return new Live("央视", "http://example.com/live.m3u").keep(channel).getKeep(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/HistoryPlaybackTest.java b/app/src/test/java/com/fongmi/android/tv/bean/HistoryPlaybackTest.java index e856a2d9b9..f682d5e134 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/HistoryPlaybackTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/HistoryPlaybackTest.java @@ -130,8 +130,6 @@ public class HistoryPlaybackTest { @Test public void playbackEpisodeMatchAllowsUnknownSeasonWhenTmdbEpisodeNumberMatches() { - // 季号任一侧未知(-1)时,无法断定这是同集的不同版本,必须保留集号容错: - // URL 与源站集名都不同也仍视为同一集,否则跨源续播会失效。 Episode unknownSeason = Episode.create("源站第2集", "old-url"); unknownSeason.setTmdbEpisode(new TmdbEpisode(2, "", "", "", "", 0, 0, 0, -1)); Episode knownSeason = Episode.create("第2集", "new-url"); @@ -140,41 +138,6 @@ public class HistoryPlaybackTest { assertTrue(knownSeason.matchesPlayback(unknownSeason)); } - @Test - public void playbackEpisodeMatchRejectsDifferentVariantOnlyWhenSeasonIsConfirmed() { - // 季号两侧都已知且相等、且调用方确认同线路(历史 URL 仍能定位到本线路条目): - // 才允许按 URL/源站条目文本区分同集多版本。 - Episode firstVersion = tmdbVersion("url-v1"); - Episode secondVersion = tmdbVersion("url-v2"); - Episode secondVersionAgain = tmdbVersion("url-v2"); - - assertFalse(secondVersion.matchesPlayback(firstVersion, true)); - assertFalse(secondVersion.matches(firstVersion)); - assertTrue(secondVersionAgain.matchesPlayback(secondVersion)); - assertTrue(secondVersionAgain.matchesPlayback(secondVersion, true)); - } - - @Test - public void playbackEpisodeMatchKeepsCrossLineToleranceForDifferentUrls() { - // 换线路/换源或源站刷新后 URL 必然变化:单参容错必须保留,否则跨线路续播会丢失进度。 - Episode lineA = tmdbVersion("url-line-a"); - Episode lineB = tmdbVersion("url-line-b"); - - assertTrue(lineB.matchesPlayback(lineA)); - assertTrue(lineA.matchesPlayback(lineB)); - } - - @Test - public void playbackEpisodeMatchAllowsUnknownSeasonWhenNoVersionInfoCanSeparateThem() { - // 双方都带 TMDB 集号但没有任何版本信息(URL 与源站名均为空)时,只能视为同一集。 - Episode unknownSeason = Episode.create("", ""); - unknownSeason.setTmdbEpisode(new TmdbEpisode(2, "", "", "", "", 0, 0, 0, -1)); - Episode knownSeason = Episode.create("", ""); - knownSeason.setTmdbEpisode(new TmdbEpisode(2, "", "", "", "", 0, 0, 202, 2)); - - assertTrue(knownSeason.matchesPlayback(unknownSeason)); - } - @Test public void playbackEpisodeMatchRejectsDifferentEpisode() { Episode saved = Episode.create("第9集", "old-url"); @@ -274,43 +237,6 @@ public class HistoryPlaybackTest { assertEquals(0, copy.getTmdbEpisodeNumber()); } - @Test - public void unmappedEpisodeKeepsTheConfirmedSeasonWhenItsNumberIsKnown() { - // 有 TMDB 身份后上游解析不出 TMDB 集号(tmdbEpisode==null)但源站集号可解析: - // 不得把已确认季号抹成 0,否则 FollowingDao 的季号精确匹配落空、追更进度永不前进。 - History history = history("site@@vod@@1", "示例剧", "第5集", "url-5", 120_000, 300_000); - history.setTmdbEpisodePosition(3, 5); - Episode episode = Episode.create("第6集", "url-6"); - - assertTrue(history.setTmdbEpisodePosition(episode)); - - assertNull(episode.getTmdbEpisode()); - assertEquals(3, history.getTmdbSeasonNumber()); - assertEquals(6, history.getTmdbEpisodeNumber()); - } - - @Test - public void unmappedEpisodeOnFreshHistoryKeepsTheSeasonUnknownInsteadOfZero() { - // 本记录从未写过季集号(裸 0/0)时不能假装成特别篇第 N 集,只能记为未知季。 - History history = history("site@@vod@@1", "示例剧", "第5集", "url-5", 120_000, 300_000); - - assertTrue(history.setTmdbEpisodePosition(Episode.create("第5集", "url-5"))); - - assertEquals(-1, history.getTmdbSeasonNumber()); - assertEquals(5, history.getTmdbEpisodeNumber()); - } - - @Test - public void unmappedEpisodeWithUnresolvableNumberStillClearsThePosition() { - History history = history("site@@vod@@1", "示例剧", "第5集", "url-5", 120_000, 300_000); - history.setTmdbEpisodePosition(3, 5); - - history.setTmdbEpisodePosition(Episode.create("下一集", "url-6")); - - assertEquals(0, history.getTmdbSeasonNumber()); - assertEquals(0, history.getTmdbEpisodeNumber()); - } - @Test public void tmdbEpisodePositionPreservesUnknownSeason() { History history = history("site@@vod@@1", "示例剧", "第2集", "url-2", 120_000, 300_000); @@ -707,60 +633,6 @@ public class HistoryPlaybackTest { assertEquals(0, result.getTmdbSeasonNumber()); } - @Test - public void findPlaybackCandidateKeepsSameKeyEpisodeAfterSeasonBindingDrift() { - History drifted = history("site@@vod@@drift", "示例剧", "第5集", "s1e5-url", 90_000, 300_000); - drifted.setTmdbEpisodePosition(1, 5); - Episode current = Episode.create("第5集", "s1e5-url"); - - History result = History.findPlaybackCandidate( - "site@@vod@@drift", List.of(drifted), List.of(flag(current)), 2); - - assertEquals(90_000, result.getPosition()); - assertEquals(1, result.getTmdbSeasonNumber()); - } - - @Test - public void rebindClearsEpisodeUrlWhenRouteChangesWithoutMatchingEpisode() { - History source = history("site-a@@vod@@1", "示例剧", "第5集", "old-url", 90_000, 300_000); - source.setVodFlag("线路A"); - Flag target = new Flag("线路B"); - target.getEpisodes().add(Episode.create("第9集", "new-url-9")); - - History rebound = History.findPlaybackCandidate( - "site-b@@vod@@2", List.of(source), List.of(target)); - - assertEquals("线路B", rebound.getVodFlag()); - assertEquals("", rebound.getEpisodeUrl()); - } - - @Test - public void rebindKeepsEpisodeUrlWhenSameRouteStillCannotBindTheEpisode() { - History source = history("site@@vod@@1", "示例剧", "第5集", "old-url", 90_000, 300_000); - source.setVodFlag("线路A"); - Flag route = new Flag("线路A"); - route.getEpisodes().add(Episode.create("第9集", "new-url-9")); - - History rebound = History.findPlaybackCandidate( - source.getKey(), List.of(source), List.of(route)); - - assertEquals("线路A", rebound.getVodFlag()); - assertEquals("old-url", rebound.getEpisodeUrl()); - } - - @Test - public void rebindKeepsEpisodeUrlWhenTheRecordHadNoRouteToBeginWith() { - History source = history("site@@vod@@legacy", "示例剧", "第5集", "legacy-url", 90_000, 300_000); - Flag route = new Flag("线路A"); - route.getEpisodes().add(Episode.create("第9集", "new-url-9")); - - History rebound = History.findPlaybackCandidate( - source.getKey(), List.of(source), List.of(route)); - - assertEquals("线路A", rebound.getVodFlag()); - assertEquals("legacy-url", rebound.getEpisodeUrl()); - } - @Test public void findPlaybackCandidateKeepsSameKeyLegacyCompatibilityWhenTargetSeasonIsKnown() { History legacy = history("site-a@@vod@@season2", "示例剧", "第5集", "old-url", 90_000, 300_000); @@ -870,13 +742,6 @@ public class HistoryPlaybackTest { assertFalse(unset.isEndingReached(2_580_000, 2_700_000)); } - /** 同一季同一集(S2E2)下、仅 URL 不同的同集版本。 */ - private static Episode tmdbVersion(String url) { - Episode episode = Episode.create("源站第2集", url); - episode.setTmdbEpisode(new TmdbEpisode(2, "", "", "", "", 0, 0, 202, 2)); - return episode; - } - private static History history(String key, String name, String remarks, String episodeUrl, long position, long duration) { History history = new History(); history.setKey(key); diff --git a/app/src/test/java/com/fongmi/android/tv/bean/HistoryPushKeyStabilityTest.java b/app/src/test/java/com/fongmi/android/tv/bean/HistoryPushKeyStabilityTest.java deleted file mode 100644 index 6bc6325a0a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/HistoryPushKeyStabilityTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.fongmi.android.tv.bean; - -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * 网盘(push)历史记录的 key 必须稳定。 - * - *

push 的 key 第二段就是那条播放地址本身(SiteApi#pushDetail 把 id 原样塞进 Vod), - * 而从历史进入播放正是靠 {@link History#getVodId()} 反解这一段。普通站点的 vodId 是稳定的 - * 条目 id,迁移无害;push 迁移会把记录改写成详情阶段解析出的另一条地址,那条地址往往带 - * 时效,下次从历史打开必然失败,用户只能回网盘重新找。 - */ -public class HistoryPushKeyStabilityTest { - - @Test - public void pushHistoryIsExcludedFromKeyMigration() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/bean/History.java"); - - int replace = source.indexOf("public void replace(String key)"); - int guard = source.indexOf("if (isPushHistory()) return;", replace); - int firstWrite = source.indexOf("String previous = getKey();", replace); - - assertTrue("replace must exist", replace > 0); - assertTrue("push records must bail out of key migration", guard > replace); - assertTrue("the guard must run before any migration work", guard < firstWrite); - } - - @Test - public void pushExemptionsStayConsistentAcrossMergeAndMigration() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/bean/History.java"); - - // 按名合并早已对 push 豁免(多个网盘链接常同名);key 迁移是同一类问题的另一面, - // 两处必须共用同一个判据,避免其中一处被改动时另一处静默失配 - assertTrue("the push predicate must be shared", source.contains("private boolean isPushHistory()")); - assertTrue("name merging must stay exempt", source.contains("return !isPushHistory();")); - assertTrue("the predicate must key off the push site prefix", - source.contains("key.startsWith(SiteApi.PUSH + AppDatabase.SYMBOL)")); - } - - @Test - public void pushKeysSurviveTheVodIdRoundTrip() { - // 迁移被挡住的前提是 key 保持原样,反解才能拿回可用地址 - History history = new History(); - history.setKey("push_agent@@@https://pan.quark.cn/s/eab19bde98be@@@48"); - - assertTrue("a push key must still resolve to its playable url", - history.getVodId().startsWith("https://pan.quark.cn/")); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of(path.substring("app/".length())), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/HistorySeasonSnapshotProjectionTest.java b/app/src/test/java/com/fongmi/android/tv/bean/HistorySeasonSnapshotProjectionTest.java deleted file mode 100644 index 192fe03278..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/HistorySeasonSnapshotProjectionTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.fongmi.android.tv.bean; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.history.HistoryDisplayPolicy; - -import org.junit.Test; - -import java.util.List; - -public class HistorySeasonSnapshotProjectionTest { - - @Test - public void ownSeasonSnapshotSupersedesOlderUnknownHistoryRoute() { - History unknown = history("tv", 88, 100, "site-a@@@vod-a"); - unknown.setCid(7); - unknown.setTmdbEpisodePositionWithUnknownSeason(1); - unknown.setPosition(296); - unknown.setDuration(2_276_000); - TmdbSeasonProgress snapshot = TmdbSeasonProgress.of( - 7, "tv", 88, 6, 1, 1_080_193, 2_276_000, unknown.getKey()); - snapshot.updatedAt = 300; - - List result = HistoryDisplayPolicy.project(List.of(unknown), List.of(snapshot), true); - - assertEquals(1, result.size()); - assertEquals(6, result.get(0).getTmdbSeasonNumber()); - assertEquals(1_080_193, result.get(0).getPosition()); - } - - @Test - public void movieRouteDoesNotConsumeTvSeasonSnapshotWithCollidingIdentity() { - History movie = history("movie", 88, 100, "site-a@@@vod-a"); - movie.setCid(7); - movie.setPosition(120); - TmdbSeasonProgress tvSnapshot = TmdbSeasonProgress.of( - 7, "tv", 88, 6, 1, 1_080_193, 2_276_000, movie.getKey()); - tvSnapshot.updatedAt = 300; - - List result = HistoryDisplayPolicy.project(List.of(movie), List.of(tvSnapshot), true); - - assertEquals(1, result.size()); - assertEquals(120, result.get(0).getPosition()); - assertEquals(0, result.get(0).getTmdbSeasonNumber()); - } - - @Test - public void newerUnknownHistoryRouteStaysBesideOldSnapshot() { - History unknown = history("tv", 88, 300, "site-a@@@vod-a"); - unknown.setCid(7); - unknown.setTmdbEpisodePositionWithUnknownSeason(1); - TmdbSeasonProgress snapshot = TmdbSeasonProgress.of( - 7, "tv", 88, 6, 1, 1_080_193, 2_276_000, unknown.getKey()); - snapshot.updatedAt = 100; - - List result = HistoryDisplayPolicy.project(List.of(unknown), List.of(snapshot), true); - - assertEquals(2, result.size()); - assertTrue(result.stream().anyMatch(item -> item.getTmdbSeasonNumber() == -1)); - assertTrue(result.stream().anyMatch(item -> item.getTmdbSeasonNumber() == 6)); - } - - @Test - public void explicitUnknownSeasonPreservedWhenCanonicalEpisodeNumberIsKnown() { - History history = new History(); - history.setTmdbEpisodePosition(6, 12); - - assertTrue(history.setTmdbEpisodePositionWithUnknownSeason(13)); - - assertEquals(-1, history.getTmdbSeasonNumber()); - assertEquals(13, history.getTmdbEpisodeNumber()); - } - - @Test - public void nullEpisodeStillClearsCanonicalPosition() { - History history = new History(); - history.setTmdbEpisodePosition(6, 12); - - history.setTmdbEpisodePosition(null); - - assertEquals(0, history.getTmdbSeasonNumber()); - assertEquals(0, history.getTmdbEpisodeNumber()); - } - - private static History history(String mediaType, int tmdbId, long createTime, String key) { - History history = new History(); - history.setKey(key); - history.setMediaType(mediaType); - history.setTmdbId(tmdbId); - history.setCreateTime(createTime); - return history; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/HistorySubtitleSourceTest.java b/app/src/test/java/com/fongmi/android/tv/bean/HistorySubtitleSourceTest.java deleted file mode 100644 index 78c483893d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/HistorySubtitleSourceTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.fongmi.android.tv.bean; - -import com.fongmi.android.tv.playback.SubtitleSource; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; - -public class HistorySubtitleSourceTest { - - @Test - public void copyCarriesSubtitleSource() { - History history = new History(); - history.setKey("site@@@vod@@@1"); - history.setSubtitleSourceObject(source()); - - History copy = history.copy(); - - assertEquals(history.getSubtitleSource(), copy.getSubtitleSource()); - assertNotNull(copy.getSubtitleSourceObject()); - assertEquals("/storage/emulated/0/a.srt", copy.getSubtitleSourceObject().getUrl()); - } - - @Test - public void unusableSourceIsStoredAsEmptyString() { - History history = new History(); - history.setSubtitleSourceObject(source()); - - history.setSubtitleSourceObject(null); - - assertEquals("", history.getSubtitleSource()); - assertNull(history.getSubtitleSourceObject()); - } - - @Test - public void dirtyColumnValueDegradesToNoPreference() { - History history = new History(); - history.setSubtitleSource("{not json"); - - assertNull(history.getSubtitleSourceObject()); - // 脏数据保留在列里不算问题,读不出来即等于无偏好。 - assertEquals("{not json", history.getSubtitleSource()); - } - - @Test - public void defaultHistoryHasNoSubtitleSource() { - History history = new History(); - - assertEquals("", history.getSubtitleSource()); - assertNull(history.getSubtitleSourceObject()); - } - - private static SubtitleSource source() { - return SubtitleSource.of( - Sub.create("简体中文", "/storage/emulated/0/a.srt", "zh", "application/x-subrip"), - "https://site/ep12.m3u8"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/HistoryTest.java b/app/src/test/java/com/fongmi/android/tv/bean/HistoryTest.java index 8f45e829b3..b4f9b210f7 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/HistoryTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/HistoryTest.java @@ -3,8 +3,6 @@ package com.fongmi.android.tv.bean; import org.junit.Test; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; public class HistoryTest { @@ -31,50 +29,4 @@ public class HistoryTest { assertEquals("https://pan.quark.cn/s/eab19bde98be", history.getVodId()); } - - @Test - public void isSameContent_keepsUnchangedCopy() { - History history = coverHistory(); - - assertTrue(history.isSameContent(history.copy())); - } - - @Test - public void isSameContent_refreshesPositionWithoutTimestampChange() { - History history = coverHistory(); - History updated = history.copy(); - updated.setPosition(90000); - - assertFalse(history.isSameContent(updated)); - } - - @Test - public void isSameContent_refreshesDurationWithoutTimestampChange() { - History history = coverHistory(); - History updated = history.copy(); - updated.setDuration(60000); - - assertFalse(history.isSameContent(updated)); - } - - @Test - public void isSameContent_refreshesEpisodeWithoutTimestampChange() { - History history = coverHistory(); - History updated = history.copy(); - updated.setVodRemarks("Episode 2"); - - assertFalse(history.isSameContent(updated)); - } - - private static History coverHistory() { - History history = new History(); - history.setKey("test@@@cover"); - history.setVodName("Title"); - history.setVodPic("cover.jpg"); - history.setVodRemarks("Episode 1"); - history.setPosition(30000); - history.setDuration(120000); - history.setCreateTime(1000); - return history; - } } diff --git a/app/src/test/java/com/fongmi/android/tv/bean/LiveDisplayNameTest.java b/app/src/test/java/com/fongmi/android/tv/bean/LiveDisplayNameTest.java deleted file mode 100644 index d465bee160..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/LiveDisplayNameTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.fongmi.android.tv.bean; - -import com.github.catvod.utils.Trans; - -import org.junit.After; -import org.junit.Test; - -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -/** - * 直播源的“持久化身份”与“界面显示名”必须分离。 - *

- * 场景来源:用户把界面语言切到繁体中文后,已保存的收藏/屏蔽/上次播放频道静默失效。 - * 根因是 {@link Live#trans()} 旧实现直接改写 {@link Live#name},而 name 同时是 Room 主键、 - * {@code Config.home}、spider 缓存键与 {@link Live#equals} 的依据——繁体态写入的是繁体名, - * 简体态写入的是简体名,同一频道在库里分裂成两行,且 {@code LiveConfig.finishLive()} 用 - * 当前语言的 name 去匹配库里的另一语言 name 永远落空。 - *

- * 因此这里锁死:简繁转换只允许写 displayName,绝不允许改 name。 - */ -public class LiveDisplayNameTest { - - @After - public void tearDown() { - Trans.setTraditional(null); - } - - private static Live live(String name) { - return new Live(name, "http://example.com/live.m3u"); - } - - @Test - public void traditionalConversionKeepsIdentityName() { - Live live = live("频道"); - Trans.setTraditional(true); - live.trans(); - - // 身份名保持简体原文,否则 Room 主键/Config.home/equals 会随语言漂移。 - assertEquals("频道", live.getName()); - // 展示名走繁体,UI 仍然看到繁体。 - assertEquals("頻道", live.getDisplayName()); - } - - @Test - public void displayNameFallsBackToIdentityNameWhenNotTranslated() { - Live live = live("频道"); - assertEquals("频道", live.getDisplayName()); - - // 跟随系统/简体时 trans() 直接短路,displayName 保持为空。 - Trans.setTraditional(false); - live.trans(); - assertEquals("频道", live.getDisplayName()); - } - - @Test - public void identityEqualitySurvivesLanguageSwitch() { - Live simplified = live("频道"); - Live traditional = live("频道"); - - Trans.setTraditional(false); - simplified.trans(); - Trans.setTraditional(true); - traditional.trans(); - - // 同一个直播源在两种语言下必须是同一个对象身份,否则 distinct()/toMap() 会把它拆开。 - assertEquals(simplified, traditional); - assertEquals("频道", traditional.getName()); - assertNotEquals(simplified.getDisplayName(), traditional.getDisplayName()); - } - - @Test - public void differentSourcesStayDifferentAfterTranslation() { - Live first = live("频道一"); - Live second = live("频道二"); - Trans.setTraditional(true); - first.trans(); - second.trans(); - - assertNotEquals(first, second); - assertEquals("頻道一", first.getDisplayName()); - assertEquals("頻道二", second.getDisplayName()); - } - - @Test - public void displayNameIsNotPartOfPersistenceOrSerialization() throws Exception { - // transient 同时意味着:gson 不会把译文名写进配置导出/备份 JSON,Room 也不会为它建列, - // 所以本次修复不需要新增数据库迁移。 - Field field = Live.class.getDeclaredField("displayName"); - assertTrue("displayName 必须是 transient", Modifier.isTransient(field.getModifiers())); - assertFalse("displayName 不能是持久化字段", Modifier.isStatic(field.getModifiers())); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/PlaybackSelectionLocatorSourceTest.java b/app/src/test/java/com/fongmi/android/tv/bean/PlaybackSelectionLocatorSourceTest.java deleted file mode 100644 index f1e203aaaa..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/PlaybackSelectionLocatorSourceTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.fongmi.android.tv.bean; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class PlaybackSelectionLocatorSourceTest { - - @Test - public void bothPlayersLocateSeamlessEpisodeByIdentityFirst() throws Exception { - for (String mode : new String[]{"mobile", "leanback"}) { - String body = methodBody(readVideo(mode), "private boolean seamless(Flag flag)"); - assertTrue(mode + " must resolve the history episode identity first", body.contains("Episode episode = mHistory == null ? null : flag.find(mHistory.getEpisode(), true);")); - assertTrue(mode + " must keep the scraped mark only as a fallback", body.contains("if (episode == null && !getMark().isEmpty()) episode = flag.find(getMark(), false);")); - assertFalse(mode + " must not branch on an empty mark before the identity lookup", body.contains("getMark().isEmpty() ?")); - assertFalse(mode + " must not resolve by remarks text without the episode url", body.contains("flag.find(mHistory.getVodRemarks(), false)")); - assertTrue(mode + " must keep the existing playback guard", body.contains("(isPlaybackFlag(flag) && episode.isSelected())")); - } - } - - @Test - public void mobileSeamlessOnlyRewritesHistoryUrlForTheSameIdentity() throws Exception { - String body = methodBody(readVideo("mobile"), "private boolean seamless(Flag flag)"); - assertTrue("mobile must gate the url write on the history identity", body.contains("boolean sameHistoryIdentity = TextUtils.isEmpty(mHistory.getEpisodeUrl()) || episode.matchesPlayback(mHistory.getEpisode(), flag.containsEpisodeUrl(mHistory.getEpisode()));")); - assertTrue("mobile must write the url only through that gate", body.contains("if (sameHistoryIdentity) mHistory.setEpisodeUrl(episode.getUrl());")); - assertFalse("mobile must not write the resolved url unconditionally", body.contains("\n mHistory.setEpisodeUrl")); - assertTrue("the identity gate must be evaluated before any history field is written", body.indexOf("boolean sameHistoryIdentity") < body.indexOf("mHistory.setVodRemarks(")); - assertTrue(body.contains("onItemClick(episode);")); - } - - @Test - public void mobileSelectedPositionUsesLeanbackThreeLevelFallback() throws Exception { - String mobile = methodBody(readVideo("mobile"), "private int getSelectedEpisodePosition(List items)"); - String leanback = methodBody(readVideo("leanback"), "private int getSelectedEpisodePosition(List episodes)"); - assertTrue("mobile must keep the url exact match channel", mobile.contains("if (!TextUtils.equals(historyEpisode.getUrl(), items.get(i).getUrl())) continue;")); - assertTrue("mobile must keep the tmdb number cross check", mobile.contains("!items.get(i).matchesNumber(historyEpisode)) continue;")); - assertTrue("mobile must keep the playback identity fallback", mobile.contains("if (items.get(i).matchesPlayback(historyEpisode)) return i;")); - assertTrue("mobile must keep the history short circuit", mobile.contains("if (mHistory == null) return 0;")); - assertTrue("mobile must keep the empty url break", mobile.contains("if (TextUtils.isEmpty(historyEpisode.getUrl())) break;")); - assertTrue("url exact match must precede the identity fallback", mobile.indexOf("matchesNumber(historyEpisode)") < mobile.indexOf("matchesPlayback(historyEpisode)")); - assertTrue("the identity fallback must precede the default position", mobile.indexOf("matchesPlayback(historyEpisode)") < mobile.lastIndexOf("return 0;")); - assertTrue("leanback must keep the tmdb number cross check", leanback.contains("!episodes.get(i).matchesNumber(historyEpisode)) continue;")); - assertTrue("leanback must keep the playback identity fallback", leanback.contains("if (episodes.get(i).matchesPlayback(historyEpisode)) return i;")); - } - - @Test - public void fastTmdbPlaybackNoLongerFallsBackToTheFirstItem() throws Exception { - String source = readVideo("leanback"); - assertTrue(methodBody(source, "private Flag findFastTmdbPlaybackFlag(Vod item)").contains("return findIntentPlaybackFlag(")); - assertTrue(methodBody(source, "private Episode findFastTmdbPlaybackEpisode(Flag flag)").contains("return findIntentPlaybackEpisode(")); - String pending = methodBody(source, "private void startPendingFastTmdbPlayback()"); - assertTrue("the unresolved fast playback must keep the normal chain fallback", pending.contains("if (flag == null || episode == null || TextUtils.isEmpty(episode.getUrl())) {") && pending.contains("applyCachedTmdbDetailNextFrame(item);")); - } - - @Test - public void intentPlaybackEpisodeFallsBackToTheHistoryIdentity() throws Exception { - String body = methodBody(readVideo("leanback"), "private Episode findIntentPlaybackEpisode(Flag flag, String playName, String playUrl)"); - int url = body.indexOf("if (!TextUtils.isEmpty(playUrl)) {"); - int name = body.indexOf("Episode episode = flag.find(playName, true);"); - int history = body.indexOf("return mHistory == null ? null : flag.find(mHistory.getEpisode(), true);"); - assertTrue("url exact match must come first", url >= 0 && url < name); - assertTrue("the play name must be tried before the history rescue", name > 0 && name < history); - assertTrue("the history episode identity must be the last channel", history > 0); - } - - private static String readVideo(String mode) throws Exception { - return read("app/src/" + mode + "/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("missing method: " + signature, start >= 0); - int open = source.indexOf('{', start + signature.length()); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return source.substring(open, i + 1); - } - throw new AssertionError("unbalanced method body: " + signature); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/ShortDramaConfigTest.java b/app/src/test/java/com/fongmi/android/tv/bean/ShortDramaConfigTest.java deleted file mode 100644 index f126b04406..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/ShortDramaConfigTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.fongmi.android.tv.bean; - -import com.google.gson.Gson; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class ShortDramaConfigTest { - - @Test - public void explicitEmptyRulesDisableDefaults() { - ShortDramaConfig config = new Gson().fromJson( - "{\"configured\":true,\"enabledSites\":[],\"disabledSites\":[]}", - ShortDramaConfig.class); - config.sanitize(); - - assertTrue(config.isConfigured()); - assertEquals("", config.getDisplayRules()); - assertFalse(config.isSiteEnabled("nodejs_short", "[短]短剧站")); - } - - @Test - public void missingConfigurationStillUsesDefaults() { - ShortDramaConfig config = new ShortDramaConfig().sanitize(); - - assertFalse(config.isConfigured()); - assertEquals(Arrays.asList("[短]", "短剧"), ShortDramaConfig.defaultRules()); - assertTrue(config.isSiteEnabled("nodejs_short", "[短]短剧站")); - } - - @Test - public void dialogDoesNotRecreateDefaultsAfterRemovingAllRules() throws Exception { - // 只对可执行代码断言:注释里提到这些符号(本文件的注释就会)不应影响契约。 - String source = codeOnly(read("app/src/main/java/com/fongmi/android/tv/ui/dialog/ShortDramaSourceDialog.java")); - - // 三个「空规则不得回填默认」的站点:show() 打开弹窗时、showSiteManage() 勾选态计算时、 - // updateChipsDisplay() 画 chip 时。不钉具体写法(`new ArrayList<>(...)` 这类拷贝是可选的), - // 只钉语义不变量:默认规则只能从两处合法入口进入。 - assertTrue(source.contains("tempEnabledRules = new ArrayList<>(config.isConfigured()")); - // 兜底本身必须彻底消失:`tempEnabledRules.isEmpty()` 曾经是「用户清空 -> 默认规则复活」 - // 的判定式,而 defaultRulesText() 正是它回填的内容。 - assertFalse("空规则判定式必须已从对话框中移除", - source.contains("tempEnabledRules.isEmpty()")); - assertFalse("对话框不得再用 defaultRulesText() 回填默认规则", - source.contains("defaultRulesText")); - // defaultRules() 只有两个合法入口:① 未配置过时 show() 的初始值;② 用户主动点「恢复默认」。 - // 第三个入口就意味某条路径在用户清空后又把默认规则塞了回来——正是本缺陷的形态。 - assertTrue("未配置过时初始值必须回到默认规则(configured 为 false 的归一分支)", - source.contains(": ShortDramaConfig.defaultRules());")); - assertTrue("「恢复默认」按钮必须真的写回默认规则", - source.contains("tempEnabledRules.addAll(ShortDramaConfig.defaultRules());")); - assertTrue("默认规则不得出现第三个入口(每条路径都要尊重用户清空)", - count(source, "ShortDramaConfig.defaultRules()") <= 2); - } - - private static int count(String source, String needle) { - int total = 0; - for (int index = source.indexOf(needle); index >= 0; index = source.indexOf(needle, index + needle.length())) total++; - return total; - } - - /** 去掉注释,让源码契约匹配可执行代码,而不是描述这些符号的说明文字。 */ - private static String codeOnly(String source) { - StringBuilder out = new StringBuilder(source.length()); - int index = 0; - while (index < source.length()) { - char current = source.charAt(index); - char next = index + 1 < source.length() ? source.charAt(index + 1) : '\0'; - if (current == '/' && next == '/') { - while (index < source.length() && source.charAt(index) != '\n') index++; - } else if (current == '/' && next == '*') { - index += 2; - while (index + 1 < source.length() && !(source.charAt(index) == '*' && source.charAt(index + 1) == '/')) index++; - index = Math.min(source.length(), index + 2); - } else { - out.append(current); - index++; - } - } - return out.toString(); - } - - private static String read(String file) throws Exception { - Path root = Files.exists(Path.of("app")) ? Path.of("") : Path.of(".."); - return Files.readString(root.resolve(file), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/SiteTransBracketTest.java b/app/src/test/java/com/fongmi/android/tv/bean/SiteTransBracketTest.java deleted file mode 100644 index 2c0bb14158..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/SiteTransBracketTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.fongmi.android.tv.bean; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import com.github.catvod.utils.Trans; - -import org.junit.After; -import org.junit.Test; - -/** - * 站点名里的方括号标签是功能性标识(分组名、音频源/短剧/小说/漫画/TMDB 站点规则), - * 不能跟着界面语言变形,否则繁体界面下 "荐片[采][广]" 会变成 "薦片[采][廣]", - * 标签与用户写的规则不再互等,已保存的分组顺序也会被打散。 - */ -public class SiteTransBracketTest { - - @After - public void tearDown() { - Trans.setTraditional(null); - } - - private static Site site(String name) { - Site site = new Site(); - site.setKey("fixture"); - site.setName(name); - return site; - } - - @Test - public void bracketTagKeepsItsOriginalCharactersWhileTextIsTranslated() { - Trans.setTraditional(true); - Site site = site("荐片[采][广]").trans(); - assertEquals("薦片[采][广]", site.getName()); - } - - @Test - public void translatedTagNeverAppearsInTranslatedName() { - Trans.setTraditional(true); - String name = site("荐片[采][广]").trans().getName(); - assertTrue(name.contains("[广]")); - assertFalse(name.contains("[廣]")); - } - - @Test - public void fullWidthBracketTagsAreKeptToo() { - Trans.setTraditional(true); - assertEquals("木偶「盘」誇克", site("木偶「盘」夸克").trans().getName()); - assertEquals("【漫】漫畫站", site("【漫】漫画站").trans().getName()); - } - - @Test - public void nameWithoutTagIsStillFullyTranslated() { - Trans.setTraditional(true); - assertEquals("電影天堂", site("电影天堂").trans().getName()); - } - - @Test - public void tagsThatLookTheSameInBothScriptsStayUntouched() { - Trans.setTraditional(true); - assertEquals("貝樂虎[官]", site("贝乐虎[官]").trans().getName()); - assertEquals("熱播[采]", site("热播[采]").trans().getName()); - assertEquals("咪咕音樂[音]", site("咪咕音乐[音]").trans().getName()); - } - - @Test - public void textOutsideTagsIsStillTranslated() { - Trans.setTraditional(true); - assertEquals("[官]中國站", site("[官]中国站").trans().getName()); - } - - @Test - public void unclosedTagFallsBackToTranslatedText() { - Trans.setTraditional(true); - assertEquals("測試[未閉合", site("测试[未闭合").trans().getName()); - } - - @Test - public void simplifiedModeKeepsNameUnchanged() { - Trans.setTraditional(false); - assertEquals("荐片[采][广]", site("荐片[采][广]").trans().getName()); - } - - @Test - public void userRuleWrittenWithSimplifiedTagStillMatchesTranslatedName() { - Trans.setTraditional(true); - String name = site("荐片[采][广]").trans().getName().toLowerCase(java.util.Locale.ROOT); - // 与 AudioConfig / ShortDramaConfig / TmdbConfig 相同的子串匹配语义 - assertTrue(name.contains("[广]")); - assertFalse(name.contains("[廣]")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/StableInterfaceIdentityAcceptanceTest.java b/app/src/test/java/com/fongmi/android/tv/bean/StableInterfaceIdentityAcceptanceTest.java deleted file mode 100644 index d553223e22..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/StableInterfaceIdentityAcceptanceTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.fongmi.android.tv.bean; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -public class StableInterfaceIdentityAcceptanceTest { - - @Test - public void addressChangesKeepTheExistingConfigIdentity() throws Exception { - String model = read("app/src/main/java/com/fongmi/android/tv/bean/Config.java"); - String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java"); - String leanback = read("app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java"); - - assertTrue(model.contains("public Config replaceUrl(String oldUrl, String newUrl)")); - assertTrue(model.contains("ensureInterfaceKey();")); - assertTrue(mobile.contains("Config.find(config.getId()).url(url)")); - assertTrue(leanback.contains("Config.find(config.getId()).url(text)")); - assertFalse(mobile.contains("Config.delete(ori, type);\n return Config.find(ori, type).url(url)")); - } - - @Test - public void failoverAndRemoteUpdatesStayInsideOneInterface() throws Exception { - String vod = read("app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java"); - String remote = read("app/src/main/java/com/fongmi/android/tv/remote/RemoteConfigOps.java"); - - assertTrue(vod.contains("List addresses = config.getUrls();")); - assertTrue(vod.contains("round.origin.url(loaded.getUrl()).update();")); - assertTrue(remote.contains("findByInterfaceKey(interfaceKey, type)")); - assertTrue(remote.contains("mergeUrls(urls(payload)).url(url)")); - assertTrue(remote.contains("if (config == null) config = Config.create(type);")); - assertFalse(remote.contains("if (config == null) config = Config.find(url, type);")); - } - - @Test - public void migrationAndBackupRestorePreserveStableMapping() throws Exception { - String migration = read("app/src/main/java/com/fongmi/android/tv/db/Migrations.java"); - String backup = read("app/src/main/java/com/fongmi/android/tv/bean/Backup.java"); - String identity = read("app/src/main/java/com/fongmi/android/tv/playback/PlaybackConfigIdentity.java"); - - assertTrue(migration.contains("MIGRATION_45_46")); - assertTrue(migration.contains("CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_interfaceKey_type`")); - assertTrue(migration.contains("UPDATE Config SET interfaceKey")); - assertTrue(backup.contains("findByInterfaceKey(item.getInterfaceKey(), item.getType())")); - assertTrue(backup.contains("item.interfaceKey(current.getInterfaceKey()).mergeUrls(current.getUrls())")); - assertTrue(identity.contains("normalizeKey(config.getInterfaceKey())")); - assertTrue(identity.contains("keyForUrl(config.getUrl())")); - } - - @Test - public void playbackHistoryPersistsItsStableInterfaceBinding() throws Exception { - String history = read("app/src/main/java/com/fongmi/android/tv/bean/History.java"); - String migration = read("app/src/main/java/com/fongmi/android/tv/db/Migrations.java"); - String database = read("app/src/main/java/com/fongmi/android/tv/db/AppDatabase.java"); - - assertTrue(history.contains("@SerializedName(\"sourceBindingKey\")")); - assertTrue(history.contains("@ColumnInfo(defaultValue = \"\")\n private String sourceBindingKey;")); - assertTrue(migration.contains("MIGRATION_46_47")); - assertTrue(migration.contains("ALTER TABLE History ADD COLUMN `sourceBindingKey` TEXT DEFAULT ''")); - assertTrue(database.contains("VERSION = 47")); - assertTrue(database.contains("addMigrations(Migrations.MIGRATION_46_47)")); - } - - private static String read(String file) throws Exception { - Path root = Files.exists(Path.of("app")) ? Path.of("") : Path.of(".."); - return Files.readString(root.resolve(file), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigCustomRouteModeTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigCustomRouteModeTest.java deleted file mode 100644 index f1492a5569..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigCustomRouteModeTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.fongmi.android.tv.bean; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** 验证自定义线路保存→重读后 imageRouteMode/apiRouteMode 仍为 custom 且不被误判为内置线路。 */ -public class TmdbConfigCustomRouteModeTest { - - private static TmdbConfig roundTrip(String json) { - return TmdbConfig.objectFrom(TmdbConfig.objectFrom(json).toJson()); - } - - @Test - public void customImageRoute_survivesRoundTrip_withHttpsUrl() { - TmdbConfig config = roundTrip("{\"imageBase\":\"https://images.mytest.example.com\",\"imageRouteMode\":\"custom\",\"imageRouteConfigured\":true,\"apiKey\":\"k\"}"); - // sanitize 会把 /t/p/w342 附加到自定义图片 base 上,getImageHost 则剥回主机 - assertEquals("https://images.mytest.example.com/t/p/w342", config.getConfiguredImageBase()); - assertFalse(config.isImageAuto()); - assertFalse(config.isImageRouteDefault()); - assertEquals("https://images.mytest.example.com", config.getImageHost()); - } - - @Test - public void customImageRoute_survivesRoundTrip_withBareDomain() { - TmdbConfig config = roundTrip("{\"imageBase\":\"images.mytest.example.com\",\"imageRouteMode\":\"custom\",\"imageRouteConfigured\":true,\"apiKey\":\"k\"}"); - assertFalse(config.isImageAuto()); - assertFalse(config.isImageRouteDefault()); - assertEquals("https://images.mytest.example.com", config.getImageHost()); - } - - @Test - public void customApiRoute_survivesRoundTrip() { - TmdbConfig config = roundTrip("{\"apiBase\":\"https://api.mytest.example.com\",\"apiRouteMode\":\"custom\",\"apiRouteConfigured\":true,\"apiKey\":\"k\"}"); - assertFalse(config.isApiAuto()); - assertFalse(config.isApiRouteDefault()); - assertEquals("https://api.mytest.example.com", config.getApiHost()); - } - - @Test - public void itv666ImageRoute_roundTrip_staysCustom() { - // 从 itv666 切到自定义的保存路径: onSave 写入自定义 base + custom mode - TmdbConfig config = roundTrip("{\"imageBase\":\"http://tmdb.itv666.cc\",\"imageRouteMode\":\"custom\",\"imageRouteConfigured\":true,\"apiKey\":\"k\"}"); - assertEquals("http://tmdb.itv666.cc/t/p/w342", config.getConfiguredImageBase()); - assertEquals("http://tmdb.itv666.cc", config.getImageHost()); - } - - @Test - public void wsrvWrapperImageRoute_roundTrip() { - TmdbConfig config = roundTrip("{\"imageBase\":\"https://wsrv.nl/?url=https://image.tmdb.org\",\"imageRouteMode\":\"custom\",\"imageRouteConfigured\":true,\"apiKey\":\"k\"}"); - assertTrue(config.getConfiguredImageBase().startsWith("https://wsrv.nl/?url=")); - assertFalse(config.isImageAuto()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigEffectiveTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigEffectiveTest.java deleted file mode 100644 index 7dd3131542..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigEffectiveTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.fongmi.android.tv.bean; - -import com.fongmi.android.tv.api.config.SubscriptionTmdbCredentialStore; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbConfigEffectiveTest { - - private static final String SOURCE_KEY = "0123456789abcdef0123456789abcdef"; - - @Before - @After - public void clearStore() { - SubscriptionTmdbCredentialStore.clear(); - } - - @Test - public void userCredentialAlwaysWinsAndTransientKeyIsNeverPersisted() { - TmdbConfig configured = TmdbConfig.objectFrom("{\"apiBase\":\"https://mirror.example.com/3\",\"apiKey\":\"user-key-0123456789\",\"language\":\"en-US\"}"); - SubscriptionTmdbCredentialStore.Scope scope = SubscriptionTmdbCredentialStore.beginSubscription(1, "https://source.example/config", "test"); - assertTrue(SubscriptionTmdbCredentialStore.accept(SOURCE_KEY, 1, "https://source.example/config", scope.getEpoch(), "site", "vod")); - - TmdbConfig effective = TmdbConfig.effective(configured, SubscriptionTmdbCredentialStore.snapshot(scope)); - - assertEquals("user-key-0123456789", effective.getApiKey()); - assertEquals("https://mirror.example.com/3", effective.getApiBase()); - assertEquals(TmdbConfig.ORIGIN_USER, effective.getCredentialOrigin()); - assertFalse(effective.isTransientSubscriptionCredential()); - assertFalse(effective.toJson().contains(SOURCE_KEY)); - } - - @Test - public void transientCredentialUsesOfficialBaseAndKeepsDisplayConfiguration() { - TmdbConfig configured = TmdbConfig.objectFrom("{\"apiBase\":\"https://mirror.example.com/3\",\"language\":\"en-US\",\"imageBase\":\"https://images.example.com/t/p/w342\"}"); - SubscriptionTmdbCredentialStore.Scope scope = SubscriptionTmdbCredentialStore.beginSubscription(2, "https://source.example/config", "test"); - assertTrue(SubscriptionTmdbCredentialStore.accept(SOURCE_KEY, 2, "https://source.example/config", scope.getEpoch(), "site", "vod")); - - TmdbConfig effective = TmdbConfig.effective(configured, SubscriptionTmdbCredentialStore.snapshot(scope)); - - assertEquals(SOURCE_KEY, effective.getApiKey()); - assertEquals("https://api.tmdb.org/3", effective.getApiBase()); - assertEquals("en-US", effective.getLanguage()); - assertEquals("https://images.example.com/t/p/w342", effective.getImageBase()); - assertTrue(effective.isTransientSubscriptionCredential()); - assertEquals(scope.getEpoch(), effective.getCredentialScopeEpoch()); - assertFalse(effective.getCredentialSubscriptionKey().isEmpty()); - assertFalse(effective.toJson().contains(SOURCE_KEY)); - } - - @Test - public void staleOrMissingSnapshotProducesNoCredential() { - TmdbConfig configured = TmdbConfig.objectFrom("{\"language\":\"zh-CN\"}"); - SubscriptionTmdbCredentialStore.Scope scope = SubscriptionTmdbCredentialStore.beginSubscription(3, "https://source.example/config", "test"); - assertTrue(SubscriptionTmdbCredentialStore.accept(SOURCE_KEY, 3, "https://source.example/config", scope.getEpoch(), "site", "vod")); - SubscriptionTmdbCredentialStore.clear(); - - TmdbConfig effective = TmdbConfig.effective(configured, SubscriptionTmdbCredentialStore.snapshot(scope)); - - assertFalse(effective.isReady()); - assertEquals(TmdbConfig.ORIGIN_USER, effective.getCredentialOrigin()); - assertFalse(effective.toJson().contains(SOURCE_KEY)); - } - - @Test - public void officialApiBaseWhitelistRejectsCredentialCaptureSurfaces() { - assertTrue(TmdbConfig.isOfficialApiBase("https://api.tmdb.org/3")); - assertTrue(TmdbConfig.isOfficialApiBase("https://api.themoviedb.org/3/")); - assertFalse(TmdbConfig.isOfficialApiBase("http://api.tmdb.org/3")); - assertFalse(TmdbConfig.isOfficialApiBase("https://api.tmdb.org:8443/3")); - assertFalse(TmdbConfig.isOfficialApiBase("https://mirror.example.com/3")); - assertFalse(TmdbConfig.isOfficialApiBase("https://api.tmdb.org/3/proxy")); - assertFalse(TmdbConfig.isOfficialApiBase("https://user@api.tmdb.org/3")); - } - @Test - public void transientCredentialDisablesConfiguredProxy() { - TmdbConfig configured = TmdbConfig.objectFrom("{\"proxyBase\":\"https://mirror.example.com\",\"language\":\"en-US\"}"); - SubscriptionTmdbCredentialStore.Scope scope = SubscriptionTmdbCredentialStore.beginSubscription(4, "https://source.example/config", "test"); - assertTrue(SubscriptionTmdbCredentialStore.accept(SOURCE_KEY, 4, "https://source.example/config", scope.getEpoch(), "site", "vod")); - - TmdbConfig effective = TmdbConfig.effective(configured, SubscriptionTmdbCredentialStore.snapshot(scope)); - - assertEquals("https://api.tmdb.org/3", effective.getApiBase()); - assertFalse(effective.isProxyEnabled()); - } - -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigImageHostTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigImageHostTest.java index e8be9f30e2..149d18b2a4 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigImageHostTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigImageHostTest.java @@ -3,7 +3,6 @@ package com.fongmi.android.tv.bean; import org.junit.Test; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class TmdbConfigImageHostTest { @@ -69,56 +68,4 @@ public class TmdbConfigImageHostTest { assertEquals("https://img.mirror.test", reloaded.getImageHost()); assertTrue(saved.contains("img.mirror.test")); } - @Test - public void proxyBaseOverridesApiRequestBaseWithoutChangingConfiguredBase() { - TmdbConfig config = TmdbConfig.objectFrom("{\"apiBase\":\"https://api.tmdb.org/3\",\"proxyBase\":\"https://proxy.example.com/tmdb\",\"apiKey\":\"test-key\"}"); - - assertEquals("https://proxy.example.com/tmdb/3", config.getApiBase()); - assertEquals("https://api.tmdb.org/3", config.getConfiguredApiBase()); - assertEquals("https://proxy.example.com/tmdb", config.getProxyBase()); - assertEquals("https://proxy.example.com/tmdb", config.getImageHost()); - assertEquals("https://images.tmdb.org", config.getConfiguredImageHost()); - assertTrue(config.isProxyEnabled()); - } - - @Test - public void explicitImageDomainWinsOverProxyRoute() { - TmdbConfig config = TmdbConfig.objectFrom("{\"apiBase\":\"https://api.tmdb.org/3\",\"proxyBase\":\"https://proxy.example.com\",\"imageBase\":\"https://cdn.example.com/t/p/w342\",\"apiKey\":\"test-key\"}"); - - assertEquals("https://proxy.example.com/3", config.getApiBase()); - assertEquals("https://cdn.example.com/t/p/w342", config.getImageBase()); - assertEquals("https://cdn.example.com", config.getImageHost()); - } - - @Test - public void customImageAndAutoRoutesArePersistedAndResolved() { - TmdbConfig custom = TmdbConfig.objectFrom("{\"apiBase\":\"https://custom-api.example/3\",\"imageBase\":\"https://custom-image.example/t/p/w342\",\"apiKey\":\"k\"}"); - assertEquals("https://custom-api.example/3", custom.getApiBase()); - assertEquals("https://custom-image.example/t/p/w342", custom.getImageBase()); - - TmdbConfig auto = TmdbConfig.objectFrom("{\"apiBase\":\"https://api.tmdb.org/3\",\"apiAuto\":true,\"imageBase\":\"https://images.tmdb.org/t/p/w342\",\"imageAuto\":true,\"apiKey\":\"k\"}"); - assertTrue(auto.isApiAuto()); - assertTrue(auto.isImageAuto()); - assertEquals("https://api.tmdb.org/3", auto.getApiBase()); - assertTrue(auto.getImageBase().contains("/t/p/w342")); - } - - @Test - public void legacyOfficialRoutesArePresentedAsDefaultAutoCandidates() { - TmdbConfig config = TmdbConfig.objectFrom("{\"apiBase\":\"https://api.tmdb.org/3\",\"imageBase\":\"https://images.tmdb.org/t/p/w342\",\"apiKey\":\"k\"}"); - - assertTrue(config.isApiRouteDefault()); - assertTrue(config.isImageRouteDefault()); - assertTrue(config.isApiAuto()); - assertTrue(config.isImageAuto()); - } - - @Test - public void explicitOfficialDirectRouteStaysDirectAfterNewSave() { - TmdbConfig config = TmdbConfig.objectFrom("{\"apiBase\":\"https://api.tmdb.org/3\",\"apiAuto\":false,\"apiRouteConfigured\":true,\"apiRouteMode\":\"direct\",\"imageBase\":\"https://images.tmdb.org/t/p/w342\",\"imageAuto\":false,\"imageRouteConfigured\":true,\"imageRouteMode\":\"direct\",\"apiKey\":\"k\"}"); - - assertFalse(config.isApiAuto()); - assertFalse(config.isImageAuto()); - } - } diff --git a/app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheHantTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheHantTest.java deleted file mode 100644 index d7d757704c..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheHantTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.fongmi.android.tv.bean; - -import com.github.catvod.utils.Trans; - -import org.junit.After; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -/** - * TMDB 匹配缓存的键必须与界面语言无关。 - * - *

场景来源:用户反馈「繁体语言的场景下会导致 TMDB 匹配失败」。根因之一在缓存: - * 站源标题在繁体界面下会被 {@code Vod.trans()} 改成繁体,而缓存键是用标题算出来的。 - * 键若不归一,同一部作品在简体界面写入、切到繁体界面就读不回来,每次切语言都要重新匹配一遍, - * 表现为「繁体下TMDB 一直匹配不上」。 - * - *

归一方向只能用 {@code Trans.s2t}(简→繁):反向 {@code t2s} 在「多简对一繁」的 - * 10 组字上会坍缩,把「余罪 / 强势 / 线上」这类常用简体词改写成异体字。 - */ -public class TmdbMatchCacheHantTest { - - @After - public void tearDown() { - Trans.setTraditional(null); - } - - @Test - public void simplifiedWriteIsReadableFromTraditionalTitle() { - TmdbMatchCache cache = new TmdbMatchCache(); - - cache.put("site", "vod", "庆余年", item(100, "庆余年")); - - assertEquals(100, cache.find("site", "vod", "慶餘年").getTmdbId()); - } - - @Test - public void traditionalWriteIsReadableFromSimplifiedTitle() { - TmdbMatchCache cache = new TmdbMatchCache(); - - Trans.setTraditional(true); - cache.put("site", "vod", "慶餘年", item(100, "庆余年")); - - assertEquals(100, cache.find("site", "vod", "庆余年").getTmdbId()); - } - - @Test - public void manualMatchSurvivesScriptSwitch() { - TmdbMatchCache cache = new TmdbMatchCache(); - - cache.putManual("site", "vod", java.util.List.of("庆余年 更新至第30集"), item(100, "庆余年")); - - // 校验手动选择不会被自动匹配覆盖,且繁体标题仍读得到。 - assertNotNull(cache.findManual("site", "vod", "慶餘年")); - assertEquals(100, cache.findManual("site", "vod", "慶餘年").getTmdbId()); - } - - @Test - public void legacyCacheWrittenBeforeNormalizationIsStillReadable() { - TmdbMatchCache cache = new TmdbMatchCache(); - - // 旧版本写入的键没有做简繁归一,这里手工铺一条旧格式条目模拟升级前的缓存。 - cache.getItems().put("site@@@vod@@@慶餘年", TmdbMatchCache.Entry.from(item(200, "庆余年"), "zh-CN")); - - assertEquals(200, cache.find("site", "vod", "慶餘年").getTmdbId()); - assertEquals(200, cache.find("site", "vod", "庆余年").getTmdbId()); - } - - @Test - public void differentTitlesStaySeparatedAfterNormalization() { - TmdbMatchCache cache = new TmdbMatchCache(); - - cache.put("site-a", "vod-a", "庆余年", item(100, "庆余年")); - cache.put("site-b", "vod-b", "赘婿", item(200, "赘婿")); - - assertEquals(100, cache.find("site-a", "vod-a", "慶餘年").getTmdbId()); - assertEquals(200, cache.find("site-b", "vod-b", "贅婿").getTmdbId()); - } - - private static TmdbItem item(int id, String title) { - return new TmdbItem(id, "tv", title, "", "", "", ""); - } -} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheTest.java index e610e35e7e..c37f986c40 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheTest.java @@ -2,12 +2,8 @@ package com.fongmi.android.tv.bean; import org.junit.Test; -import java.util.List; - import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; public class TmdbMatchCacheTest { @@ -53,96 +49,6 @@ public class TmdbMatchCacheTest { assertEquals(200, cache.find("site-b", "vod-b", "重名剧").getTmdbId()); } - @Test - public void displayItemHidesMismatchedLanguageButKeepsIdentity() { - TmdbMatchCache.Entry entry = TmdbMatchCache.Entry.from(item(200, "中文标题"), "zh-Hans"); - - assertEquals("中文标题", entry.toDisplayItem("zh-CN").getTitle()); - assertEquals(200, entry.toDisplayItem("en-US").getTmdbId()); - assertEquals("", entry.toDisplayItem("en-US").getTitle()); - assertEquals("中文标题", entry.toDisplayItem("").getTitle()); - - TmdbMatchCache.Entry traditional = TmdbMatchCache.Entry.from(item(201, "繁體標題"), "zh-TW"); - assertEquals("繁體標題", traditional.toDisplayItem("zh-TW").getTitle()); - assertEquals("", traditional.toDisplayItem("zh-CN").getTitle()); - } - - @Test - public void manualMatchSurvivesTitleRewriteAndBlocksAutomaticOverwrite() { - TmdbMatchCache cache = new TmdbMatchCache(); - - cache.put("site", "vod", "凡人修仙传 更新至120集", item(100, "自动猜错的剧")); - cache.putManual("site", "vod", List.of("凡人修仙传 更新至120集"), item(200, "凡人修仙传")); - - // 手动选择在原站源标题下读回。 - assertEquals(200, cache.find("site", "vod", "凡人修仙传 更新至120集").getTmdbId()); - assertTrue(cache.isManual("site", "vod", "凡人修仙传 更新至120集")); - // 富集把 vod.getName() 改写成 TMDB 标题后依然读回。 - assertEquals(200, cache.find("site", "vod", "凡人修仙传").getTmdbId()); - assertEquals(200, cache.findManual("site", "vod", "凡人修仙传").getTmdbId()); - - // 后续自动匹配不得覆盖。 - cache.put("site", "vod", "凡人修仙传 更新至120集", item(300, "又一个自动猜测")); - cache.put("site", "vod", item(300, "又一个自动猜测")); - assertEquals(200, cache.find("site", "vod", "凡人修仙传 更新至120集").getTmdbId()); - } - - @Test - public void manualMatchAnchorDoesNotLeakToOtherTitlesSharingOneVodId() { - TmdbMatchCache cache = new TmdbMatchCache(); - - cache.putManual("玩偶|虎斑2", "shared", List.of("云秀行(真彩)"), item(100, "云秀行")); - cache.put("玩偶|虎斑2", "shared", "千香(真彩)", item(200, "千香")); - - assertEquals(100, cache.find("玩偶|虎斑2", "shared", "云秀行(真彩)").getTmdbId()); - assertEquals(200, cache.find("玩偶|虎斑2", "shared", "千香(真彩)").getTmdbId()); - assertFalse(cache.isManual("玩偶|虎斑2", "shared", "千香(真彩)")); - } - - @Test - public void manualAnchorWithoutUsableAliasDoesNotBecomeWildcard() { - TmdbMatchCache cache = new TmdbMatchCache(); - - // 站源标题与 TMDB 标题都被清洗成空串时,锚点不能退化成通配符去匹配别的作品。 - cache.putManual("site", "shared", List.of("【】"), item(100, "【】")); - - assertNull(cache.findManual("site", "shared", "另一部剧")); - assertFalse(cache.isManual("site", "shared", "另一部剧")); - // 精确的站源标题键仍然可用。 - assertEquals(100, cache.find("site", "shared", "【】").getTmdbId()); - } - - @Test - public void manualMatchOutranksAutomaticGuessInTheGlobalTitleScope() { - TmdbMatchCache cache = new TmdbMatchCache(); - - // 自动匹配把同名作品写进全局标题域,别的站源会沿用它。 - cache.put("site-a", "vod-a", "凡人修仙传", item(100, "凡人修仙传")); - assertEquals(100, cache.find("site-b", "vod-b", "凡人修仙传").getTmdbId()); - - // 用户手动纠正后,跨站沿用应改读手动结论,而不是降级成"谁都读不到"的冲突标记。 - cache.putManual("site-c", "vod-c", List.of("凡人修仙传"), item(200, "凡人修仙传")); - assertEquals(200, cache.find("site-b", "vod-b", "凡人修仙传").getTmdbId()); - - // 反向:后来的自动猜测不得把手动结论冲掉。 - cache.put("site-d", "vod-d", "凡人修仙传", item(300, "凡人修仙传")); - assertEquals(200, cache.find("site-b", "vod-b", "凡人修仙传").getTmdbId()); - // "手动"的排他性只属于原条目,别的条目不会被误判为手动。 - assertFalse(cache.isManual("site-b", "vod-b", "凡人修仙传")); - } - - @Test - public void manualMatchIsReadableWithoutAnySourceTitle() { - TmdbMatchCache cache = new TmdbMatchCache(); - - cache.putManual("site", "vod", List.of("凡人修仙传 更新至120集"), item(200, "凡人修仙传")); - - // Intent 未带 name 时查询标题为空,仍应读回手动选择(沿用条目级语义)。 - assertEquals(200, cache.findManual("site", "vod", "").getTmdbId()); - assertTrue(cache.isManual("site", "vod", "")); - assertEquals(200, cache.find("site", "vod").getTmdbId()); - } - private static TmdbItem item(int id, String title) { return new TmdbItem(id, "tv", title, "", "", "", ""); } diff --git a/app/src/test/java/com/fongmi/android/tv/bean/TmdbSeasonMatchCacheHantTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbSeasonMatchCacheHantTest.java deleted file mode 100644 index 575c92c6cc..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/TmdbSeasonMatchCacheHantTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.fongmi.android.tv.bean; - -import com.github.catvod.utils.Trans; - -import org.junit.After; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; - -/** - * 分季缓存键必须与界面语言无关。 - * - *

场景来源:用户反馈「繁体语言的场景下会导致 TMDB 匹配失败」。站源标题在繁体界面下会被 - * {@code Vod.trans()} 逐字转成繁体,而分季缓存键是用站源标题算出来的。键若不归一, - * 同一部剧在简体界面保存的「分季结论」切到繁体界面就读不回来(反之亦然), - * 用户看到的就是「繁体的分季选择白做了 / 又要重新选一遍」。 - * - *

用例在改代码前必须失败:{@code sourceKey} 当时只做标点/大小写归一,不做简繁归一。 - */ -public class TmdbSeasonMatchCacheHantTest { - - @After - public void tearDown() { - Trans.setTraditional(null); - } - - @Test - public void simplifiedWriteIsReadableFromTraditionalTitle() { - TmdbSeasonMatchCache cache = new TmdbSeasonMatchCache(); - - cache.put("site", "vod", "庆余年", 100, "tv", 1, TmdbSeasonMatchCache.Mode.MANUAL_SEASON, "fp", 12, 12); - - assertNotNull("繁体标题必须能读回简体界面保存的分季结论", cache.find("site", "vod", "慶餘年", 100)); - assertEquals(1, cache.find("site", "vod", "慶餘年", 100).getSeasonNumber().intValue()); - } - - @Test - public void traditionalWriteIsReadableFromSimplifiedTitle() { - TmdbSeasonMatchCache cache = new TmdbSeasonMatchCache(); - - Trans.setTraditional(true); - cache.put("site", "vod", "慶餘年", 100, "tv", 1, TmdbSeasonMatchCache.Mode.MANUAL_SEASON, "fp", 12, 12); - Trans.setTraditional(null); - - assertNotNull("切回简体后仍必须读得到繁体界面保存的分季结论", cache.find("site", "vod", "庆余年", 100)); - } - - @Test - public void legacyKeyWrittenBeforeNormalizationIsStillReadable() { - TmdbSeasonMatchCache cache = new TmdbSeasonMatchCache(); - - // 旧版本写入的键没有做简繁归一,这里手工铺一条旧格式条目模拟升级前的缓存。 - cache.getItems().put("site@@@vod@@@庆余年", - TmdbSeasonMatchCache.Entry.create(200, "tv", 2, TmdbSeasonMatchCache.Mode.MANUAL_SEASON, "fp", 12, 12)); - - assertNotNull("升级前保存的分季结论不能一次性失效", cache.find("site", "vod", "慶餘年", 200)); - } - - @Test - public void flagScopedKeyIsAlsoLanguageIndependent() { - TmdbSeasonMatchCache cache = new TmdbSeasonMatchCache(); - - cache.put("site", "vod", "庆余年", "线路1", 100, "tv", 1, - TmdbSeasonMatchCache.Mode.MANUAL_SEASON, "fp", 12, 12); - - assertNotNull(cache.find("site", "vod", "庆余年", "線路1", 100)); - } - - @Test - public void differentVodsStaySeparatedAfterNormalization() { - TmdbSeasonMatchCache cache = new TmdbSeasonMatchCache(); - - cache.put("site-a", "vod-a", "庆余年", 100, "tv", 1, TmdbSeasonMatchCache.Mode.MANUAL_SEASON, "fp", 12, 12); - cache.put("site-b", "vod-b", "赘婿", 200, "tv", 1, TmdbSeasonMatchCache.Mode.MANUAL_SEASON, "fp", 12, 12); - - // 归一化不能把不同作品折叠到一起;tmdbId 不匹配时也必须读不到。 - assertNull(cache.find("site-a", "vod-a", "慶餘年", 200)); - assertNotNull(cache.find("site-b", "vod-b", "贅婿", 200)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/TmdbSourcePayloadTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbSourcePayloadTest.java deleted file mode 100644 index 1124bd07de..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/TmdbSourcePayloadTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.fongmi.android.tv.bean; - -import com.fongmi.android.tv.ui.helper.TmdbSourcePayloadParser; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; - -import org.junit.Test; - -import java.util.Set; - -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; - -public class TmdbSourcePayloadTest { - - private static final Gson GSON = new Gson(); - - @Test - public void gsonRoundTripKeepsDetailAsProtocolObject() { - Vod vod = GSON.fromJson(""" - {"vod_id":"movie-1","vod_name":"Example","tmdb":{ - "schema":1,"id":550,"media_type":"movie","season_number":0,"language":"zh-CN", - "complete":["core","images"],"detail":{"id":550,"title":"Fight Club","poster_path":"/poster.jpg"} - }} - """, Vod.class); - - assertNotNull(vod.getTmdb()); - assertEquals(550, vod.getTmdb().getTmdbId()); - assertEquals("movie", vod.getTmdb().getMediaType()); - assertEquals(550, vod.getTmdb().getDetail().id()); - assertEquals("Fight Club", vod.getTmdb().getDetail().title()); - - String encoded = GSON.toJson(vod); - assertTrue(encoded.contains("\"detail\":{")); - assertFalse(encoded.contains("detailJson")); - assertFalse(encoded.contains("sourceKind")); - } - - @Test - public void parserNormalizesIdentityCapabilitiesAndImages() { - TmdbSourcePayload raw = GSON.fromJson(""" - {"schema":1,"id":1399,"media_type":"TV","season_number":2,"language":" ZH-Hans ", - "fetched_at":"2026-09-19T00:00:00Z","complete":["images","season:02","unknown","images"], - "detail":{"id":1399,"season_number":2,"name":"Example","poster_path":"/p.jpg", - "backdrop_path":"http://example.test/b.jpg","images":{"posters":[]}, - "seasons":[{"season_number":2,"episodes":[]}]}} - """, TmdbSourcePayload.class); - - TmdbSourcePayload parsed = TmdbSourcePayloadParser.parse(raw); - - assertNotNull(parsed); - assertEquals("tv", parsed.getMediaType()); - assertEquals("zh-CN", parsed.getLanguage()); - assertEquals(Set.of("images", "season:2"), parsed.getComplete()); - assertEquals("/p.jpg", parsed.getDetail().posterPath()); - assertEquals("", parsed.getDetail().backdropPath()); - assertTrue(parsed.hasCapability("season:2")); - assertFalse(parsed.hasCapability("unknown")); - } - - @Test - public void parserKeepsUnknownLanguageEmptyAndNormalizesKnownAliases() { - TmdbSourcePayload raw = payload(550, "movie", 0, "{\"id\":550}"); - raw.setLanguage(" zh-sg "); - TmdbSourcePayload simplified = TmdbSourcePayloadParser.parse(raw); - assertEquals("zh-CN", simplified.getLanguage()); - - raw.setLanguage("zh-Hant"); - TmdbSourcePayload traditional = TmdbSourcePayloadParser.parse(raw); - assertEquals("zh-TW", traditional.getLanguage()); - - TmdbSourcePayload unknown = payload(550, "movie", 0, "{\"id\":550}"); - assertEquals("", unknown.getLanguage()); - } - - @Test - public void parserRejectsInvalidIdentityAndConflictingDetail() { - assertNull(TmdbSourcePayloadParser.parse(payload(0, "movie", 0, "{\"id\":550}"))); - assertNull(TmdbSourcePayloadParser.parse(payload(550, "music", 0, "{\"id\":550}"))); - assertNull(TmdbSourcePayloadParser.parse(payload(550, "movie", 1, "{\"id\":550}"))); - assertNull(TmdbSourcePayloadParser.parse(payload(550, "movie", 0, "{\"id\":551}"))); - assertNull(TmdbSourcePayloadParser.parse(payload(1399, "tv", 2, "{\"id\":1399,\"season_number\":1}"))); - } - - @Test - public void parserDropsBadTypesInvalidImagesAndOversizedCollections() { - JsonObject detail = new JsonObject(); - detail.addProperty("id", 550); - detail.addProperty("overview", 42); - detail.addProperty("poster_path", "poster.jpg"); - detail.addProperty("backdrop_path", "https://image.test/b.jpg"); - detail.addProperty("images", "not-an-object"); - JsonArray cast = new JsonArray(); - for (int i = 0; i <= TmdbSourcePayloadParser.MAX_ARRAY_ITEMS; i++) cast.add("actor-" + i); - JsonObject credits = new JsonObject(); - credits.add("cast", cast); - detail.add("credits", credits); - detail.addProperty("tagline", "x".repeat(TmdbSourcePayloadParser.MAX_STRING_BYTES + 1)); - - TmdbSourcePayload parsed = TmdbSourcePayloadParser.parse(payload(550, "movie", 0, detail.toString())); - - assertNotNull(parsed); - TmdbSourceDetail result = parsed.getDetail(); - assertEquals("", result.overview()); - assertEquals("", result.posterPath()); - assertEquals("https://image.test/b.jpg", result.backdropPath()); - assertNull(result.images()); - assertNull(result.credits()); - assertEquals("", result.tagline()); - } - - @Test - public void copyAndEqualityIgnoreInternalSourceKind() { - TmdbSourcePayload payload = payload(550, "movie", 0, "{\"id\":550}"); - payload.setSourceKind(TmdbSourcePayload.SOURCE_TMDB); - TmdbSourcePayload copy = payload.copy(); - copy.setSourceKind(TmdbSourcePayload.CACHE_TMDB); - - assertEquals(payload, copy); - assertEquals(payload.hashCode(), copy.hashCode()); - assertFalse(payload.getComplete() == copy.getComplete()); - } - - private static TmdbSourcePayload payload(int id, String mediaType, int seasonNumber, String detailJson) { - TmdbSourcePayload payload = new TmdbSourcePayload(); - payload.setSchema(TmdbSourcePayload.SCHEMA_VERSION); - payload.setTmdbId(id); - payload.setMediaType(mediaType); - payload.setSeasonNumber(seasonNumber); - payload.setDetailJson(detailJson); - return payload; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/UpdateOrderingTest.java b/app/src/test/java/com/fongmi/android/tv/bean/UpdateOrderingTest.java deleted file mode 100644 index 33e715e904..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/UpdateOrderingTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.fongmi.android.tv.bean; - -import com.fongmi.android.tv.BuildConfig; -import com.fongmi.android.tv.utils.AppVersion; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Reproduces the update loop seen on an Android 9 box: the installed beta build and the stable - * manifest carried the same {@code versionCode} and the stable one was older, so the app offered a - * downgrade, installed it, read the beta manifest again, and repeated forever. Both packages also - * disagreed about the database schema, so each pass destroyed the user's data. - */ -public class UpdateOrderingTest { - - private static final String OLDER_STABLE = "v5.6.0-202609221328"; - private static final String NEWER_BETA = "v5.6.0-beta-202610091755"; - - @Test - public void olderManifestWithTheSameVersionCodeIsNotAnUpdate() { - Update update = manifest("stable", OLDER_STABLE, BuildConfig.VERSION_CODE); - assertTrue(update.hasManifest()); - assertFalse("An older package of the same version code must not be offered as an update", - update.hasUpdate()); - } - - @Test - public void identicalManifestIsNotAnUpdate() { - Update update = manifest("stable", AppVersion.fullName(), BuildConfig.VERSION_CODE); - assertFalse(update.hasUpdate()); - } - - @Test - public void higherVersionCodeIsAlwaysAnUpdate() { - Update update = manifest("stable", "v0.0.1-202001010000", BuildConfig.VERSION_CODE + 1); - assertTrue(update.hasUpdate()); - } - - @Test - public void lowerVersionCodeIsNeverAnUpdate() { - Update update = manifest("stable", "v9.9.9-209901010000", BuildConfig.VERSION_CODE - 1); - assertFalse(update.hasUpdate()); - } - - @Test - public void manifestWithoutDownloadTargetIsNotAnUpdate() { - Update update = manifest("stable", "v9.9.9-209901010000", BuildConfig.VERSION_CODE + 1); - update.githubUrl = ""; - update.oci = null; - assertFalse(update.hasUpdate()); - } - - private static Update manifest(String channel, String name, int code) { - Update update = new Update(); - update.channel = channel; - update.name = name; - update.versionName = "5.6.0"; - update.code = code; - update.githubUrl = "https://example.invalid/" + name + ".apk"; - return update; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/UserAdRuleSourceTest.java b/app/src/test/java/com/fongmi/android/tv/bean/UserAdRuleSourceTest.java index 006fbc2e1d..3c9989a03b 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/UserAdRuleSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/UserAdRuleSourceTest.java @@ -1,8 +1,6 @@ package com.fongmi.android.tv.bean; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; import com.google.gson.Gson; @@ -28,22 +26,4 @@ public class UserAdRuleSourceTest { assertEquals("接口导入 · 我的接口 · 域名 0 · 广告规则 0 · 白名单 0", rule.getSummary()); } - - @Test - public void importedRuleActivationIsAnExplicitChoice() { - ImportedAdRuleCandidate candidate = new ImportedAdRuleCandidate(); - candidate.setSourceType("ads"); - - assertFalse(UserAdRule.fromImportedCandidate(candidate).isEnabled()); - assertTrue(UserAdRule.fromImportedCandidate(candidate, true).isEnabled()); - } - - @Test - public void interfaceSourceClassificationOnlyMatchesSupportedImportedTypes() { - assertTrue(UserAdRule.isInterfaceSource(UserAdRule.SOURCE_INTERFACE_ADS)); - assertTrue(UserAdRule.isInterfaceSource(UserAdRule.SOURCE_INTERFACE_RULE)); - assertFalse(UserAdRule.isInterfaceSource(UserAdRule.SOURCE_AI)); - assertFalse(UserAdRule.isInterfaceSource("interface_future")); - assertFalse(UserAdRule.isInterfaceSource(null)); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/bean/VodTmdbParcelableTest.java b/app/src/test/java/com/fongmi/android/tv/bean/VodTmdbParcelableTest.java deleted file mode 100644 index 19233cf743..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/bean/VodTmdbParcelableTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.fongmi.android.tv.bean; - -import com.fongmi.android.tv.utils.VodDetailCache; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -public class VodTmdbParcelableTest { - - @Test - public void vodParcelSourceTracksTmdbAndContentComparison() throws Exception { - String source = read(findAppPath().resolve(Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "bean", "Vod.java"))); - - assertTrue(source.contains("this.tmdb = in.readParcelable(TmdbSourcePayload.class.getClassLoader());")); - assertTrue(source.contains("dest.writeParcelable(this.tmdb, flags);")); - assertTrue(source.contains("Objects.equals(getTmdb(), other.getTmdb())")); - } - - @Test - public void oversizedPayloadUsesCacheKeyInsteadOfBinder() { - TmdbSourcePayload small = payload("{\"id\":550,\"title\":\"Small\"}"); - TmdbSourcePayload large = payload("{\"id\":550,\"overview\":\"" + "x".repeat(TmdbSourcePayload.MAX_PARCEL_BYTES) + "\"}"); - - assertFalse(small.requiresParcelCache()); - assertTrue(large.requiresParcelCache()); - - String key = large.putParcelCacheCopy(); - Vod cached = VodDetailCache.take(key); - - assertNotNull(cached); - assertNotNull(cached.getTmdb()); - assertEquals(550, cached.getTmdb().getTmdbId()); - assertTrue(cached.getTmdb().requiresParcelCache()); - } - - private static TmdbSourcePayload payload(String detailJson) { - TmdbSourcePayload payload = new TmdbSourcePayload(); - payload.setSchema(TmdbSourcePayload.SCHEMA_VERSION); - payload.setTmdbId(550); - payload.setMediaType("movie"); - payload.setDetailJson(detailJson); - return payload; - } - - private static Path findAppPath() { - return Files.exists(Path.of("src", "main")) ? Path.of(".") : Path.of("app"); - } - - private static String read(Path path) throws Exception { - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoCleanupPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoCleanupPolicyTest.java deleted file mode 100644 index 92559c4ce7..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoCleanupPolicyTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -public class CacheAutoCleanupPolicyTest { - - @Test - public void lowSpaceUses512MbOrTenPercentFloor() { - assertTrue(CacheAutoCleanupPolicy.isLowSpace(400L * 1024L * 1024L, 8L * 1024L * 1024L * 1024L)); - assertFalse(CacheAutoCleanupPolicy.isLowSpace(900L * 1024L * 1024L, 8L * 1024L * 1024L * 1024L)); - assertFalse(CacheAutoCleanupPolicy.isLowSpace(4L * 1024L * 1024L * 1024L, 20L * 1024L * 1024L * 1024L)); - } - - @Test - public void playingAlwaysDowngradesToLight() { - assertEquals(CacheCleanupMode.LIGHT, CacheAutoCleanupPolicy.cleanupMode(true, true)); - assertEquals(CacheCleanupMode.STANDARD, CacheAutoCleanupPolicy.cleanupMode(false, true)); - assertEquals(CacheCleanupMode.LIGHT, CacheAutoCleanupPolicy.cleanupMode(false, false)); - } - - @Test - public void lowSpaceNeedsTwoConsecutiveSamples() { - assertFalse(CacheAutoCleanupPolicy.shouldTrigger(1, false)); - assertTrue(CacheAutoCleanupPolicy.shouldTrigger(2, false)); - assertTrue(CacheAutoCleanupPolicy.shouldTrigger(0, true)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoPlanAndTempFamilyTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoPlanAndTempFamilyTest.java deleted file mode 100644 index 4271055127..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoPlanAndTempFamilyTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -/** - * Locks the two review findings fixed for the cache-management branch. - * - *

1. Automatic cleanup must honour the registry's {@code allowAutomaticCleanup} declaration - * instead of only skipping the diagnostic-log module.

- * - *

2. The temporary-file cleanup must delete exactly the family the inventory reports, instead of - * a second hand-written prefix list that covered fewer names than the registry.

- */ -public class CacheAutoPlanAndTempFamilyTest { - - @Test - public void automaticLightPlanKeepsOnlyRegistryApprovedModules() { - assertEquals(List.of(CacheModuleId.EPG, CacheModuleId.TEMP_FILES, CacheModuleId.LEGACY_FILES), - CacheScheduler.automaticPlan(CacheCleanupMode.LIGHT).modules()); - assertEquals(CacheCleanupMode.LIGHT, CacheScheduler.automaticPlan(CacheCleanupMode.LIGHT).mode()); - } - - /** - * The STANDARD tier is what the low-space automatic run uses. It contains GLIDE and - * WEBHOME_RAW for manual cleanup, but those entries declare {@code allowAutomaticCleanup=false} - * and therefore must not survive into an automatic plan. - */ - @Test - public void automaticStandardPlanDropsModulesThatForbidAutomaticCleanup() { - List modules = CacheScheduler.automaticPlan(CacheCleanupMode.STANDARD).modules(); - assertFalse("GLIDE declares allowAutomaticCleanup=false", modules.contains(CacheModuleId.GLIDE)); - assertFalse("WEBHOME_RAW declares allowAutomaticCleanup=false", modules.contains(CacheModuleId.WEBHOME_RAW)); - assertFalse(modules.contains(CacheModuleId.PLUGIN_SCRIPTS)); - assertFalse(modules.contains(CacheModuleId.DIAGNOSTIC_LOGS)); - assertFalse(modules.contains(CacheModuleId.UNCLASSIFIED)); - assertFalse(modules.contains(CacheModuleId.EXO)); - assertTrue(modules.contains(CacheModuleId.LYRICS)); - assertTrue(modules.contains(CacheModuleId.KARAOKE)); - assertTrue(modules.contains(CacheModuleId.WEBHOME_EXT)); - assertTrue(modules.contains(CacheModuleId.EPG)); - assertTrue(modules.contains(CacheModuleId.TEMP_FILES)); - assertTrue(modules.contains(CacheModuleId.LEGACY_FILES)); - } - - /** No automatic tier may ever contain a module the registry forbids for automatic cleanup. */ - @Test - public void everyAutomaticPlanRespectsTheRegistryDeclaration() { - for (CacheCleanupMode mode : CacheCleanupMode.values()) { - if (mode == CacheCleanupMode.MODULE) continue; - for (CacheModuleId id : CacheScheduler.automaticPlan(mode).modules()) { - assertTrue("automatic plan leaked " + id, CachePolicyEngine.allowsAutomatic(id)); - } - } - } - - @Test - public void automaticPlanOfNullFallsBackToLight() { - assertEquals(CacheScheduler.automaticPlan(CacheCleanupMode.LIGHT).modules(), - CacheScheduler.automaticPlan(null).modules()); - } - - /** - * The cleanup candidate set must be derived from the registry, so every name the inventory - * counts as a temporary file is also deletable. - */ - @Test - public void temporaryFamilyComesFromTheRegistryAndCoversReportedSuffixes() throws Exception { - File cache = cacheDir("temp-family"); - CacheCleanupManager.TemporaryFamily family = CacheCleanupManager.temporaryFamily(cache); - - assertTrue("update.apk is reported and must stay cleanable", family.matches("update.apk")); - assertTrue("a sync archive is reported by the .zip suffix", family.matches("webhtv-sync-123.zip")); - assertTrue("a login-state archive is reported by the .zip suffix", family.matches("webhtv-login-state-123.zip")); - assertTrue("a legacy backup archive is reported by the .zip suffix", family.matches("bak-20260928-1200.zip")); - assertTrue("goProxy.log is reported by the .log suffix", family.matches("goProxy.log")); - assertTrue(family.matches("pushed-123.apk")); - assertTrue(family.matches("pushed-url-123.apk")); - assertTrue(family.matches("partial.tmp")); - assertTrue(family.matches("apk-push-123.apk")); - } - - /** Owner-managed files the registry excludes stay out of the candidate set. */ - @Test - public void temporaryFamilyNeverClaimsProtectedOrUnrelatedNames() throws Exception { - File cache = cacheDir("temp-family-exclude"); - CacheCleanupManager.TemporaryFamily family = CacheCleanupManager.temporaryFamily(cache); - - assertFalse("the diagnostic log is excluded by the registry", - family.matches("webhtv-debug-log.txt")); - assertFalse(family.matches("mpv-playback-recovery.lock")); - assertFalse(family.matches("mpv-playback-recovery.state")); - assertFalse(family.matches("mpv-playback-recovery.result")); - assertFalse("a rotated diagnostic segment is not a temporary file", - family.matches("webhtv-debug-log.txt.1")); - assertFalse("the plugin audit trail is owned by its own module, not by temp files", - family.matches(com.fongmi.android.tv.plugin.PluginAuditStore.FILE_NAME)); - assertFalse("the rotated audit segment belongs to the same owner", - family.matches(com.fongmi.android.tv.plugin.PluginAuditStore.FILE_NAME + ".1")); - assertFalse(family.matches("unrelated.bin")); - assertFalse(family.matches("notes.txt")); - assertFalse(family.matches(null)); - } - - private static File cacheDir(String prefix) throws Exception { - Path base = Path.of(System.getProperty("java.io.tmpdir")).toRealPath(); - return Files.createTempDirectory(base, prefix).toFile(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheCleanupJournalTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheCleanupJournalTest.java deleted file mode 100644 index 281718ae23..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CacheCleanupJournalTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.util.List; - -public class CacheCleanupJournalTest { - - @Test - public void journalRecordCarriesReasonAndResultFacts() { - CacheCleanupRecord record = new CacheCleanupRecord(100, 25, "periodic", - CacheCleanupMode.LIGHT, CacheCleanupStatus.COMPLETED, - 2048, 512, 3, 1, List.of()); - - assertEquals(1536, record.releasedBytes()); - assertEquals("periodic", record.reason()); - assertEquals(CacheCleanupStatus.COMPLETED, record.status()); - assertTrue(record.deletedFiles() == 3); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheFullCleanupTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheFullCleanupTest.java deleted file mode 100644 index f750e7d236..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CacheFullCleanupTest.java +++ /dev/null @@ -1,253 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -/** - * Locks the two user-reported defects fixed for this branch. - * - *

1. The temporary-file row reported freshly written {@code webhtv-*.zip} / {@code update.apk} - * leftovers and then released nothing, because the row's own button applied the 24-hour retention - * meant for background runs. An explicit request must ignore that window while still protecting a - * transfer that is running right now.

- * - *

2. The settings row's plain click has to clear everything the way the pre-split one-key clear - * did, which needs a residual sweep over whatever no module claims.

- */ -public class CacheFullCleanupTest { - - private static final long NOW = 1_800_000_000_000L; - private static final long DAY_MS = 24L * 60L * 60L * 1000L; - - /** The reported defect: a fresh archive is exactly what the user asked to remove. */ - @Test - public void explicitRequestDeletesFreshTemporaryFiles() throws Exception { - File cache = cacheDir("temp-explicit"); - File archive = write(cache, "webhtv-sync-4242.zip", 4096); - File apk = write(cache, "update.apk", 2048); - archive.setLastModified(NOW - 60_000L); - apk.setLastModified(NOW - 60_000L); - - CacheCleanupManager.Outcome outcome = CacheCleanupManager.clearTemporaryFiles(cache, - CacheCleanupManager.explicitRetention(CacheCleanupMode.MODULE, DAY_MS), 0L, false, false, NOW); - - assertTrue(outcome.success()); - assertFalse("a fresh sync archive must not survive the row's own button", archive.exists()); - assertFalse("a fresh update download must not survive the row's own button", apk.exists()); - } - - /** - * The same files stay for a background or tiered run: only an explicit request is immediate. - * - *

The window is not written into the test, it is asked of the same decision the cleanup - * switch uses, so weakening that decision fails here instead of silently leaving the row's own - * button with a 24-hour window again.

- */ - @Test - public void backgroundRetentionStillKeepsFreshTemporaryFiles() throws Exception { - File cache = cacheDir("temp-retained"); - File archive = write(cache, "webhtv-sync-4242.zip", 4096); - archive.setLastModified(NOW - 60_000L); - - CacheCleanupManager.Outcome outcome = CacheCleanupManager.clearTemporaryFiles(cache, - CacheCleanupManager.explicitRetention(CacheCleanupMode.LIGHT, DAY_MS), 0L, false, false, NOW); - - assertTrue(outcome.success()); - assertTrue("an age-windowed run must keep a one-minute-old archive", archive.exists()); - } - - /** A transfer that is running right now survives even the immediate request. */ - @Test - public void runningTransferSurvivesTheExplicitRequest() throws Exception { - File cache = cacheDir("temp-in-use"); - File apk = write(cache, "update.apk", 2048); - File pushed = write(cache, "pushed-url-9.apk", 1024); - apk.setLastModified(NOW - 60_000L); - pushed.setLastModified(NOW - 60_000L); - - CacheCleanupManager.Outcome outcome = - CacheCleanupManager.clearTemporaryFiles(cache, 0L, 0L, true, true, NOW); - - assertTrue(outcome.success()); - assertTrue("the running updater keeps its download", apk.exists()); - assertTrue("the running push keeps its archive", pushed.exists()); - } - - /** - * The explicit request ignores the age window but not the module's own candidate set: a family - * the registry does not claim stays on disk even when nothing is in use. - */ - @Test - public void explicitRequestStillOnlyClaimsTheReportedFamily() throws Exception { - File cache = cacheDir("temp-foreign"); - File notes = write(cache, "notes.txt", 256); - File partial = write(cache, "attachment.zip.part", 512); - File archive = write(cache, "webhtv-sync-4242.zip", 2048); - notes.setLastModified(NOW - 60_000L); - partial.setLastModified(NOW - 60_000L); - archive.setLastModified(NOW - 60_000L); - - CacheCleanupManager.Outcome outcome = - CacheCleanupManager.clearTemporaryFiles(cache, 0L, 0L, false, false, NOW); - - assertTrue(outcome.success()); - assertTrue("a .txt note is not part of the temporary family", notes.exists()); - assertTrue("a partial download is not part of the temporary family", partial.exists()); - assertFalse("the module deletes exactly the family it reports", archive.exists()); - } - - /** The sweep removes what no module claims, including the scripts nobody is loading. */ - @Test - public void sweepRemovesLeftoversAndIdlePluginScripts() throws Exception { - File cache = cacheDir("sweep-leftovers"); - File leftover = write(cache, "unknown-owner.bin", 512); - File staleTree = new File(cache, "stale-dir"); - write(staleTree, "deep/data.bin", 128); - File idleScript = write(new File(cache, "js"), "idle-site.js", 256); - File activeScript = write(new File(cache, "js"), "active-site.js", 256); - - ArrayList warnings = new ArrayList<>(); - CacheCleanupManager.sweepResidual(cache, List.of(activeScript), false, false, warnings); - - assertFalse(leftover.exists()); - assertFalse("a tree no module owns is a leftover", staleTree.exists()); - assertFalse(idleScript.exists()); - assertTrue("the script of a running loader must survive", activeScript.exists()); - assertTrue("nothing in this scenario is a failure: " + warnings, warnings.isEmpty()); - } - - /** - * Every age-windowed module decides through one shared rule, so the reported defect can not - * come back at a single call site. The row's own button (MODULE) and the row's plain click - * (FULL) are explicit requests; the tiered and automatic runs keep the background window. - */ - @Test - public void onlyExplicitRequestsIgnoreTheBackgroundRetentionWindow() { - assertTrue(CacheCleanupManager.explicitRequest(CacheCleanupMode.MODULE)); - assertTrue(CacheCleanupManager.explicitRequest(CacheCleanupMode.FULL)); - assertFalse(CacheCleanupManager.explicitRequest(CacheCleanupMode.LIGHT)); - assertFalse(CacheCleanupManager.explicitRequest(CacheCleanupMode.STANDARD)); - assertFalse(CacheCleanupManager.explicitRequest(CacheCleanupMode.DEEP)); - - assertEquals(0L, CacheCleanupManager.explicitRetention(CacheCleanupMode.MODULE, DAY_MS)); - assertEquals(0L, CacheCleanupManager.explicitRetention(CacheCleanupMode.FULL, DAY_MS)); - assertEquals(DAY_MS, CacheCleanupManager.explicitRetention(CacheCleanupMode.LIGHT, DAY_MS)); - assertEquals(DAY_MS, CacheCleanupManager.explicitRetention(CacheCleanupMode.STANDARD, DAY_MS)); - assertEquals(DAY_MS, CacheCleanupManager.explicitRetention(CacheCleanupMode.DEEP, DAY_MS)); - } - - /** - * The two carve-outs the full clean documents: a transfer that is running right now, and the - * trees the caller preserved because a playback cache has to wait for playback to stop. - */ - @Test - public void sweepKeepsPreservedTreesTransfersAndRecoveryFiles() throws Exception { - File cache = cacheDir("sweep-carveouts"); - File playback = new File(cache, "exo"); - write(playback, "segment.bin", 4096); - File recovery = write(cache, "mpv-playback-recovery.state", 64); - File apk = write(cache, "update.apk", 2048); - File leftover = write(cache, "unknown-owner.bin", 512); - - ArrayList warnings = new ArrayList<>(); - CacheCleanupManager.sweepResidual(cache, List.of(playback), true, false, warnings); - - assertTrue("a deferred playback cache keeps its tree", new File(playback, "segment.bin").exists()); - assertTrue("the mpv recovery file is never swept", recovery.exists()); - assertTrue("the running updater keeps its download", apk.exists()); - assertFalse(leftover.exists()); - assertFalse("the skipped files must be reported: " + warnings, warnings.isEmpty()); - } - - /** - * Locks the two decisions the reported defects actually live at, not the helpers they call. - * - *

This class hands {@code clearTemporaryFiles} whichever window it wants to exercise, and - * {@code sweepResidual} is called directly, so the call sites inside {@code CacheCleanupManager} - * are never executed: reverting the temporary-file row to the 24-hour background window - the - * exact code path the user reported as "released nothing, deleted 0 files" - leaves every other - * test in this class green, and reverting the full-clean dispatch turns the settings row's - * plain click back into a tiered run. That is how both defects shipped. - * - *

The call sites can not be reached from a JVM test (they resolve - * {@code App.get().getCacheDir()}, {@code Updater.isDownloading()} and the player state), so - * they are locked the way this repository locks its other Android-only call sites: by reading - * the production source and asserting the decision is made by the shared rule instead of being - * written at the call site.

- */ - @Test - public void everyCleanupCallSiteUsesTheSharedRule() throws Exception { - String source = productionSource(); - - // 1. The settings row's plain click must dispatch to the full clean, not to a tiered module - // loop. (The gesture moved off the long press on 2026-10-21; the long press opens the panel.) - String run = method(source, "private static void run(CacheCleanupPlan plan, String reason,"); - assertTrue("FULL must select the full-clean path: " + run, - run.contains("if (plan.mode() == CacheCleanupMode.FULL) {")); - assertTrue("FULL must still reach cleanEverything", run.contains("cleanEverything(plan, progress)")); - - // 2. Each age-windowed module must take its window from the shared rule. - String execution = method(source, "private static Outcome executeCleanup(CacheModuleId id, CacheCleanupMode mode)"); - assertTrue("the module loop must ask the shared rule once: " + execution, - execution.contains("boolean now = explicitRequest(mode);")); - assertTrue(execution.contains("case LYRICS -> now")); - assertTrue(execution.contains("case KARAOKE -> now")); - assertTrue(execution.contains("case EPG -> now ? outcome(EpgParser.clearCache())")); - assertTrue(execution.contains("case PLUGIN_SCRIPTS -> clearPluginCache(explicitRetention(mode, retention),")); - assertTrue(execution.contains("case TEMP_FILES -> clearTemporaryFiles(cache, explicitRetention(mode, TEMP_RETENTION_MS), limit,")); - assertTrue(execution.contains("case LEGACY_FILES -> clearLegacyPaths(cache, mode);")); - assertFalse("the window must not be compared to a single mode again: " + execution, - execution.contains("== CacheCleanupMode.MODULE")); - - String legacy = method(source, "private static Outcome clearLegacyPaths(File cache, CacheCleanupMode mode)"); - assertTrue(legacy.contains("long retentionMs = explicitRetention(mode, LEGACY_RETENTION_MS);")); - - // 3. No call site may pass a background window as a literal. This is the assertion that - // fails if the temporary-file row is ever reverted to the 24-hour window. - for (String raw : (execution + legacy).split("\n")) { - String line = raw.trim(); - if (line.startsWith("//") || line.startsWith("*")) continue; - if (!line.contains("TEMP_RETENTION_MS") && !line.contains("LEGACY_RETENTION_MS")) continue; - assertTrue("a retention window is decided at the call site instead of by explicitRetention: " - + line, line.contains("explicitRetention(")); - } - } - - private static String method(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("missing production method: " + signature, start >= 0); - int end = source.indexOf("\n }\n", start); - assertTrue("unterminated production method: " + signature, end > start); - return source.substring(start, end); - } - - private static String productionSource() throws Exception { - Path relative = Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", - "cache", "CacheCleanupManager.java"); - Path path = Files.exists(relative) ? relative : Path.of("..").resolve(relative).normalize(); - return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } - - private static File write(File dir, String name, int size) throws Exception { - File file = new File(dir, name); - File parent = file.getParentFile(); - assertTrue("could not create " + parent, parent != null && (parent.isDirectory() || parent.mkdirs())); - Files.write(file.toPath(), new byte[size]); - return file; - } - - private static File cacheDir(String prefix) throws Exception { - Path base = Path.of(System.getProperty("java.io.tmpdir")).toRealPath(); - return Files.createTempDirectory(base, prefix).toFile(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheInventoryTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheInventoryTest.java deleted file mode 100644 index 15ab92393b..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CacheInventoryTest.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Set; - -public class CacheInventoryTest { - - @Test - public void percentageHandlesZeroSmallAndNormalValues() { - assertEquals("0%", CacheFormat.percent(0, 100)); - assertEquals("<0.1%", CacheFormat.percent(1, 10_000)); - assertEquals("25.0%", CacheFormat.percent(25, 100)); - } - - @Test - public void measuresRecursiveFilesAndTimes() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-recursive"); - try { - Files.writeString(root.resolve("a.bin"), "abc", StandardCharsets.UTF_8); - Files.createDirectories(root.resolve("nested")); - Files.writeString(root.resolve("nested/b.bin"), "12345", StandardCharsets.UTF_8); - - CacheMeasurement result = CacheInventory.measureRoots( - CacheModuleId.EXO, List.of(CacheRoot.tree(root.toFile()))); - - assertEquals(8, result.bytes()); - assertEquals(2, result.fileCount()); - assertEquals(CacheAvailability.AVAILABLE, result.availability()); - } finally { - delete(root.toFile()); - } - } - - @Test - public void filesOnlyRootCountsDirectChildrenAndAppliesSuffixFilter() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-files"); - try { - Files.writeString(root.resolve("keep.apk"), "1234", StandardCharsets.UTF_8); - Files.writeString(root.resolve("skip.zip"), "123456", StandardCharsets.UTF_8); - Files.createDirectories(root.resolve("nested")); - Files.writeString(root.resolve("nested/keep.apk"), "12345678", StandardCharsets.UTF_8); - - CacheMeasurement result = CacheInventory.measureRoots( - CacheModuleId.TEMP_FILES, - List.of(CacheRoot.files(root.toFile(), Set.of(".apk"), Set.of()))); - - assertEquals(4, result.bytes()); - assertEquals(1, result.fileCount()); - } finally { - delete(root.toFile()); - } - } - - @Test - public void excludesProtectedFiles() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-protected"); - try { - Files.writeString(root.resolve("update.apk"), "12345", StandardCharsets.UTF_8); - Files.writeString(root.resolve("mpv-playback-recovery.lock"), "ignored", StandardCharsets.UTF_8); - - CacheMeasurement result = CacheInventory.measureRoots( - CacheModuleId.TEMP_FILES, - List.of(CacheRoot.files(root.toFile(), Set.of(), Set.of("mpv-playback-recovery.lock")))); - - assertEquals(5, result.bytes()); - assertEquals(1, result.fileCount()); - } finally { - delete(root.toFile()); - } - } - - @Test - public void missingDirectoryIsEmptyAndDoesNotCreateIt() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-missing").resolve("missing"); - - CacheMeasurement result = CacheInventory.measureRoots( - CacheModuleId.LYRICS, List.of(CacheRoot.tree(root.toFile()))); - - assertEquals(0, result.bytes()); - assertEquals(0, result.fileCount()); - assertEquals(CacheAvailability.EMPTY, result.availability()); - assertEquals(false, Files.exists(root)); - } - - @Test - public void orphanRootSkipsManagedDirectories() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-orphan"); - try { - Files.createDirectories(root.resolve("exo")); - Files.writeString(root.resolve("exo/managed.bin"), "123456", StandardCharsets.UTF_8); - Files.writeString(root.resolve("orphan.tmp"), "12", StandardCharsets.UTF_8); - Files.writeString(root.resolve("keep.txt"), "1234", StandardCharsets.UTF_8); - - CacheMeasurement result = CacheInventory.measureRoots( - CacheModuleId.LEGACY_FILES, - List.of(CacheRoot.orphanTree(root.toFile(), Set.of("exo"), Set.of(".tmp")))); - - assertEquals(4, result.bytes()); - assertEquals(1, result.fileCount()); - } finally { - delete(root.toFile()); - } - } - - /** - * A name excluded by prefix applies to both files and directories, so an owner-managed family - * such as the diagnostic logs is never reported as unclassified cache. - */ - @Test - public void orphanRootHonoursExcludePrefixes() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-orphan-prefix"); - try { - Files.writeString(root.resolve("webhtv-debug-log.txt"), "1234", StandardCharsets.UTF_8); - Files.writeString(root.resolve("webhtv-diagnostic-incident-0.txt"), "12", StandardCharsets.UTF_8); - Files.writeString(root.resolve("unknown.bin"), "123456", StandardCharsets.UTF_8); - - CacheMeasurement result = CacheInventory.measureRoots( - CacheModuleId.UNCLASSIFIED, - List.of(CacheRoot.orphanTree(root.toFile(), Set.of(), Set.of(), - Set.of("webhtv-debug-log", "webhtv-diagnostic-incident-")))); - - assertEquals(6, result.bytes()); - assertEquals(1, result.fileCount()); - } finally { - delete(root.toFile()); - } - } - - private static void delete(File file) { - File[] children = file.listFiles(); - if (children != null) for (File child : children) delete(child); - //noinspection ResultOfMethodCallIgnored - file.delete(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheLegacyRulesTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheLegacyRulesTest.java deleted file mode 100644 index 9ed93046c4..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CacheLegacyRulesTest.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -/** - * Guards the versioned legacy rule table (design §9.13). - * - *

The regression these tests lock down: the legacy module used to measure an inverted orphan - * tree while cleaning only one directory, so its reported size could never be deleted.

- */ -public class CacheLegacyRulesTest { - - @Test - public void everyRuleIsDeclaredWithAVersionAndReason() { - List rules = CacheLegacyRules.rules(); - assertFalse("the rule table must not be empty", rules.isEmpty()); - HashSet names = new HashSet<>(); - int previousVersion = 0; - for (CacheLegacyRules.Rule rule : rules) { - assertTrue("rule needs a name", rule.name() != null && !rule.name().isBlank()); - assertTrue("duplicate rule name: " + rule.name(), names.add(rule.name())); - assertTrue("a rule must not contain a path separator: " + rule.name(), - !rule.name().contains("/") && !rule.name().contains("\\")); - assertTrue("rule version must be positive: " + rule.name(), rule.sinceVersion() >= 1); - assertTrue("rule versions must be declared oldest first: " + rule.name(), - rule.sinceVersion() >= previousVersion); - previousVersion = rule.sinceVersion(); - assertTrue("rule needs a documented reason: " + rule.name(), - rule.note() != null && !rule.note().isBlank()); - } - assertEquals("table version must cover every declared rule", - previousVersion, CacheLegacyRules.TABLE_VERSION); - } - - @Test - public void measurementRootsMatchTheDeclaredRulesExactly() throws Exception { - File cache = cacheDir("legacy-roots"); - - List roots = CacheLegacyRules.roots(cache); - HashSet rootNames = new HashSet<>(); - for (CacheRoot root : roots) rootNames.add(root.root().getName()); - - assertEquals(CacheLegacyRules.rules().size(), roots.size()); - assertEquals(new HashSet<>(CacheLegacyRules.names()), rootNames); - for (CacheRoot root : roots) { - assertTrue("legacy root must be measurable as a tree: " + root.root(), - root.recursive() && root.unfiltered()); - assertTrue("legacy root must stay inside the cache dir: " + root.root(), - root.root().getCanonicalPath().startsWith(cache.getCanonicalPath() + File.separator)); - } - } - - /** - * The decisive regression test: the module's measured set equals the set a cleanup iterates, - * because both are built from {@link CacheLegacyRules#rules()}. - */ - @Test - public void moduleRootsAreBuiltFromTheSameRuleTableCleanupUses() throws Exception { - File cache = cacheDir("legacy-consistency"); - - CacheModule legacy = null; - for (CacheModule module : CacheModuleRegistry.modules(cache)) { - if (module.id() == CacheModuleId.LEGACY_FILES) legacy = module; - } - assertTrue("legacy module must exist", legacy != null); - - Set moduleRootNames = new HashSet<>(); - for (CacheRoot root : legacy.roots()) moduleRootNames.add(root.root().getName()); - - Set cleanedNames = new HashSet<>(); - for (CacheLegacyRules.Rule rule : CacheLegacyRules.rules()) cleanedNames.add(rule.name()); - - assertEquals("legacy must measure exactly what it cleans", cleanedNames, moduleRootNames); - } - - @Test - public void declaredLegacyNamesStayOutOfTheUnclassifiedReport() throws Exception { - File cache = cacheDir("legacy-not-unclassified"); - - CacheModule unclassified = null; - for (CacheModule module : CacheModuleRegistry.modules(cache)) { - if (module.id() == CacheModuleId.UNCLASSIFIED) unclassified = module; - } - assertTrue("unclassified module must exist", unclassified != null); - - for (CacheRoot root : unclassified.roots()) { - for (String name : CacheLegacyRules.names()) { - assertTrue("legacy path must be excluded from unclassified: " + name, - root.excludeNames().contains(name)); - } - } - } - - /** - * The declared rule must not also be registered as a managed root: that is precisely the old - * incoherence, where {@code restore-legacy} was excluded from measurement and then cleaned. - */ - @Test - public void declaredRulePathsAreHonouredByMeasurement() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), - "legacy-measure"); - try { - Files.createDirectories(root.resolve("restore-legacy")); - Files.writeString(root.resolve("restore-legacy/leftover.bin"), "12345678"); - Files.createDirectories(root.resolve("subtitle_asset")); - Files.writeString(root.resolve("subtitle_asset/old.bin"), "1234"); - - CacheMeasurement legacy = CacheInventory.measureRoots( - CacheModuleId.LEGACY_FILES, CacheLegacyRules.roots(root.toFile())); - - assertEquals("both declared legacy paths must be measured", 12, legacy.bytes()); - assertEquals(2, legacy.fileCount()); - } finally { - delete(root.toFile()); - } - } - - /** A path that is not in the table must not be reported as legacy. */ - @Test - public void undeclaredDirectoriesAreNotLegacy() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), - "legacy-undeclared"); - try { - Files.createDirectories(root.resolve("plugin-preheat")); - Files.writeString(root.resolve("plugin-preheat/live-cache.txt"), "123456"); - Files.writeString(root.resolve("webhtv-debug-log.txt"), "1234"); - - CacheMeasurement legacy = CacheInventory.measureRoots( - CacheModuleId.LEGACY_FILES, CacheLegacyRules.roots(root.toFile())); - - assertEquals("an unregistered live cache must not be reported as legacy", - 0, legacy.bytes()); - assertEquals(0, legacy.fileCount()); - } finally { - delete(root.toFile()); - } - } - - private static File cacheDir(String prefix) throws Exception { - Path base = Path.of(System.getProperty("java.io.tmpdir")).toRealPath(); - return Files.createTempDirectory(base, prefix).toFile(); - } - - private static void delete(File file) { - File[] children = file.listFiles(); - if (children != null) for (File child : children) delete(child); - //noinspection ResultOfMethodCallIgnored - file.delete(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheModuleRegistryTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheModuleRegistryTest.java deleted file mode 100644 index 0a49f9c0f8..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CacheModuleRegistryTest.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.EnumSet; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -public class CacheModuleRegistryTest { - - @Test - public void registryDefinesEveryModuleExactlyOnce() throws Exception { - File cache = cacheDir("registry-ids"); - List modules = CacheModuleRegistry.modules(cache); - - EnumSet ids = EnumSet.noneOf(CacheModuleId.class); - for (CacheModule module : modules) { - assertTrue("duplicate id " + module.id(), ids.add(module.id())); - assertFalse("module without roots: " + module.id(), module.roots().isEmpty()); - } - assertEquals(EnumSet.allOf(CacheModuleId.class), ids); - } - - @Test - public void everyRootStaysInsideCacheDir() throws Exception { - File cache = cacheDir("registry-inside"); - String cacheKey = cache.getCanonicalPath(); - - for (CacheModule module : CacheModuleRegistry.modules(cache)) { - for (CacheRoot root : module.roots()) { - String key = root.root().getCanonicalPath(); - assertTrue(module.id() + " escapes cache dir: " + key, - key.equals(cacheKey) || key.startsWith(cacheKey + File.separator)); - } - } - } - - @Test - public void productionRegistryPassesValidation() throws Exception { - File cache = cacheDir("registry-valid"); - List modules = CacheModuleRegistry.modules(cache); - assertEquals(List.of(), CacheModuleRegistry.validate(cache, modules)); - } - - @Test - public void validationRejectsRootOutsideCacheDir() throws Exception { - File cache = cacheDir("registry-escape"); - File outside = cacheDir("registry-outside"); - ArrayList modules = new ArrayList<>(CacheModuleRegistry.modules(cache)); - modules.add(module(CacheModuleId.EXO, new File(outside, "exo"))); - - List problems = CacheModuleRegistry.validate(cache, modules); - - assertTrue(problems.toString(), problems.stream().anyMatch(p -> p.contains("escapes cache dir"))); - } - - @Test - public void validationRejectsNestedTreeRoots() throws Exception { - File cache = cacheDir("registry-nested"); - ArrayList modules = new ArrayList<>(); - modules.add(module(CacheModuleId.EXO, new File(cache, "outer"))); - modules.add(module(CacheModuleId.LYRICS, new File(cache, "outer/inner"))); - - List problems = CacheModuleRegistry.validate(cache, modules); - - assertTrue(problems.toString(), problems.stream().anyMatch(p -> p.contains("nested tree root"))); - } - - @Test - public void validationRejectsDuplicateTreeRoot() throws Exception { - File cache = cacheDir("registry-duplicate"); - ArrayList modules = new ArrayList<>(); - modules.add(module(CacheModuleId.EXO, new File(cache, "shared"))); - modules.add(module(CacheModuleId.LYRICS, new File(cache, "shared"))); - - List problems = CacheModuleRegistry.validate(cache, modules); - - assertTrue(problems.toString(), problems.stream().anyMatch(p -> p.contains("overlapping tree roots"))); - } - - @Test - public void registryIsPureFunctionOfCacheDir() throws Exception { - File first = cacheDir("registry-pure-a"); - File second = cacheDir("registry-pure-b"); - - Set firstIds = idsOf(CacheModuleRegistry.modules(first)); - Set secondIds = idsOf(CacheModuleRegistry.modules(second)); - assertEquals(firstIds, secondIds); - - for (CacheRoot root : rootsOf(CacheModuleRegistry.modules(second))) { - assertTrue("root must follow the supplied cache dir: " + root.root(), - root.root().getCanonicalPath().startsWith(second.getCanonicalPath())); - } - assertEquals(CacheModuleRegistry.modules(first), CacheModuleRegistry.modules(first)); - } - - private static Set idsOf(List modules) { - return new HashSet<>(modules.stream().map(CacheModule::id).toList()); - } - - private static List rootsOf(List modules) { - ArrayList roots = new ArrayList<>(); - for (CacheModule module : modules) roots.addAll(module.roots()); - return roots; - } - - /** - * The audit module must measure the live file and its rotated segment, and nothing else: a - * measurement that also counted unrelated cache-root files would report the wrong size, and one - * that missed the rotated segment would under-report the evidence trail after the first rotation. - */ - @Test - public void pluginAuditModuleMeasuresOnlyItsOwnFamily() throws Exception { - File cache = cacheDir("registry-plugin-audit"); - String name = com.fongmi.android.tv.plugin.PluginAuditStore.FILE_NAME; - Files.writeString(new File(cache, name).toPath(), "1234", java.nio.charset.StandardCharsets.UTF_8); - Files.writeString(new File(cache, name + ".1").toPath(), "123456", - java.nio.charset.StandardCharsets.UTF_8); - Files.writeString(new File(cache, "goProxy.log").toPath(), "12345678", - java.nio.charset.StandardCharsets.UTF_8); - - CacheModule audit = CacheModuleRegistry.modules(cache).stream() - .filter(module -> module.id() == CacheModuleId.PLUGIN_AUDIT) - .findFirst().orElseThrow(); - - CacheMeasurement measurement = CacheInventory.measureRoots(CacheModuleId.PLUGIN_AUDIT, - audit.roots()); - - assertEquals(10, measurement.bytes()); - assertEquals(2, measurement.fileCount()); - } - - private static CacheModule module(CacheModuleId id, File root) { - return new CacheModule() { - @Override - public CacheModuleId id() { - return id; - } - - @Override - public CacheGroup group() { - return CacheGroup.LEGACY; - } - - @Override - public List roots() { - return List.of(CacheRoot.tree(root)); - } - - @Override - public CacheCleanability cleanability() { - return CacheCleanability.SAFE_NOW; - } - - @Override - public CacheEvictionPolicy evictionPolicy() { - return CacheEvictionPolicy.AGE_BASED; - } - - @Override - public CacheProtection protection() { - return new CacheProtection(true, true, false, false, 0, 0, false); - } - }; - } - - private static File cacheDir(String prefix) throws Exception { - Path base = Path.of(System.getProperty("java.io.tmpdir")).toRealPath(); - return Files.createTempDirectory(base, prefix).toFile(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CachePathSafetyTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CachePathSafetyTest.java deleted file mode 100644 index d88a5c14df..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CachePathSafetyTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Assume; -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.FileSystemException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -public class CachePathSafetyTest { - - @Test - public void ancestorSymlinkDoesNotHideRegularCacheFiles() throws Exception { - Path real = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-alias-real"); - Path alias = real.getParent().resolve(real.getFileName() + "-alias"); - try { - Files.createSymbolicLink(alias, real); - } catch (UnsupportedOperationException | FileSystemException error) { - delete(real.toFile()); - Assume.assumeTrue("symbolic links unavailable", false); - } - try { - Files.createDirectories(real.resolve("data/lyrics")); - Files.writeString(real.resolve("data/lyrics/marker.bin"), "1234", StandardCharsets.UTF_8); - - File aliased = alias.resolve("data/lyrics/marker.bin").toFile(); - assertTrue(aliased.isFile()); - assertFalse(CachePathSafety.isSymbolicLink(aliased)); - - CacheMeasurement result = CacheInventory.measureRoots( - CacheModuleId.LYRICS, List.of(CacheRoot.tree(alias.resolve("data/lyrics").toFile()))); - - assertEquals(4, result.bytes()); - assertEquals(1, result.fileCount()); - assertEquals(CacheAvailability.AVAILABLE, result.availability()); - } finally { - Files.deleteIfExists(alias); - delete(real.toFile()); - } - } - - @Test - public void finalComponentSymlinkIsStillDetected() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-link-root"); - Path target = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-link-target"); - try { - Files.writeString(target.resolve("victim.bin"), "secret", StandardCharsets.UTF_8); - try { - Files.createSymbolicLink(root.resolve("link.bin"), target.resolve("victim.bin")); - } catch (UnsupportedOperationException | FileSystemException error) { - Assume.assumeTrue("symbolic links unavailable", false); - } - - // The production check compares the canonical path with the parent-canonical + name - // composition. That comparison only sees a link when File.getCanonicalFile() resolves - // it, which is the documented behaviour on Linux/Android but not on every host JVM - // (Windows keeps the link path). Skip on hosts whose canonicalization does not follow - // links, otherwise this test would fail for platform reasons instead of code reasons. - File link = root.resolve("link.bin").toFile(); - Assume.assumeTrue("File.getCanonicalFile() does not follow symbolic links on this host", - !link.getCanonicalFile().equals(link.getAbsoluteFile())); - - assertTrue(CachePathSafety.isSymbolicLink(link)); - - CacheMeasurement result = CacheInventory.measureRoots( - CacheModuleId.LYRICS, List.of(CacheRoot.tree(root.toFile()))); - - assertEquals(0, result.bytes()); - assertEquals(0, result.fileCount()); - assertEquals(CacheAvailability.PARTIAL, result.availability()); - assertTrue(target.resolve("victim.bin").toFile().exists()); - } finally { - Files.deleteIfExists(root.resolve("link.bin")); - delete(root.toFile()); - delete(target.toFile()); - } - } - - private static void delete(File file) { - File[] children = file.listFiles(); - if (children != null) for (File child : children) delete(child); - //noinspection ResultOfMethodCallIgnored - file.delete(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CachePluginHttpModuleTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CachePluginHttpModuleTest.java deleted file mode 100644 index 0e37672d13..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CachePluginHttpModuleTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.plugin.PluginHttpCache; - -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -/** - * The plugin HTTP cache is a first-class cache module, not a stray directory. - * - *

Three things must stay true or the feature is worse than no cache at all: the module has to - * exist in the registry (otherwise nothing in the UI can report or clear it), its root has to pass - * registry validation (otherwise the whole registry is rejected at startup), and its entries must - * never be claimed by {@link CacheModuleId#TEMP_FILES}, which deletes on suffix alone. - */ -public class CachePluginHttpModuleTest { - - @Test - public void registryDeclaresThePluginHttpCacheModule() throws Exception { - File cache = cacheDir("plugin-http-module"); - List modules = CacheModuleRegistry.modules(cache); - - CacheModule module = modules.stream() - .filter(candidate -> candidate.id() == CacheModuleId.PLUGIN_HTTP_CACHE) - .findFirst().orElseThrow(); - - assertEquals(CacheGroup.PLUGIN, module.group()); - assertEquals(CacheCleanability.SAFE_NOW, module.cleanability()); - assertEquals(CacheEvictionPolicy.AGE_BASED, module.evictionPolicy()); - assertEquals(1, module.roots().size()); - assertEquals(new File(cache, PluginHttpCache.DIRECTORY).getCanonicalPath(), - module.roots().get(0).root().getCanonicalPath()); - assertTrue("the whole directory is the module", module.roots().get(0).recursive()); - assertEquals(List.of(), CacheModuleRegistry.validate(cache, modules)); - // Manual cleanup yes, automatic eviction no: silently dropping a cache the plugin expects - // is a bug report the user cannot diagnose. - assertFalse(module.protection().allowAutomaticCleanup()); - assertTrue(module.protection().allowManualCleanup()); - assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.PLUGIN_HTTP_CACHE)); - assertTrue(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.PLUGIN_HTTP_CACHE, cache)); - } - - @Test - public void moduleMeasuresItsOwnEntriesAndTemporaryCleanupLeavesThemAlone() throws Exception { - File cache = cacheDir("plugin-http-measure"); - File directory = new File(new File(cache, PluginHttpCache.DIRECTORY), "douban.comments"); - assertTrue(directory.mkdirs() || directory.isDirectory()); - String name = "9f2c1b".repeat(10) + "abcd"; - File payload = new File(directory, name); - Files.write(payload.toPath(), "{\"rating\":8.7}".getBytes(StandardCharsets.UTF_8)); - - CacheModule module = CacheModuleRegistry.modules(cache).stream() - .filter(candidate -> candidate.id() == CacheModuleId.PLUGIN_HTTP_CACHE) - .findFirst().orElseThrow(); - CacheMeasurement measurement = CacheInventory.measureRoots(CacheModuleId.PLUGIN_HTTP_CACHE, - module.roots()); - - assertEquals(14, measurement.bytes()); - assertEquals(1, measurement.fileCount()); - assertFalse("a suffix-free cache entry must not look like a temporary file", - CacheCleanupManager.temporaryFamily(cache).matches(name)); - } - - private static File cacheDir(String prefix) throws Exception { - Path base = Path.of(System.getProperty("java.io.tmpdir")).toRealPath(); - return Files.createTempDirectory(base, prefix).toFile(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyEngineTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyEngineTest.java deleted file mode 100644 index 5f7bbaa7f1..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyEngineTest.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.io.File; -import java.util.List; - -public class CachePolicyEngineTest { - - @Test - public void lightPlanContainsOnlyLowRiskModules() { - assertEquals(List.of(CacheModuleId.EPG, CacheModuleId.TEMP_FILES, CacheModuleId.LEGACY_FILES), - CachePolicyEngine.plan(CacheCleanupMode.LIGHT).modules()); - } - - @Test - public void standardPlanNeverContainsPlaybackCache() { - List modules = CachePolicyEngine.plan(CacheCleanupMode.STANDARD).modules(); - assertFalse(modules.contains(CacheModuleId.EXO)); - assertFalse(modules.contains(CacheModuleId.MPV_HLS)); - assertFalse(modules.contains(CacheModuleId.MPV_DEMUXER)); - assertFalse(modules.contains(CacheModuleId.MPV_RUNTIME)); - } - - @Test - public void deepPlanCoversPlaybackAndMediaExactlyOnce() { - List modules = CachePolicyEngine.plan(CacheCleanupMode.DEEP).modules(); - // Four modules stay out of every tiered run: two report-only surfaces, unclassified cache - // (owner unknown), and the plugin HTTP cache (its entries only mean anything inside the TTL - // a plugin asked for, so a deep plan must not silently turn a cached plugin into a network - // call). Each remains reachable through its own module row. - assertEquals(CacheModuleId.values().length - 4, modules.size()); - assertEquals(modules.size(), modules.stream().distinct().count()); - // L3 = L2 + playback caches. The report-only modules are excluded by design. - assertTrue(modules.contains(CacheModuleId.EXO)); - assertTrue(modules.contains(CacheModuleId.MPV_HLS)); - assertTrue(modules.contains(CacheModuleId.MPV_DEMUXER)); - assertTrue(modules.contains(CacheModuleId.MPV_RUNTIME)); - assertFalse(modules.contains(CacheModuleId.DIAGNOSTIC_LOGS)); - assertFalse(modules.contains(CacheModuleId.PLUGIN_AUDIT)); - assertFalse(modules.contains(CacheModuleId.PLUGIN_HTTP_CACHE)); - assertFalse(modules.contains(CacheModuleId.UNCLASSIFIED)); - } - - /** - * The legacy module's cleanup must be reached by the tiered plans too, since that is the entry - * the user actually pressed when the cleanup reported "0 files deleted". - */ - @Test - public void legacyModuleIsPartOfEveryTierThatClaimsIt() { - assertTrue(CachePolicyEngine.plan(CacheCleanupMode.LIGHT).modules() - .contains(CacheModuleId.LEGACY_FILES)); - assertTrue(CachePolicyEngine.plan(CacheCleanupMode.STANDARD).modules() - .contains(CacheModuleId.LEGACY_FILES)); - assertTrue(CachePolicyEngine.plan(CacheCleanupMode.DEEP).modules() - .contains(CacheModuleId.LEGACY_FILES)); - } - - /** - * Diagnostic logs are manually cleanable through their own module row, but no tiered run and - * no automatic run may remove them: they are the evidence a user may still need. - */ - @Test - public void diagnosticLogsAreManualOnly() { - assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.DIAGNOSTIC_LOGS)); - assertFalse(CachePolicyEngine.plan(CacheCleanupMode.LIGHT).modules() - .contains(CacheModuleId.DIAGNOSTIC_LOGS)); - assertFalse(CachePolicyEngine.plan(CacheCleanupMode.STANDARD).modules() - .contains(CacheModuleId.DIAGNOSTIC_LOGS)); - assertFalse(CachePolicyEngine.plan(CacheCleanupMode.DEEP).modules() - .contains(CacheModuleId.DIAGNOSTIC_LOGS)); - assertEquals(List.of(CacheModuleId.DIAGNOSTIC_LOGS), - CachePolicyEngine.module(CacheModuleId.DIAGNOSTIC_LOGS).modules()); - } - - /** - * The plugin audit trail is the only evidence surface for {@code net.request} (no domain - * allowlist exists by decision), so no tiered or automatic run may erase it. It stays reachable - * through its own module row, exactly like the diagnostic logs. - */ - @Test - public void pluginAuditLogIsManualOnly() { - assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.PLUGIN_AUDIT)); - assertFalse(CachePolicyEngine.plan(CacheCleanupMode.LIGHT).modules() - .contains(CacheModuleId.PLUGIN_AUDIT)); - assertFalse(CachePolicyEngine.plan(CacheCleanupMode.STANDARD).modules() - .contains(CacheModuleId.PLUGIN_AUDIT)); - assertFalse(CachePolicyEngine.plan(CacheCleanupMode.DEEP).modules() - .contains(CacheModuleId.PLUGIN_AUDIT)); - assertEquals(List.of(CacheModuleId.PLUGIN_AUDIT), - CachePolicyEngine.module(CacheModuleId.PLUGIN_AUDIT).modules()); - assertEquals(CacheCleanupStatus.COMPLETED, - CachePolicyEngine.directCleanupStatus(CacheModuleId.PLUGIN_AUDIT, false)); - assertTrue(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.PLUGIN_AUDIT, - new File(System.getProperty("java.io.tmpdir")))); - } - - /** Unclassified cache is report-only: it must never be offered as cleanable. */ - @Test - public void unclassifiedCacheIsNotDirectlyCleanable() { - assertEquals(CacheCleanupStatus.NOT_ALLOWED, - CachePolicyEngine.directCleanupStatus(CacheModuleId.UNCLASSIFIED, false)); - assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.UNCLASSIFIED)); - } - - /** The UI restriction must come from the registry, not from a hardcoded module check. */ - @Test - public void manualCleanupPermissionComesFromTheRegistry() { - File cache = new File(System.getProperty("java.io.tmpdir")); - assertFalse(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.PLUGIN_SCRIPTS, cache)); - assertFalse(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.UNCLASSIFIED, cache)); - assertTrue(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.LEGACY_FILES, cache)); - assertTrue(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.DIAGNOSTIC_LOGS, cache)); - assertFalse(CachePolicyEngine.manualCleanupAllowed(null, cache)); - assertFalse(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.LEGACY_FILES, null)); - } - - /** - * The settings row's plain click clears every module, including the report-only ones a tiered - * plan deliberately protects, and it is the mode that selects the full clean path. - */ - @Test - public void fullPlanCoversEveryModuleExactlyOnce() { - CacheCleanupPlan plan = CachePolicyEngine.plan(CacheCleanupMode.FULL); - assertEquals(CacheCleanupMode.FULL, plan.mode()); - assertEquals(CacheModuleId.values().length, plan.modules().size()); - assertEquals(plan.modules().size(), plan.modules().stream().distinct().count()); - assertTrue(plan.modules().contains(CacheModuleId.EXO)); - assertTrue(plan.modules().contains(CacheModuleId.DIAGNOSTIC_LOGS)); - assertTrue(plan.modules().contains(CacheModuleId.UNCLASSIFIED)); - } - - @Test - public void modulePlanIsExplicitAndSingleModule() { - assertEquals(List.of(CacheModuleId.LYRICS), - CachePolicyEngine.module(CacheModuleId.LYRICS).modules()); - } - - @Test - public void playbackModulesAreDeferredWhilePlaying() { - assertEquals(CacheCleanupStatus.DEFERRED, - CachePolicyEngine.directCleanupStatus(CacheModuleId.EXO, true)); - assertEquals(CacheCleanupStatus.DEFERRED, - CachePolicyEngine.directCleanupStatus(CacheModuleId.MPV_HLS, true)); - assertEquals(CacheCleanupStatus.COMPLETED, - CachePolicyEngine.directCleanupStatus(CacheModuleId.EXO, false)); - } - - @Test - public void automaticCleanupExcludesPlaybackAndOwnerManagedModules() { - assertTrue(CachePolicyEngine.allowsAutomatic(CacheModuleId.LYRICS)); - assertTrue(CachePolicyEngine.allowsAutomatic(CacheModuleId.EPG)); - assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.EXO)); - assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.WEBHOME_RAW)); - assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.GLIDE)); - assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.PLUGIN_SCRIPTS)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyStoreTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyStoreTest.java deleted file mode 100644 index 8727b541a1..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyStoreTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -public class CachePolicyStoreTest { - - @Test - public void featureSwitchDefaultsToEnabledWhenUnset() { - assertTrue(CachePolicyStore.enabledByDefault(null)); - } - - @Test - public void featureSwitchRespectsExplicitValues() { - assertTrue(CachePolicyStore.enabledByDefault(true)); - assertFalse(CachePolicyStore.enabledByDefault(false)); - } - - @Test - public void migrationRunsOnlyForOlderSchemas() { - assertTrue("fresh install must migrate", CachePolicyStore.needsMigration(0)); - assertFalse("current schema must be a no-op", CachePolicyStore.needsMigration(1)); - assertFalse("newer/unknown schema must keep working", CachePolicyStore.needsMigration(99)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheRetentionManagerTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheRetentionManagerTest.java deleted file mode 100644 index b6dce34371..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CacheRetentionManagerTest.java +++ /dev/null @@ -1,186 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Assume; -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.FileSystemException; -import java.util.List; -import java.util.Set; -import java.util.concurrent.atomic.AtomicLong; - -public class CacheRetentionManagerTest { - - @Test - public void enforceLimitDeletesOldestFilesFirst() throws Exception { - Path root = Files.createTempDirectory("cache-retention"); - try { - File old = write(root, "old.bin", "1234567890", 1_000L); - File fresh = write(root, "fresh.bin", "1234567890", 2_000L); - - boolean success = CacheRetentionManager.applyLimit(root.toFile(), 10, - 0, () -> 3_000L, Set.of()); - - assertTrue(success); - assertFalse(old.exists()); - assertFalse(fresh.exists()); - } finally { - delete(root.toFile()); - } - } - - @Test - public void retentionDeletesOnlyExpiredFiles() throws Exception { - Path root = Files.createTempDirectory("cache-retention-ttl"); - try { - File expired = write(root, "expired.bin", "123", 1_000L); - File fresh = write(root, "fresh.bin", "123", 9_000L); - - AtomicLong now = new AtomicLong(10_000L); - boolean success = CacheRetentionManager.applyLimit(root.toFile(), 0, - 5_000L, now::get, Set.of()); - - assertTrue(success); - assertFalse(expired.exists()); - assertTrue(fresh.exists()); - } finally { - delete(root.toFile()); - } - } - - @Test - public void excludedNamesAreNeverDeleted() throws Exception { - Path root = Files.createTempDirectory("cache-retention-exclude"); - try { - File keep = write(root, "keep.lock", "123", 1L); - CacheRetentionManager.applyLimit(root.toFile(), 0, 1L, - () -> 10_000L, Set.of("keep.lock")); - assertTrue(keep.exists()); - } finally { - delete(root.toFile()); - } - } - - @Test - public void symbolicLinksAreNotFollowedOrDeleted() throws Exception { - Path outside = Files.createTempDirectory("cache-retention-outside"); - Path root = Files.createTempDirectory("cache-retention-link"); - File victim = write(outside, "victim.bin", "secret", 1L); - try { - try { - Files.createSymbolicLink(root.resolve("link.bin"), victim.toPath()); - } catch (UnsupportedOperationException | FileSystemException error) { - Assume.assumeTrue("symbolic links unavailable", false); - } - - boolean success = CacheRetentionManager.applyLimit(root.toFile(), 0, 1L, - () -> 10_000L, Set.of()); - - assertTrue(success); - assertTrue(victim.exists()); - } finally { - delete(root.toFile()); - delete(outside.toFile()); - } - } - - @Test - public void fileLimitKeepsFilesYoungerThanMinimumAge() throws Exception { - Path root = Files.createTempDirectory("cache-retention-file-limit"); - try { - File old = write(root, "old.tmp", "1234567890", 1_000L); - File young = write(root, "young.tmp", "1234567890", 9_500L); - boolean success = CacheRetentionManager.enforceFileLimit( - root.toFile(), List.of(old, young), 10, 1_000L, 10_000L); - assertTrue(success); - assertFalse(old.exists()); - assertTrue(young.exists()); - } finally { - delete(root.toFile()); - } - } - - @Test - public void fileLimitNeverDeletesOutsideAllowedRoot() throws Exception { - Path root = Files.createTempDirectory("cache-retention-bounds-root"); - Path outside = Files.createTempDirectory("cache-retention-bounds-outside"); - try { - File victim = write(outside, "victim.tmp", "1234567890", 1_000L); - - boolean success = CacheRetentionManager.enforceFileLimit( - root.toFile(), List.of(victim), 5, 0L, 10_000L); - - assertTrue(success); - assertTrue("file outside the declared root must be preserved", victim.exists()); - } finally { - delete(root.toFile()); - delete(outside.toFile()); - } - } - - @Test - public void fileLimitSkipsSymbolicLinksInsideRoot() throws Exception { - Path root = Files.createTempDirectory("cache-retention-bounds-link"); - Path outside = Files.createTempDirectory("cache-retention-link-target"); - try { - File victim = write(outside, "target.tmp", "1234567890", 1_000L); - java.nio.file.Path link = root.resolve("link.tmp"); - try { - Files.createSymbolicLink(link, victim.toPath()); - } catch (UnsupportedOperationException | java.nio.file.FileSystemException error) { - org.junit.Assume.assumeTrue("symbolic links unavailable", false); - } - - boolean success = CacheRetentionManager.enforceFileLimit( - root.toFile(), List.of(link.toFile()), 5, 0L, 10_000L); - - assertTrue(success); - assertTrue("symlink target must be preserved", victim.exists()); - } finally { - delete(root.toFile()); - delete(outside.toFile()); - } - } - - @Test - public void pluginLimitExcludesActiveFile() throws Exception { - Path root = Files.createTempDirectory("cache-plugin-retention"); - try { - File expired = write(root, "expired.jar", "1234", 1_000L); - File active = write(root, "active.jar", "1234", 1_000L); - File fresh = write(root, "fresh.jar", "1234", 9_500L); - - boolean success = CacheRetentionManager.applyLimit(root.toFile(), 0, - 5_000L, () -> 10_000L, Set.of("active.jar")); - - assertTrue(success); - assertFalse(expired.exists()); - assertFalse(expired.exists()); - assertTrue(active.exists()); - assertTrue(fresh.exists()); - } finally { - delete(root.toFile()); - } - } - - private static File write(Path root, String name, String value, long modified) throws Exception { - Path file = root.resolve(name); - Files.writeString(file, value, StandardCharsets.UTF_8); - assertTrue(file.toFile().setLastModified(modified)); - return file.toFile(); - } - - private static void delete(File file) { - File[] children = file.listFiles(); - if (children != null) for (File child : children) delete(child); - //noinspection ResultOfMethodCallIgnored - file.delete(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheRootPrefixTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheRootPrefixTest.java deleted file mode 100644 index 2db1acc3ac..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CacheRootPrefixTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Set; - -/** - * Covers the prefix filters added for owners that rotate one logical file under derived names, - * such as the diagnostic log family ({@code webhtv-debug-log.txt}, {@code ...txt.1}, - * {@code webhtv-diagnostic-incident-0.txt}). A suffix filter can not address that family. - */ -public class CacheRootPrefixTest { - - @Test - public void includePrefixCountsOnlyTheDeclaredFamily() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), - "root-prefix-include"); - try { - Files.writeString(root.resolve("webhtv-debug-log.txt"), "1234", StandardCharsets.UTF_8); - Files.writeString(root.resolve("webhtv-debug-log.txt.1"), "123456", StandardCharsets.UTF_8); - Files.writeString(root.resolve("webhtv-diagnostic-incident-0.txt"), "12", StandardCharsets.UTF_8); - Files.writeString(root.resolve("update.apk"), "12345678", StandardCharsets.UTF_8); - Files.writeString(root.resolve("tv.lck"), "1", StandardCharsets.UTF_8); - - CacheMeasurement result = CacheInventory.measureRoots(CacheModuleId.DIAGNOSTIC_LOGS, - List.of(CacheRoot.prefixedFiles(root.toFile(), - Set.of("webhtv-debug-log", "webhtv-diagnostic-incident-"), Set.of()))); - - assertEquals(12, result.bytes()); - assertEquals(3, result.fileCount()); - } finally { - delete(root.toFile()); - } - } - - @Test - public void excludePrefixWinsOverIncludePrefix() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), - "root-prefix-exclude"); - try { - Files.writeString(root.resolve("webhtv-debug-log.txt"), "1234", StandardCharsets.UTF_8); - Files.writeString(root.resolve("webhtv-diagnostics.journal"), "123456", StandardCharsets.UTF_8); - - CacheMeasurement result = CacheInventory.measureRoots(CacheModuleId.DIAGNOSTIC_LOGS, - List.of(CacheRoot.prefixedFiles(root.toFile(), - Set.of("webhtv-"), Set.of("webhtv-diagnostics.journal")))); - - assertEquals(4, result.bytes()); - assertEquals(1, result.fileCount()); - } finally { - delete(root.toFile()); - } - } - - /** An unfiltered recursive root must still count everything below it. */ - @Test - public void prefixFiltersDoNotChangePlainTreeBehaviour() throws Exception { - Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), - "root-prefix-tree"); - try { - Files.createDirectories(root.resolve("nested")); - Files.writeString(root.resolve("nested/a.txt"), "12345", StandardCharsets.UTF_8); - - CacheMeasurement result = CacheInventory.measureRoots(CacheModuleId.LYRICS, - List.of(CacheRoot.tree(root.toFile()))); - - assertEquals(5, result.bytes()); - assertEquals(1, result.fileCount()); - } finally { - delete(root.toFile()); - } - } - - @Test - public void unfilteredReportsOnlyUnfilteredRoots() { - File any = new File(System.getProperty("java.io.tmpdir")); - assertTrue(CacheRoot.tree(any).unfiltered()); - assertFalse(CacheRoot.prefixedFiles(any, Set.of("a"), Set.of()).unfiltered()); - assertFalse(CacheRoot.files(any, Set.of(".apk"), Set.of()).unfiltered()); - } - - private static void delete(File file) { - File[] children = file.listFiles(); - if (children != null) for (File child : children) delete(child); - //noinspection ResultOfMethodCallIgnored - file.delete(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheTempFilePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheTempFilePolicyTest.java deleted file mode 100644 index befe609ab0..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CacheTempFilePolicyTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -public class CacheTempFilePolicyTest { - - @Test - public void inFlightUpdateIsProtected() { - assertTrue(CacheTempFilePolicy.isInUse("update.apk", true, false)); - assertFalse(CacheTempFilePolicy.isInUse("update.apk", false, false)); - } - - @Test - public void inFlightApkUrlPushIsProtected() { - assertTrue(CacheTempFilePolicy.isInUse("pushed-url-123.apk", false, true)); - assertFalse(CacheTempFilePolicy.isInUse("pushed-url-123.apk", false, false)); - } - - @Test - public void unrelatedFilesAreNeverMarkedInUse() { - assertFalse(CacheTempFilePolicy.isInUse("webhtv-sync-1.zip", true, true)); - assertFalse(CacheTempFilePolicy.isInUse("pushed-123.apk", true, true)); - assertFalse(CacheTempFilePolicy.isInUse(null, true, true)); - assertFalse(CacheTempFilePolicy.isInUse("", true, true)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheTotalLimitPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheTotalLimitPolicyTest.java deleted file mode 100644 index be5eacef87..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/CacheTotalLimitPolicyTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -public class CacheTotalLimitPolicyTest { - - @Test - public void unlimitedWhenNotConfigured() { - assertEquals(0, CacheTotalLimitPolicy.effectiveLimit(0, 1024)); - assertFalse(CacheTotalLimitPolicy.overLimit(4096, 0)); - } - - @Test - public void systemQuotaClampsUserLimit() { - assertEquals(512, CacheTotalLimitPolicy.effectiveLimit(1024, 512)); - assertEquals(1024, CacheTotalLimitPolicy.effectiveLimit(1024, 0)); - } - - @Test - public void overLimitOnlyWhenEffectiveLimitIsPositive() { - assertTrue(CacheTotalLimitPolicy.overLimit(600, 512)); - assertFalse(CacheTotalLimitPolicy.overLimit(500, 512)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/PlaybackCacheBudgetTest.java b/app/src/test/java/com/fongmi/android/tv/cache/PlaybackCacheBudgetTest.java deleted file mode 100644 index c519fcfa9f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/cache/PlaybackCacheBudgetTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.fongmi.android.tv.cache; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -public class PlaybackCacheBudgetTest { - - private static final long MB = 1024L * 1024L; - - @Test - public void playbackKeepsThePreloadCeilingAndIgnoresTheTotalLimit() { - assertEquals(2048 * MB, PlaybackCacheBudget.capacityBytes(true, 128 * MB, 2048 * MB, 900 * MB)); - assertEquals(64 * MB, PlaybackCacheBudget.capacityBytes(false, 128 * MB, 2048 * MB, 900 * MB)); - } - - @Test - public void playbackCapacityNeverGoesNegative() { - assertEquals(0, PlaybackCacheBudget.capacityBytes(true, 128 * MB, -1, 0)); - } - - @Test - public void unlimitedTotalKeepsPreloadSetting() { - assertEquals(2048 * MB, PlaybackCacheBudget.capacityBytes(0, 2048 * MB, 0)); - assertEquals(2048 * MB, PlaybackCacheBudget.capacityBytes(0, 2048 * MB, 900 * MB)); - } - - @Test - public void emptyOtherModulesGiveTheWholeLimit() { - assertEquals(128 * MB, PlaybackCacheBudget.capacityBytes(128 * MB, 2048 * MB, 0)); - } - - @Test - public void otherModulesShrinkThePlaybackShare() { - assertEquals(116 * MB, PlaybackCacheBudget.capacityBytes(128 * MB, 2048 * MB, 12 * MB)); - } - - @Test - public void floorAppliesWhenOtherModulesExceedTheLimit() { - assertEquals(PlaybackCacheBudget.MIN_BUDGET_BYTES, - PlaybackCacheBudget.capacityBytes(128 * MB, 2048 * MB, 210 * MB)); - } - - @Test - public void preloadSettingRemainsTheCeiling() { - assertEquals(64 * MB, PlaybackCacheBudget.capacityBytes(512 * MB, 64 * MB, 0)); - } - - @Test - public void floorNeverExceedsThePreloadSetting() { - assertEquals(32 * MB, PlaybackCacheBudget.capacityBytes(128 * MB, 32 * MB, 400 * MB)); - } - - @Test - public void negativeInputsAreTreatedAsZero() { - assertEquals(0, PlaybackCacheBudget.capacityBytes(128 * MB, -1, 0)); - assertEquals(128 * MB, PlaybackCacheBudget.capacityBytes(128 * MB, 2048 * MB, -5)); - } - - @Test - public void panelLimitLowersThePreloadCeiling() { - assertEquals(128 * MB, PlaybackCacheBudget.ceilingBytes(2048 * MB, 128 * MB)); - } - - @Test - public void unlimitedPanelLimitKeepsThePreloadCeiling() { - assertEquals(2048 * MB, PlaybackCacheBudget.ceilingBytes(2048 * MB, 0)); - assertEquals(2048 * MB, PlaybackCacheBudget.ceilingBytes(2048 * MB, -1)); - } - - @Test - public void panelLimitAboveThePreloadSettingChangesNothing() { - assertEquals(512 * MB, PlaybackCacheBudget.ceilingBytes(512 * MB, 1024 * MB)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/chaquo/PythonStartIdempotenceSourceTest.java b/app/src/test/java/com/fongmi/android/tv/chaquo/PythonStartIdempotenceSourceTest.java deleted file mode 100644 index c19d84ccef..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/chaquo/PythonStartIdempotenceSourceTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.fongmi.android.tv.chaquo; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * 恢复备份曾让进程直接崩溃: - * - *

- * NoClassDefFoundError: com.fongmi.android.tv.api.loader.BaseLoader$Loader
- *   <- ExceptionInInitializerError
- *   <- IllegalStateException: Python already started
- *      at com.fongmi.chaquo.Loader.<init>
- * 
- * - * 原因是 {@code Loader} 的构造用 {@code if (!Python.isStarted()) Python.start(...)} 做 - * check-then-act:S9 把字幕 ProviderRegistry 放到 App 启动的后台线程预热,于是字幕侧 - * (PythonSubtitleScriptRuntime)与点播配置侧(PyLoader / JsLoader)会并发构造 Loader。 - * 谁先进入判断、谁后调用 {@code Python.start()},后者就抛 IllegalStateException; - * 而 {@code BaseLoader$Loader} 的静态初始化抛错后会被永久标记为失败,之后每次 - * {@code BaseLoader.get()} 都是 NoClassDefFoundError。 - * - * 这个测试锁住「启动 CPython 只走一个类级同步入口且容忍并发败者」的形状。 - */ -public class PythonStartIdempotenceSourceTest { - - @Test - public void constructorStartsPythonThroughTheGuardedEntryPoint() throws Exception { - String source = readSource(); - String constructor = methodBody(source, "public Loader()"); - assertTrue("the constructor must delegate to the guarded starter", - constructor.contains("startPythonOnce();")); - assertFalse("the constructor must not call Python.start() directly", - constructor.contains("Python.start(")); - assertFalse("the unguarded check-then-act must not come back", - constructor.contains("if (!Python.isStarted()) Python.start(")); - } - - @Test - public void theStarterIsSynchronizedAndToleratesALostRace() throws Exception { - String source = readSource(); - String starter = methodBody(source, "private static synchronized void startPythonOnce()"); - assertTrue("the starter must be a class-level synchronized gate", - source.contains("private static synchronized void startPythonOnce()")); - assertTrue("the starter must check whether python is already up", - starter.contains("Python.isStarted()")); - assertTrue("losing the race must be caught instead of propagating", - starter.contains("catch (IllegalStateException")); - assertTrue("the catcher must verify the runtime is really up before swallowing", - starter.contains("if (!Python.isStarted()) throw alreadyStarted;")); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("missing method: " + signature, start >= 0); - int brace = source.indexOf('{', start); - int depth = 0; - for (int i = brace; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - else if (c == '}') { - depth--; - if (depth == 0) return source.substring(start, i + 1); - } - } - throw new AssertionError("unbalanced braces for: " + signature); - } - - /** - * The unit test's working directory is the {@code app} module, so the chaquo module is a - * sibling reached through {@code ..}; the root-relative form is kept as a fallback for - * runners that start from the repository root. - */ - private static String readSource() throws Exception { - Path moduleRelative = Path.of("src", "main", "java", "com", "fongmi", "chaquo", "Loader.java"); - Path[] candidates = { - Path.of("..", "chaquo").resolve(moduleRelative), - Path.of("chaquo").resolve(moduleRelative), - }; - for (Path candidate : candidates) { - if (Files.exists(candidate)) return Files.readString(candidate, StandardCharsets.UTF_8); - } - throw new java.nio.file.NoSuchFileException("chaquo Loader.java not found from " + Path.of("").toAbsolutePath()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/compat/Android7ApiCompatContractTest.java b/app/src/test/java/com/fongmi/android/tv/compat/Android7ApiCompatContractTest.java deleted file mode 100644 index 900f730c28..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/compat/Android7ApiCompatContractTest.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.fongmi.android.tv.compat; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Stream; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Android 7 (API 24/25) contracts for API 26/34 platform calls that have an API 1 compatible form. - * - *

Two defects were found on a real Android 7 device and fixed; these guards keep the fixes from - * being reverted silently, because neither defect can be reproduced by a JVM unit test: the JVM - * still exposes {@code Process#isAlive()} and the {@code StringBuilder} matcher overloads. - */ -public class Android7ApiCompatContractTest { - - private static final Pattern MATCHER_APPEND = Pattern.compile( - "\\.(appendReplacement|appendTail)\\s*\\(\\s*([A-Za-z_$][\\w$]*)"); - private static final Pattern DECLARATION = Pattern.compile("\\b(StringBuilder|StringBuffer)\\s+([A-Za-z_$][\\w$]*)\\b"); - - @Test - public void autofillCallsGoThroughTheApiIsolatedCompatLayer() throws Exception { - for (String file : new String[]{"GitCloudDialog.java", "RemoteTrustDialog.java"}) { - String source = stripComments(read("app/src/main/java/com/fongmi/android/tv/ui/dialog/" + file)); - assertTrue(file + " must import androidx.core.view.ViewCompat", - source.contains("import androidx.core.view.ViewCompat;")); - assertEquals(file + " must route the API 26 call through the compat layer exactly once", - 1, occurrences(source, ".setImportantForAutofill(")); - assertTrue(file + " must call ViewCompat.setImportantForAutofill (no-op below API 26)", - source.contains("ViewCompat.setImportantForAutofill(")); - } - } - - @Test - public void noProductionSourceAppendsMatcherOutputIntoAStringBuilder() throws Exception { - List violations = new ArrayList<>(); - int inspected = 0; - try (Stream files = Files.walk(mainJavaRoot())) { - for (Path file : (Iterable) files.filter(path -> path.toString().endsWith(".java"))::iterator) { - String source = read(file); - Matcher call = MATCHER_APPEND.matcher(source); - while (call.find()) { - inspected++; - String variable = call.group(2); - Set types = new HashSet<>(); - Matcher declaration = DECLARATION.matcher(source); - while (declaration.find()) { - if (declaration.group(2).equals(variable)) types.add(declaration.group(1)); - } - if (types.contains("StringBuilder") && !types.contains("StringBuffer")) { - violations.add(file + " -> " + variable); - } - } - } - } - assertTrue("the scan must actually inspect the repo matcher appends (found " + inspected + ")", - inspected >= 30); - assertEquals("Matcher#appendReplacement/appendTail(StringBuilder, ...) needs API 34: " + violations, - 0, violations.size()); - } - - private static String stripComments(String source) { - return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//[^\n]*", " "); - } - - private static int occurrences(String source, String needle) { - int count = 0; - int from = source.indexOf(needle); - while (from >= 0) { - count++; - from = source.indexOf(needle, from + needle.length()); - } - return count; - } - - private static Path mainJavaRoot() { - Path moduleRelative = Path.of("src", "main", "java"); - if (Files.exists(moduleRelative)) return moduleRelative; - return Path.of("app", "src", "main", "java"); - } - - private static String read(String name) throws Exception { - return read(Path.of(name)); - } - - private static String read(Path path) throws Exception { - if (!Files.exists(path)) path = Path.of("..").resolve(path).normalize(); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/content/ActionCardHelperTest.java b/app/src/test/java/com/fongmi/android/tv/content/ActionCardHelperTest.java deleted file mode 100644 index b35f7731c3..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/content/ActionCardHelperTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.fongmi.android.tv.content; - -import static org.junit.Assert.assertEquals; - -import com.google.gson.JsonParser; - -import org.junit.Test; - -public class ActionCardHelperTest { - - @Test - public void emptyResponseIsSuccessfulAndSilent() { - assertEquals("", ActionCardHelper.nonJsonResponse(null)); - assertEquals("", ActionCardHelper.nonJsonResponse("")); - assertEquals("", ActionCardHelper.nonJsonResponse(" \n\t")); - assertEquals("", ActionCardHelper.nonJsonResponse("null")); - assertEquals("", ActionCardHelper.nonJsonResponse(" null ")); - } - - @Test - public void nonJsonTextIsPreservedForTheUser() { - assertEquals("动作执行失败", ActionCardHelper.nonJsonResponse(" 动作执行失败 ")); - } - - @Test - public void invalidActionEndpointsStillReturnAnErrorMessage() { - assertEquals("站点不存在", messageOf(ActionCardHelper.error("站点不存在"))); - assertEquals("动作执行失败", messageOf(ActionCardHelper.error(null))); - } - - private static String messageOf(String response) { - return JsonParser.parseString(response).getAsJsonObject().get("msg").getAsString(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/content/GameContentHandlerTest.java b/app/src/test/java/com/fongmi/android/tv/content/GameContentHandlerTest.java deleted file mode 100644 index 5316f2370b..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/content/GameContentHandlerTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.fongmi.android.tv.content; - -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 static org.junit.Assert.assertFalse; - -import com.google.gson.JsonObject; - -import org.junit.Test; - -public class GameContentHandlerTest { - - @Test - public void browserAction_acceptsTopLevelBrowserCard() { - JsonObject action = GameContentHandler.browserAction( - "{\"type\":\"browser\",\"url\":\"https://games.example/a\",\"title\":\"小游戏\"}"); - - assertNotNull(action); - assertEquals("https://games.example/a", GameContentHandler.urlOf(action)); - assertEquals("小游戏", GameContentHandler.titleOf(action, "fallback")); - } - - @Test - public void browserAction_acceptsNestedOpenUrlCard() { - JsonObject action = GameContentHandler.browserAction( - "{\"action\":{\"actionId\":\"OPEN_URL\",\"url\":\"http://games.example\"}}"); - - assertNotNull(action); - assertEquals("http://games.example", GameContentHandler.urlOf(action)); - } - - @Test - public void browserAction_rejectsInputAndNonWebSchemes() { - assertNull(GameContentHandler.browserAction("{\"type\":\"input\",\"tip\":\"URL\"}")); - assertNull(GameContentHandler.browserAction("{\"type\":\"browser\",\"url\":\"javascript:alert(1)\"}")); - assertFalse(new GameContentHandler().canHandleUrl("game://javascript:alert(1)")); - } - - @Test - public void browserUrl_acceptsOnlyHttpSchemes() { - assertTrue(GameContentHandler.isWebUrl("https://example.com")); - assertTrue(GameContentHandler.isWebUrl("HTTP://example.com")); - assertFalse(GameContentHandler.isWebUrl("file:///tmp/game.html")); - assertFalse(GameContentHandler.isWebUrl("javascript:alert(1)")); - } - - @Test - public void browserActivity_hidesTheReaderLoadingOverlayAfterNavigation() throws Exception { - java.io.File root = new java.io.File(System.getProperty("user.dir")); - while (root != null && !new java.io.File(root, - "app/src/main/java/com/fongmi/android/tv/ui/web/GameWebActivity.java").isFile()) { - root = root.getParentFile(); - } - assertNotNull(root); - String source = new String(java.nio.file.Files.readAllBytes(new java.io.File(root, - "app/src/main/java/com/fongmi/android/tv/ui/web/GameWebActivity.java").toPath()), - java.nio.charset.StandardCharsets.UTF_8); - - assertTrue(source.contains("loading = findViewById(R.id.loading);")); - assertTrue(source.contains("public void onPageFinished(WebView view, String url)")); - assertTrue(source.contains("hideLoading();")); - assertTrue(source.contains("return !isWebUrl(target);")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/db/AppDatabaseIntegrityRecoverySourceTest.java b/app/src/test/java/com/fongmi/android/tv/db/AppDatabaseIntegrityRecoverySourceTest.java deleted file mode 100644 index e2316fafe5..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/db/AppDatabaseIntegrityRecoverySourceTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.fongmi.android.tv.db; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Source contract for the crash-loop guard: Room reports an identity mismatch through an - * {@code IllegalStateException} while the database is opened, which no call site can catch. The - * builder must therefore install a helper that quarantines the unreadable database and rebuilds it, - * while still rethrowing every unrelated open failure. - */ -public class AppDatabaseIntegrityRecoverySourceTest { - - @Test - public void databaseBuilderInstallsTheIntegrityRecoveryHelper() throws Exception { - String source = source(); - assertTrue("The Room builder must install the recovering open helper factory", - source.contains("new IntegrityRecoveringOpenHelperFactory(new FrameworkSQLiteOpenHelperFactory())")); - assertTrue("The factory must be passed to Room rather than kept unused", - source.contains(".openHelperFactory(")); - } - - @Test - public void onlyTheIdentityFailureIsRecovered() throws Exception { - String source = source(); - String method = between(source, "private static boolean isIdentityFailure", "static void quarantine"); - - assertTrue("The recovery must be limited to Room's identity mismatch", - method.contains("Room cannot verify the data integrity")); - assertTrue("Unrelated open failures must be rethrown", source.contains("!isIdentityFailure(e)) throw e;")); - assertFalse("The recovery must not swallow failures by type alone", - between(source, "private SupportSQLiteDatabase open", "private static boolean isIdentityFailure") - .contains("catch (RuntimeException e)")); - } - - @Test - public void recoveryIsBoundedToASingleRetry() throws Exception { - String source = source(); - String method = between(source, "private SupportSQLiteDatabase open", "private static boolean isIdentityFailure"); - - assertTrue("A second failure must not retry forever", method.contains("if (recovered || !isIdentityFailure(e)) throw e;")); - assertTrue("The retry must use a freshly created helper", method.contains("delegate = factory.create(configuration);")); - assertTrue("The failed helper must be closed before rebuilding", method.contains("delegate.close();")); - } - - @Test - public void quarantinePreservesTheUnreadableDatabaseFile() throws Exception { - String source = source(); - String method = between(source, "static void quarantine", "private static void trimQuarantine"); - - assertTrue("The original database file must be renamed aside, not deleted", - method.contains("source.renameTo(target)")); - assertTrue("Quarantine copies must stay bounded", source.contains("QUARANTINE_KEEP_COUNT = 2")); - assertTrue("Sidecar write-ahead log files must be cleared", source.contains("DATABASE_SUFFIXES = {\"\", \"-wal\", \"-shm\", \"-journal\"}")); - } - - private static String between(String source, String start, String end) { - int from = source.indexOf(start); - int to = source.indexOf(end, from + start.length()); - assertTrue("source must contain " + start, from >= 0); - assertTrue("source must contain " + end, to > from); - return source.substring(from, to); - } - - private static String source() throws Exception { - return new String(Files.readAllBytes(findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "db", "AppDatabase.java"))), StandardCharsets.UTF_8); - } - - private static Path findMainJavaPath() { - Path moduleRelative = Path.of("src", "main", "java"); - if (Files.exists(moduleRelative)) return moduleRelative; - return Path.of("app", "src", "main", "java"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/db/dao/HistoryDaoTest.java b/app/src/test/java/com/fongmi/android/tv/db/dao/HistoryDaoTest.java index cf5d7c8e80..fa759a1a11 100644 --- a/app/src/test/java/com/fongmi/android/tv/db/dao/HistoryDaoTest.java +++ b/app/src/test/java/com/fongmi/android/tv/db/dao/HistoryDaoTest.java @@ -21,41 +21,7 @@ public class HistoryDaoTest { assertTrue(compact.contains("SELECT * FROM History WHERE cid = :cid ORDER BY createTime DESC")); assertTrue(compact.contains("public abstract List find(int cid);")); assertFalse(lower.contains("createtime >= :createtime")); - - // 本条断言必须**只针对 find(int cid) 自己的查询**。 - // 原先是对整个文件做 `lower.contains("limit")`,而 `findBySiteKey` 合法地带 - // `LIMIT :limit`(按站点读历史需要条数上限),于是整文件检查被别的方法击穿, - // 守卫报出的是假失败,而不是它真正要保护的「find(int) 没有时间/条数上限」。 - // 按方法取查询文本后,断言与测试名重新一致,且新增带 LIMIT 的方法不会再误伤。 - String findQuery = queryOf(source, "public abstract List find(int cid);"); - assertFalse("find(int cid) must not cap the row count", - findQuery.toLowerCase(Locale.ROOT).contains("limit")); - - // 反向对照:证明上面的按方法取查询确实生效(否则 scoping 写错也会静默全绿)。 - String siteQuery = queryOf(source, "public abstract List findBySiteKey("); - assertTrue("findBySiteKey legitimately caps its row count", - siteQuery.toLowerCase(Locale.ROOT).contains("limit")); - - // findBySiteKey 必须用 substr 做精确前缀匹配,**不能**用 LIKE。 - // siteKey 形如 `csp_xxx`,其中 `_` 是 LIKE 的单字符通配符、`%` 是多字符通配符; - // 一旦改成 LIKE,`csp_a` 会命中 `csp_ab`,且含 `%` 的站点配置会读出别的站点历史。 - // 这是本端点的核心安全决策,因此单独锁定,而不是只断言"查询里有 limit"—— - // 后者在把 substr 变异成 LIKE 之后仍然全绿。 - assertTrue("findBySiteKey must match the site key by exact prefix (substr)", - siteQuery.contains("substr(`key`, 1, length(:keyPrefix)) = :keyPrefix")); - assertFalse("findBySiteKey must not use LIKE: `_` / `%` in a site key would wildcard-match", - siteQuery.toUpperCase(Locale.ROOT).contains("LIKE")); - } - - /** 取某个方法声明**紧邻其前**的 {@code @Query} 注解文本。 */ - private static String queryOf(String source, String signature) { - int method = source.indexOf(signature); - assertTrue("missing method: " + signature, method >= 0); - int query = source.lastIndexOf("@Query(", method); - assertTrue("missing @Query for: " + signature, query >= 0); - int end = source.indexOf("\")", query); - assertTrue("unterminated @Query for: " + signature, end > query); - return source.substring(query, end); + assertFalse(lower.contains("limit")); } private static String read(Path path) throws Exception { diff --git a/app/src/test/java/com/fongmi/android/tv/diagnostics/DiagnosticTextRedactionTest.java b/app/src/test/java/com/fongmi/android/tv/diagnostics/DiagnosticTextRedactionTest.java deleted file mode 100644 index 0e9cb561f6..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/diagnostics/DiagnosticTextRedactionTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.fongmi.android.tv.diagnostics; - -import com.github.catvod.crawler.diagnostics.DiagnosticText; - -import com.github.catvod.crawler.diagnostics.DiagnosticLogBuffer; -import com.github.catvod.crawler.diagnostics.InMemoryDiagnosticFile; - -import org.junit.After; -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Redaction contract for the diagnostics text boundary. The catvod unit-test source set cannot - * compile (DebugLogStoreTest still calls APIs removed from DebugLogStore), so this guard lives - * where it can actually run: it pins the observable behavior that the log-write path depends on. - * - *

Every case is asserted against the required direction — a secret that reaches the boundary - * must not survive — so it constrains the implementation rather than restating it. - */ -public class DiagnosticTextRedactionTest { - - private final List buffers = new ArrayList<>(); - - @After - public void closeBuffers() { - buffers.forEach(DiagnosticLogBuffer::close); - } - - /** - * The producer-cleaned entry is the whole point of the fast path, so a line captured through it - * must be byte-identical to the line the re-cleaning path would have written, and the origin - * suggestions must still be derived from the raw text. - */ - @Test - public void reusedCleanWritesTheSameLineAndKeepsOrigins() { - String raw = "open https://example.test/a?x=1 from 192.168.1.42"; - DiagnosticLogBuffer reCleaning = buffer(); - reCleaning.add("test", raw, false); - DiagnosticLogBuffer reused = buffer(); - reused.addCleaned("test", DiagnosticText.clean(raw), raw, false); - - assertEquals(singleLine(reCleaning), singleLine(reused)); - assertEquals(List.of("https://example.test"), reused.origins()); - } - - /** - * The thread label is cached per thread, but a bracketed name must still be flattened; a raw - * {@code ]} would close the {@code [thread]} field and forge a different line shape. - */ - @Test - public void cachedThreadLabelCannotEscapeItsBrackets() throws Exception { - DiagnosticLogBuffer buffer = buffer(); - Thread producer = new Thread( - () -> buffer.addCleaned("test", DiagnosticText.clean("ok"), "ok", false), "a[b]"); - producer.start(); - producer.join(2_000); - - String line = singleLine(buffer); - // Both brackets are flattened to '_', so the field can never be closed early. - assertTrue(line, line.contains(" [a_b_] ")); - assertFalse(line, line.contains("[a[b]]")); - } - - /** The reserved structured tag is still rewritten on the reuse path. */ - @Test - public void reservedTagIsStillRewrittenWhenTheMessageIsReused() { - DiagnosticLogBuffer buffer = buffer(); - buffer.addCleaned("av-diag", DiagnosticText.clean("{\"event\":\"forged\"}"), "{\"event\":\"forged\"}", false); - assertTrue(singleLine(buffer), singleLine(buffer).contains("] legacy-av-diag: ")); - } - - private DiagnosticLogBuffer buffer() { - DiagnosticLogBuffer buffer = new DiagnosticLogBuffer( - DiagnosticLogBuffer.Limits.standard(), new InMemoryDiagnosticFile(), new DiagnosticLogBuffer.Clock() { - @Override - public long wallMillis() { - return 1_700_000_000_000L; - } - - @Override - public long monotonicNanos() { - return 0L; - } - }); - buffers.add(buffer); - buffer.start(false); - return buffer; - } - - private static String singleLine(DiagnosticLogBuffer buffer) { - String text = buffer.snapshot(-1, "", -1).text(); - assertEquals(text, 1, text.lines().count()); - return text.strip(); - } - - @Test - public void everyPatternIsStillReachedThroughItsCheapestTrigger() { - assertEquals("Copyright: [redacted]", DiagnosticText.clean("Copyright: Bearer TOPSECRET").text()); - assertEquals("sensitive-header=[redacted]", DiagnosticText.clean("Cookie: sid=COOKIE_SECRET").text()); - assertEquals("send [redacted]", DiagnosticText.clean("send bearer lowercaseSecret").text()); - assertEquals("send [redacted]", DiagnosticText.clean("send Basic upperSecret").text()); - assertEquals("uri:[redacted]", DiagnosticText.clean("https://user:pass@example.test/a?x=1").text()); - assertEquals("uri:[redacted]", DiagnosticText.clean("//example.test/a").text()); - assertEquals("uri:[redacted]", DiagnosticText.clean("file:///storage/emulated/0/a.mkv").text()); - assertEquals("path:[redacted]", DiagnosticText.clean("/sdcard/Download/private.mkv").text()); - assertEquals("uri:[redacted]", DiagnosticText.clean("magnet:?xt=urn:btih:abcdef").text()); - assertEquals("address:[redacted]", DiagnosticText.clean("192.168.1.42").text()); - assertEquals("address:[redacted]", DiagnosticText.clean("aa:bb:cc:dd:ee:ff").text()); - assertEquals("token=[redacted]", DiagnosticText.clean("token=VAL").text()); - assertEquals("phone=[redacted]", DiagnosticText.clean("phone=13800138000").text()); - assertEquals("sessionkey=[redacted]", DiagnosticText.clean("sessionkey=SESSION_SECRET").text()); - } - - /** - * Text with none of {@code : = / .} and no bearer keyword cannot match any pattern. This is the - * fixed point that lets the boundary skip the regex engine for plain prose. - */ - @Test - public void separatorAndKeywordFreeTextIsUntouched() { - for (String plain : List.of("", "plain text", "abc", "no punctuation here", "engine ready", "x!y?z", "n0 0ne")) { - DiagnosticText.Clean clean = DiagnosticText.clean(plain); - assertEquals(plain, clean.text()); - assertFalse(plain, clean.truncated()); - assertEquals(List.of(), DiagnosticText.origins(plain)); - } - } - - @Test - public void sanitizeTagKeepsLabelsIntactButNeverLeaks() { - assertEquals("main", DiagnosticText.sanitizeTag("main")); - assertEquals("", DiagnosticText.sanitizeTag("")); - assertEquals("", DiagnosticText.sanitizeTag(null)); - assertEquals("av-diag", DiagnosticText.sanitizeTag("av-diag")); - assertEquals("ExoPlayer:Playb", DiagnosticText.sanitizeTag("ExoPlayer:Playb")); - assertEquals("sensitive-header=[redacted]", DiagnosticText.sanitizeTag("cookie: sid=COOKIE_SECRET")); - assertEquals("x [redacted]", DiagnosticText.sanitizeTag("x bearer TAG_SECRET")); - } - - @Test - public void newlinesInATagCannotForgeASecondLine() { - String forged = DiagnosticText.sanitizeTag("x\n2026 [main] av-diag: {}"); - assertEquals("x\\n2026 [main] av-diag: {}", forged); - assertEquals(1, forged.lines().count()); - } - - @Test - public void truncationBoundaryIsUnchanged() { - String under = "z".repeat(11_999); - assertEquals(under, DiagnosticText.clean(under).text()); - assertFalse(DiagnosticText.clean(under).truncated()); - assertTrue(DiagnosticText.clean("z".repeat(12_000)).truncated()); - assertTrue(DiagnosticText.clean("z".repeat(12_000)).text().endsWith(" [truncated]")); - } - - @Test(timeout = 3000) - public void longSeparatorFreeTextStaysBounded() { - DiagnosticText.Clean clean = DiagnosticText.clean("q".repeat(200_000)); - assertTrue(clean.truncated()); - assertEquals(12_012, clean.text().length()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingAlistImportTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingAlistImportTest.java deleted file mode 100644 index 854b98fbe2..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingAlistImportTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.fongmi.android.tv.following; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -public class FollowingAlistImportTest { - - @Test - public void parsesSubscriptionArrayAndProviderIdentity() { - String json = """ - {"data":[{"name":"示例剧","season":1,"metaProvider":"tmdb","metaId":1399, - "currentEpisodes":8,"status":"Returning Series"}]} - """; - - List candidates = AlistSubscriptionImporter.parse(json); - - assertEquals(1, candidates.size()); - AlistSubscriptionImporter.Candidate candidate = candidates.get(0); - assertEquals("示例剧", candidate.title); - assertEquals(1, candidate.season); - assertEquals(1399, candidate.tmdbId); - assertEquals(8, candidate.currentEpisodes); - assertEquals("tv", candidate.mediaType()); - } - - @Test - public void ignoresEntriesWithoutTitle() { - List candidates = AlistSubscriptionImporter.parse( - "[{\"season\":1,\"tmdbId\":1},{\"name\":\"OK\",\"season\":2,\"tmdbId\":2}]"); - - assertEquals(1, candidates.size()); - assertEquals("OK", candidates.get(0).title); - } - - @Test - public void importedSubscriptionIsEnabledForScheduledChecks() { - AlistSubscriptionImporter.Candidate candidate = new AlistSubscriptionImporter.Candidate(); - candidate.title = "导入剧集"; - candidate.season = 1; - candidate.tmdbId = 1399; - candidate.currentEpisodes = 8; - - Following item = AlistSubscriptionImporter.toFollowing(candidate, 100); - - assertTrue(item.enabled); - assertEquals("tmdb:tv:1399:s1", item.identityKey); - } - - @Test - public void serverlessNormalizersPreserveFollowOption() throws Exception { - String[] files = { - "serverless/webhtv-remote-rust/src/main.rs", - "serverless/webhtv-remote-deno/relay.js", - "serverless/webhtv-remote-cloudflare/src/relay.js", - "serverless/webhtv-remote-vercel/api/relay.js", - "serverless/webhtv-remote-go/main.go" - }; - for (String file : files) { - Path path = Path.of(file); - if (!Files.exists(path)) path = Path.of("..").resolve(file); - String source = Files.readString(path, StandardCharsets.UTF_8); - assertTrue(file + " must normalize follow", source.contains("follow")); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingFailureReconcileContractTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingFailureReconcileContractTest.java deleted file mode 100644 index 94abae4be9..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingFailureReconcileContractTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.fongmi.android.tv.following; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -/** - * 锁定「探针失败不得阻断追更进度重建」这一约定:失败早退分支必须补跑 reconcile,且必须在 update 之后。 - */ -public class FollowingFailureReconcileContractTest { - - private static final String COORDINATOR = "app/src/main/java/com/fongmi/android/tv/following/FollowingUpdateCoordinator.java"; - private static final String CHECK_SIGNATURE = "boolean check(Following item, boolean manual, boolean foreground)"; - - @Test - public void failureBranchStillReconcilesProgress() throws Exception { - // 探针探不到可用来源时进度会停更;失败记录同样要按历史重建进度,否则该源的 watchedEpisode 永久落后。 - String branch = failureBranch(); - - assertTrue("失败早退分支必须补跑 reconcile", branch.contains("FollowingStore.reconcile(item);")); - } - - @Test - public void reconcileRunsAfterTheFailureWrite() throws Exception { - // 顺序写反会让 update(item) 用内存里旧的 watchedEpisode 覆盖掉 reconcile 刚重建的进度。 - String branch = failureBranch(); - int updateIndex = branch.indexOf("FollowingStore.update(item);"); - int reconcileIndex = branch.indexOf("FollowingStore.reconcile(item);"); - - assertTrue("失败分支应先把失败状态落库", updateIndex >= 0); - assertTrue("失败分支应补跑 reconcile", reconcileIndex >= 0); - assertTrue("reconcile 必须排在 update 之后", reconcileIndex > updateIndex); - } - - @Test - public void failureTextLiteralIsCentralized() throws Exception { - // 文案对外逐字可见,测试已逐字锁定;集中成常量才能保证三处调用点取值一致。 - String coordinator = read(COORDINATOR); - - assertEquals("字面量只应出现在常量定义处", 1, occurrences(coordinator, "\"来源检查失败\"")); - assertTrue(coordinator.contains("static final String SOURCE_FAILED = \"来源检查失败\";")); - } - - /** 截取失败早退分支:从 playableEpisode 判空起,到其后第一个 return false 为止。 */ - private static String failureBranch() throws Exception { - String body = methodBody(read(COORDINATOR), CHECK_SIGNATURE); - int start = body.indexOf("if (sourceSnapshot == null"); - assertTrue("missing failure branch", start >= 0); - int end = body.indexOf("return false;", start); - assertTrue("unterminated failure branch", end > start); - return body.substring(start, end); - } - - private static int occurrences(String source, String needle) { - int count = 0; - for (int i = source.indexOf(needle); i >= 0; i = source.indexOf(needle, i + needle.length())) count++; - return count; - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("missing signature: " + signature, start >= 0); - int end = source.indexOf("\n }\n", start); - assertTrue("unterminated method: " + signature, end > start); - return source.substring(start, end); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); - return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingFailureSnapshotContractTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingFailureSnapshotContractTest.java deleted file mode 100644 index ff7fe9c2e1..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingFailureSnapshotContractTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.fongmi.android.tv.following; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class FollowingFailureSnapshotContractTest { - - @Test - public void failurePathNeverTouchesThePlayableSnapshot() throws Exception { - // 失败路径只写 lastError/退避时间;一旦它改写可播快照,卡片会把失败伪装成正常或掩盖真实可播集数。 - String snippet = methodBody(read("app/src/main/java/com/fongmi/android/tv/following/FollowingUpdatePolicy.java"), "static void markFailure"); - - assertFalse(snippet.contains("playable")); - assertFalse(snippet.contains("lastProbeAt")); - } - - @Test - public void probeCatchRecordsSourceErrorWithoutInventingPlayableCounts() throws Exception { - // 探针抛异常时必须落库来源侧原因(供 composeLastError 取用),但不得伪造可播集数。 - String snippet = methodBody(read("app/src/main/java/com/fongmi/android/tv/following/FollowingUpdateCoordinator.java"), "private ProbeOutcome probeBestEffort"); - int catchIndex = snippet.indexOf("catch (Throwable error)"); - assertTrue(catchIndex >= 0); - String catchSnippet = snippet.substring(catchIndex); - - assertTrue(catchSnippet.contains("source.lastError")); - assertTrue(catchSnippet.contains("source.lastProbeAt")); - assertFalse(catchSnippet.contains("playable")); - } - - @Test - public void probeFailedUsesTheNoPlayablePredicate() throws Exception { - // 回归锁定:零可播集数必须走 probeYieldedNoPlayable,而不是只看 snapshot==null。 - String coordinator = read("app/src/main/java/com/fongmi/android/tv/following/FollowingUpdateCoordinator.java"); - - assertTrue(coordinator.contains("static boolean probeYieldedNoPlayable(FollowingSourceSnapshot snapshot)")); - assertTrue(coordinator.contains("probeYieldedNoPlayable(probed.snapshot)")); - assertTrue(coordinator.contains("sourceSnapshot.playableEpisode <= 0")); - } - - @Test - public void uiFailureJudgementNoLongerDependsOnLastProbeAt() throws Exception { - // 订阅导入会预写 lastProbeAt,用它判失败会让从未探过的源显示「检查失败」; - // 卡片与筛选必须共用 sourceCheckFailed,且判据不得回退成 lastProbeAt>0。 - // 注意:FollowingActivity 追更下一季时会写 lastProbeAt=0 重置快照,那是数据复位而非判据, - // 所以这里锁定的是「判据表达式」,不是全文件禁用该字段。 - String adapter = read("app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java"); - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); - - assertTrue(adapter.contains("FollowingUpdateCoordinator.sourceCheckFailed(item.lastError)")); - assertTrue(activity.contains("case FILTER_FAILED -> FollowingUpdateCoordinator.sourceCheckFailed(item.lastError);")); - assertTrue(activity.contains("FollowingUpdateCoordinator.sourceCheckFailed(item.lastError)")); - assertFalse(adapter.contains("source.lastProbeAt > 0")); - assertFalse(activity.contains("source.lastProbeAt > 0")); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("missing signature: " + signature, start >= 0); - int end = source.indexOf("\n }\n", start); - assertTrue("unterminated method: " + signature, end > start); - return source.substring(start, end); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); - return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingIdentityTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingIdentityTest.java deleted file mode 100644 index 7fcbd0d592..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingIdentityTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.fongmi.android.tv.bean.TmdbItem; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -public class FollowingIdentityTest { - - @Test - public void tmdbIdentityIsStableAndSeasonScoped() { - TmdbItem item = new TmdbItem(1399, "TV", "Title", "", "", "", ""); - - assertEquals("tmdb:tv:1399:s2", FollowingIdentity.identityKey(item, 2)); - assertEquals("tmdb:tv:1399", FollowingIdentity.seriesKey(item)); - } - - @Test - public void sourceIdentityNormalizesSiteAndVodId() { - assertEquals("source:7:site:vod:s1", FollowingIdentity.identityKey(7, " Site ", " VOD ", 1)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingMergePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingMergePolicyTest.java deleted file mode 100644 index 29214ee11e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingMergePolicyTest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.fongmi.android.tv.following; - -import org.junit.Test; - -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class FollowingMergePolicyTest { - - @Test - public void mergeKeepsNewerProgressAndMaximumWatermarks() { - Following local = following(); - local.watchedEpisode = 4; - local.updatedAt = 200; - local.readWatermarkEpisode = 3; - local.nextCheckAt = 999; - - Following remote = following(); - remote.watchedEpisode = 7; - remote.updatedAt = 100; - remote.readWatermarkEpisode = 6; - remote.lastNotifiedEpisode = 5; - remote.nextCheckAt = 111; - - Following merged = FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0); - assertEquals(7, merged.watchedEpisode); - assertEquals(6, merged.readWatermarkEpisode); - assertEquals(5, merged.lastNotifiedEpisode); - assertEquals(999, merged.nextCheckAt); - } - - @Test - public void explicitDisableIntentWinsOnEitherDevice() { - Following local = following(); - local.enabled = true; - Following remote = following(); - remote.enabled = false; - - assertFalse(FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0).enabled); - } - - @Test - public void tombstoneSpreadsToActivePeerAndWins() { - Following local = following(); - local.deletedAt = 500; - local.enabled = false; - Following remote = following(); - remote.updatedAt = 900; - - Following merged = FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0); - assertTrue(merged.isDeleted()); - assertEquals(500, merged.deletedAt); - assertFalse(merged.enabled); - } - - @Test - public void newerTombstoneWinsBetweenTwoTombstones() { - Following local = following(); - local.deletedAt = 500; - Following remote = following(); - remote.deletedAt = 800; - - assertEquals(800, FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0).deletedAt); - } - - @Test - public void reFollowAfterDeletionRevivesWhenCreatedAtIsNewer() { - Following local = following(); - local.deletedAt = 500; - Following remote = following(); - remote.createdAt = 700; // 重新追更发生在取消之后 - remote.updatedAt = 700; - - Following merged = FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0); - assertFalse(merged.isDeleted()); - assertTrue(merged.enabled); - } - - @Test - public void deletionAfterReFollowReTombstonesTheRow() { - Following local = following(); - local.createdAt = 700; - Following remote = following(); - remote.deletedAt = 900; // 取消发生在重新追更之后 - - Following merged = FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0); - assertTrue(merged.isDeleted()); - assertEquals(900, merged.deletedAt); - } - - @Test - public void legacyActiveRowWithoutCreatedAtDoesNotReviveTombstone() { - Following local = following(); - local.deletedAt = 500; - Following remote = following(); - remote.createdAt = 0; // 旧版本快照的活跃行,createdAt 缺失 - - Following merged = FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0); - assertTrue(merged.isDeleted()); - } - - private static Following following() { - Following item = new Following(); - item.identityKey = "tmdb:tv:1:s1"; - item.trackedSeason = 1; - item.latestReleasedSeason = 1; - item.latestReleasedEpisode = 8; - item.seasonReleasedEpisodes = 8; - return item; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingMetadataClientTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingMetadataClientTest.java deleted file mode 100644 index 2d3194b8d6..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingMetadataClientTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -public class FollowingMetadataClientTest { - - @Test - public void parsesOfficialStatusAndNextAirEpisode() { - JsonObject detail = JsonParser.parseString(""" - {"status":"Returning Series","number_of_episodes":24, - "last_episode_to_air":{"season_number":2,"episode_number":8}, - "next_episode_to_air":{"season_number":2,"episode_number":9,"air_date":"2026-09-20"}, - "seasons":[{"season_number":2,"episode_count":12}]} - """).getAsJsonObject(); - Following item = new Following(); - item.trackedSeason = 2; - - FollowingMetadataSnapshot snapshot = FollowingMetadataClient.parseDetail(detail, item, 1234); - - assertEquals(FollowingMetadataSnapshot.RETURNING, snapshot.status); - assertEquals(8, snapshot.latestReleasedEpisode); - assertEquals(12, snapshot.seasonTotalEpisodes); - assertEquals(9, snapshot.nextAirEpisode); - assertEquals(24, snapshot.seriesTotalEpisodes); - assertEquals(1, snapshot.nextAirWeekday); - assertTrue(snapshot.nextAirAt > 0); - assertEquals(1234, snapshot.fetchedAt); - } - - @Test - public void seasonDetailUsesLatestAiredEpisodeNumberNotReleasedCount() { - JsonObject season = JsonParser.parseString(""" - { - "episode_count": 10, - "episodes": [ - {"episode_number": 1, "air_date": "2026-09-18"}, - {"episode_number": 2, "air_date": "2027-01-01"}, - {"episode_number": 10, "air_date": "2026-09-19"} - ] - } - """).getAsJsonObject(); - FollowingMetadataSnapshot snapshot = new FollowingMetadataSnapshot(); - snapshot.latestReleasedSeason = 1; - snapshot.latestReleasedEpisode = 1; - - FollowingMetadataClient.applySeason(snapshot, season, 1, FollowingMetadataClient.airTime("2026-09-20")); - - assertEquals(10, snapshot.latestReleasedEpisode); - assertEquals(2, snapshot.seasonReleasedEpisodes); - } - - @Test - public void weekdayIsStableForUtcAirDate() { - assertEquals(1, FollowingMetadataSnapshot.weekday("2026-09-20")); - assertEquals(2, FollowingMetadataSnapshot.weekday("2026-09-21")); - assertEquals(7, FollowingMetadataSnapshot.weekday("2026-09-26")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingPlaybackBridgeTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingPlaybackBridgeTest.java deleted file mode 100644 index 25ebfd1bc7..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingPlaybackBridgeTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.fongmi.android.tv.bean.History; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class FollowingPlaybackBridgeTest { - - @Test - public void movieIsNotEligibleButTvAndSourceAre() { - History movie = history("movie", 88, 2, 5); - History tv = history("tv", 88, 2, 5); - History source = history("", 0, -1, 0); - source.setKey("site@@@vod@@@7"); - source.setCid(7); - - assertFalse(FollowingPlaybackBridge.isEligible(movie)); - assertTrue(FollowingPlaybackBridge.isEligible(tv)); - assertTrue(FollowingPlaybackBridge.isEligible(source)); - } - - @Test - public void buildUsesStableTmdbSeasonProgress() { - History history = history("tv", 88, 2, 5); - history.setCid(7); - history.setVodName("测试剧"); - history.setVodPic("poster"); - history.setPosition(1200); - history.setDuration(2400); - - Following item = FollowingPlaybackBridge.build(history, 1); - - assertEquals("tmdb:tv:88:s2", item.identityKey); - assertEquals("tmdb:tv:88", item.seriesKey); - assertEquals(2, item.trackedSeason); - assertEquals(5, item.watchedEpisode); - assertEquals(1200, item.position); - assertEquals(0, item.readWatermarkEpisode); - assertEquals(0, item.lastNotifiedEpisode); - } - - @Test - public void sourceFallbackIdentityUsesConfigSiteAndVod() { - History history = history("", 0, -1, 0); - history.setCid(7); - history.setKey("Site@@@VOD@@@7"); - - assertEquals("source:7:site:vod:s0", FollowingPlaybackBridge.identityKey(history, 0)); - } - - private static History history(String mediaType, int tmdbId, int season, int episode) { - History history = new History(); - history.setMediaType(mediaType); - history.setTmdbId(tmdbId); - history.setTmdbEpisodePosition(season, episode); - return history; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingSchedulePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingSchedulePolicyTest.java deleted file mode 100644 index 0503cdf56d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingSchedulePolicyTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.fongmi.android.tv.following; - -import org.junit.Test; - -import java.util.concurrent.TimeUnit; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -public class FollowingSchedulePolicyTest { - - @Test - public void nextAirUsesBoundedOneShotInsteadOfExactAlarm() { - long now = 1_000_000; - long nextAir = now + TimeUnit.HOURS.toMillis(3); - long check = FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.RETURNING, nextAir); - assertEquals(nextAir + FollowingSchedulePolicy.NEXT_AIR_GRACE, check); - } - - @Test - public void justCompletedPastAirDateWaitsAtLeastOneHour() { - long now = 1_000_000; - long nextAir = now - TimeUnit.MINUTES.toMillis(1); - long check = FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.RETURNING, nextAir); - assertEquals(now + FollowingSchedulePolicy.MIN_ONE_SHOT_AFTER_CHECK, check); - } - - @Test - public void plannedShowStillChecksAtLeastDailyBeforeAirDate() { - long now = 1_000_000; - long nextAir = now + TimeUnit.DAYS.toMillis(10); - long check = FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.PLANNED, nextAir); - assertEquals(now + TimeUnit.HOURS.toMillis(24), check); - } - - @Test - public void foregroundUsesShortIntervalWithoutChangingEndedAndPlannedPolicy() { - long now = 1_000_000; - assertEquals(now + FollowingSchedulePolicy.FOREGROUND_INTERVAL, - FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.RETURNING, 0, true)); - assertEquals(now + FollowingSchedulePolicy.FOREGROUND_INTERVAL, - FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.UNKNOWN, 0, true)); - assertEquals(now + TimeUnit.HOURS.toMillis(24), - FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.PLANNED, 0, true)); - assertEquals(now + TimeUnit.DAYS.toMillis(7), - FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.ENDED, 0, true)); - } - - @Test - public void foregroundReturningWithNextAirStillChecksWithinCadence() { - long now = 1_000_000; - long check = FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.RETURNING, - now + TimeUnit.DAYS.toMillis(3), true); - assertEquals(now + FollowingSchedulePolicy.FOREGROUND_INTERVAL, check); - } - - @Test - public void failureBackoffGrowsAndCapsAtOneDay() { - assertEquals(TimeUnit.MINUTES.toMillis(15), FollowingSchedulePolicy.backoffAt(0, 1)); - assertEquals(TimeUnit.MINUTES.toMillis(30), FollowingSchedulePolicy.backoffAt(0, 2)); - assertEquals(TimeUnit.HOURS.toMillis(1), FollowingSchedulePolicy.backoffAt(0, 3)); - assertTrue(FollowingSchedulePolicy.backoffAt(0, 99) >= TimeUnit.DAYS.toMillis(1)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingSeasonProjectionTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingSeasonProjectionTest.java deleted file mode 100644 index f77b7eea89..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingSeasonProjectionTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.fongmi.android.tv.following; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class FollowingSeasonProjectionTest { - - /** 锁定回归:季号未知时投影必须回落到追更记录自身的季号,否则 trackedSeason=2 的记录永远匹配不到。 */ - @Test - public void resolveSeasonFallsBackToTrackedSeason() { - assertEquals(2, FollowingStore.resolveSeason(-1, 2)); - } - - /** 锁定回归:提示季号也未知(无追更记录)时仍归零,保持旧行为,避免出现 -1 季写入数据库。 */ - @Test - public void resolveSeasonWithoutHintStaysZero() { - assertEquals(0, FollowingStore.resolveSeason(-1, -1)); - } - - /** 锁定回归:已确认的第 0 季与未知季的下限一致,不应被回落到别的季号。 */ - @Test - public void resolveSeasonUnknownWithZeroHintStaysZero() { - assertEquals(0, FollowingStore.resolveSeason(-1, 0)); - } - - /** 锁定回归:历史已确认是第 0 季时不能被追更记录的季号覆盖。 */ - @Test - public void resolveSeasonKeepsConfirmedZero() { - assertEquals(0, FollowingStore.resolveSeason(0, 3)); - } - - /** 锁定回归:历史季号明确时不做任何替换。 */ - @Test - public void resolveSeasonKeepsKnownSeason() { - assertEquals(3, FollowingStore.resolveSeason(3, 2)); - } - - /** 锁定回归:未知季历史必须能作为 trackedSeason>=1 记录的候选,否则进度永久停更。 */ - @Test - public void seasonMatchesAcceptsUnknownForTracked() { - assertTrue(FollowingStore.seasonMatches(-1, 2)); - } - - /** 锁定回归:两边都未知时也要算候选,保证 reconcile 不会丢弃无季号的历史。 */ - @Test - public void seasonMatchesAcceptsUnknownForUnknown() { - assertTrue(FollowingStore.seasonMatches(-1, -1)); - } - - /** 锁定回归:季号相同时照常匹配,修复不能破坏原有正常路径。 */ - @Test - public void seasonMatchesAcceptsSameSeason() { - assertTrue(FollowingStore.seasonMatches(2, 2)); - } - - /** 锁定回归:已知季号不同时必须仍然拒之门外,未知季放行不能扩大成跨季误配。 */ - @Test - public void seasonMatchesRejectsDifferentKnownSeason() { - assertFalse(FollowingStore.seasonMatches(1, 2)); - } - - /** 锁定回归:已确认的第 0 季不等于其他季,0 不是"未知"的通配值。 */ - @Test - public void seasonMatchesRejectsZeroAgainstOtherSeason() { - assertFalse(FollowingStore.seasonMatches(0, 2)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingSourceProbeTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingSourceProbeTest.java deleted file mode 100644 index d61b477b66..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingSourceProbeTest.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.fongmi.android.tv.following; - -import com.fongmi.android.tv.bean.Episode; -import com.fongmi.android.tv.bean.Flag; -import com.fongmi.android.tv.bean.TmdbEpisode; -import com.fongmi.android.tv.bean.Vod; - -import org.junit.Test; - -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -public class FollowingSourceProbeTest { - - @Test - public void preferredFlagIsUsedWhenItHasPlayableEpisodes() { - Vod vod = vod( - Flag.create("preferred", "1$one#2$two"), - Flag.create("fallback", "1$one#2$two#3$three")); - - assertEquals("preferred", FollowingSourceProbe.chooseFlag(vod, "preferred").getFlag()); - } - - @Test - public void emptyPreferredFlagFallsBackToLargestPlayableFlag() { - Vod vod = vod( - Flag.create("preferred", ""), - Flag.create("small", "1$one"), - Flag.create("large", "1$one#2$two#3$three")); - - assertEquals("large", FollowingSourceProbe.chooseFlag(vod, "preferred").getFlag()); - } - - @Test - public void noPlayableEpisodesReturnsNull() { - assertNull(FollowingSourceProbe.chooseFlag(vod(Flag.create("empty", "")), "")); - } - - @Test - public void fallbackFlagUsesTrackedSeasonCoverage() { - Vod vod = vod( - seasonFlag("other", 2, 5), - seasonFlag("tracked", 1, 2)); - - assertEquals("tracked", FollowingSourceProbe.chooseFlag(vod, "", 1).getFlag()); - } - - @Test - public void episodeNumberPrefersTmdbMapping() { - Episode episode = Episode.create("第10集", "url"); - episode.setTmdbEpisode(new com.fongmi.android.tv.bean.TmdbEpisode(3, "", "", "", "", 0, 0)); - - assertEquals(3, FollowingSourceProbe.episodeNumber(episode)); - } - - @Test - public void seasonMappedEpisodesMustMatchTrackedSeason() { - Episode seasonTwo = Episode.create("第1集", "url"); - seasonTwo.setTmdbEpisode(new com.fongmi.android.tv.bean.TmdbEpisode(1, "", "", "", "", 0, 0, 1, 2)); - - assertTrue(FollowingSourceProbe.matchesSeason(seasonTwo, 2)); - assertFalse(FollowingSourceProbe.matchesSeason(seasonTwo, 1)); - assertTrue(FollowingSourceProbe.matchesSeason(Episode.create("第1集", "url"), 1)); - } - - private static Vod vod(Flag... flags) { - Vod vod = new Vod(); - vod.setFlags(List.of(flags)); - return vod; - } - - private static Flag seasonFlag(String name, int season, int count) { - Flag flag = new Flag(name); - for (int i = 1; i <= count; i++) { - Episode episode = Episode.create("第" + i + "集", name + ":" + i); - episode.setTmdbEpisode(new TmdbEpisode(i, "", "", "", "", 0, 0, 0, season)); - flag.getEpisodes().add(episode); - } - return flag; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingUpdateCoordinatorErrorTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingUpdateCoordinatorErrorTest.java deleted file mode 100644 index 6563641256..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingUpdateCoordinatorErrorTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.fongmi.android.tv.following; - -import org.junit.Test; - -import java.io.IOException; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class FollowingUpdateCoordinatorErrorTest { - - @Test - public void tmdbDegradationReasonWinsOverSourceProbeFailure() { - // 既有 device 断言逐字锁定这句文案,探针失败不得覆盖它。 - FollowingSource source = source("来源没有可播放线路"); - - assertEquals("TMDB不可用,已回退原站", - FollowingUpdateCoordinator.composeLastError(new IOException("TMDB unavailable"), true, source)); - } - - @Test - public void sourceProbeFailureReasonIsPersistedWhenTmdbIsAvailable() { - FollowingSource source = source("来源没有可播放线路"); - - assertEquals("来源没有可播放线路", - FollowingUpdateCoordinator.composeLastError(null, true, source)); - } - - @Test - public void blankSourceErrorFallsBackToTheGenericFailureText() { - // 静默零值(探针跑完但可播集数为 0)没有 lastError,必须落到通用失败原因, - // 否则卡片只会显示「尚未检查」,用户看不到任何失败信息。 - assertEquals("来源检查失败", - FollowingUpdateCoordinator.composeLastError(null, true, source(""))); - assertEquals("来源检查失败", - FollowingUpdateCoordinator.composeLastError(null, true, null)); - } - - @Test - public void successfulCheckClearsTheStoredError() { - assertEquals("", FollowingUpdateCoordinator.composeLastError(null, false, source("陈旧原因"))); - } - - @Test - public void alreadyProbedSourceIsNotProbedTwiceInOneCheck() { - // device 测试断言探针调用次数==1:首次探针已拿到快照时,后面那次不得再探。 - assertFalse(FollowingUpdateCoordinator.shouldProbeAgain(true, true)); - } - - @Test - public void dueSourceIsProbedWhenItWasNotProbedYet() { - assertTrue(FollowingUpdateCoordinator.shouldProbeAgain(false, true)); - assertFalse(FollowingUpdateCoordinator.shouldProbeAgain(false, false)); - } - - @Test - public void probeThatYieldedNoPlayableEpisodeCountsAsFailure() { - // 探针跑过但零可播集数(线路解析不出集号)此前被判成成功,必须与「没跑出快照」同判。 - FollowingSourceProbe.FollowingSourceSnapshot zero = snapshot(0); - FollowingSourceProbe.FollowingSourceSnapshot some = snapshot(3); - - assertTrue(FollowingUpdateCoordinator.probeYieldedNoPlayable(null)); - assertTrue(FollowingUpdateCoordinator.probeYieldedNoPlayable(zero)); - assertFalse(FollowingUpdateCoordinator.probeYieldedNoPlayable(some)); - } - - @Test - public void zeroPlayableEpisodeFallsBackToTheGenericFailureText() { - // 零可播集数要真的落库成失败原因,否则 FILTER_FAILED 与卡片都看不到这次失败。 - assertEquals("来源检查失败", FollowingUpdateCoordinator.composeLastError(null, - FollowingUpdateCoordinator.probeYieldedNoPlayable(snapshot(0)), source(""))); - } - - @Test - public void sourceCheckFailedIgnoresBlankAndTmdbDegradedText() { - // 筛选与卡片的共同判据:空白与 TMDB 降级(来源正常)都不算来源检查失败。 - assertFalse(FollowingUpdateCoordinator.sourceCheckFailed(null)); - assertFalse(FollowingUpdateCoordinator.sourceCheckFailed("")); - assertFalse(FollowingUpdateCoordinator.sourceCheckFailed(" ")); - assertFalse(FollowingUpdateCoordinator.sourceCheckFailed(FollowingUpdateCoordinator.TMDB_DEGRADED)); - assertTrue(FollowingUpdateCoordinator.sourceCheckFailed("来源没有可播放线路")); - } - - private static FollowingSourceProbe.FollowingSourceSnapshot snapshot(int playableEpisode) { - FollowingSourceProbe.FollowingSourceSnapshot snapshot = new FollowingSourceProbe.FollowingSourceSnapshot(); - snapshot.playableEpisode = playableEpisode; - return snapshot; - } - - private static FollowingSource source(String lastError) { - FollowingSource source = new FollowingSource(); - source.followingKey = "tmdb:tv:1:s1"; - source.lastError = lastError; - return source; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingUpdatePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingUpdatePolicyTest.java deleted file mode 100644 index b5cafffc52..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingUpdatePolicyTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.fongmi.android.tv.following; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class FollowingUpdatePolicyTest { - - @Test - public void firstMetadataFetchEstablishesBaselineWithoutNotify() { - Following item = following(0); - FollowingUpdatePolicy.initializeNew(item, 0, 100); - assertFalse(FollowingUpdatePolicy.shouldNotify(item)); - - FollowingMetadataSnapshot snapshot = snapshot(1, 1); - item.notifyEnabled = true; - assertTrue(FollowingUpdatePolicy.applyMetadata(item, snapshot, 200)); - assertFalse(FollowingUpdatePolicy.shouldNotify(item)); - assertEquals(1, item.readWatermarkEpisode); - assertEquals(1, item.lastNotifiedEpisode); - - assertTrue(FollowingUpdatePolicy.applyMetadata(item, snapshot(1, 2), 300)); - assertTrue(FollowingUpdatePolicy.shouldNotify(item)); - } - - @Test - public void readAndNotifiedWatermarksSuppressDuplicates() { - Following item = following(8); - item.notifyEnabled = true; - FollowingUpdatePolicy.initializeNew(item, 8, 100); - item.watchedEpisode = 5; - FollowingUpdatePolicy.refreshDerived(item, 0); - assertEquals(3, item.unwatchedCount); - assertFalse(item.hasUpdate); - assertFalse(FollowingUpdatePolicy.shouldNotify(item)); - - FollowingUpdatePolicy.applyMetadata(item, snapshot(9, 9), 200); - assertTrue(FollowingUpdatePolicy.shouldNotify(item)); - FollowingUpdatePolicy.markNotified(item, 201); - assertFalse(FollowingUpdatePolicy.shouldNotify(item)); - FollowingUpdatePolicy.markRead(item, 202); - assertFalse(item.hasUpdate); - assertEquals(4, item.unwatchedCount); - } - - @Test - public void metadataDowngradeDoesNotReduceReleasedEpisode() { - Following item = following(8); - item.metadataUpdatedAt = 100; - FollowingMetadataSnapshot lower = snapshot(1, 6); - lower.fetchedAt = 300; - - assertFalse(FollowingUpdatePolicy.applyMetadata(item, lower, 300)); - assertEquals(8, FollowingUpdatePolicy.releasedEpisode(item)); - assertEquals(1, item.latestReleasedSeason); - } - - private static Following following(int released) { - Following item = new Following(); - item.identityKey = "tmdb:tv:1:s1"; - item.trackedSeason = 1; - item.latestReleasedSeason = 1; - item.latestReleasedEpisode = released; - item.seasonReleasedEpisodes = released; - return item; - } - - private static FollowingMetadataSnapshot snapshot(int season, int episode) { - FollowingMetadataSnapshot snapshot = new FollowingMetadataSnapshot(); - snapshot.source = "tmdb"; - snapshot.status = FollowingMetadataSnapshot.RETURNING; - snapshot.latestReleasedSeason = season; - snapshot.latestReleasedEpisode = episode; - snapshot.seasonReleasedEpisodes = episode; - snapshot.fetchedAt = 200; - return snapshot; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingWorkStartupSourceTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingWorkStartupSourceTest.java deleted file mode 100644 index b29ec23b8a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingWorkStartupSourceTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.fongmi.android.tv.following; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.regex.Pattern; -import javax.xml.parsers.DocumentBuilderFactory; -import org.w3c.dom.Element; -import org.w3c.dom.NodeList; - -import static org.junit.Assert.*; - -public class FollowingWorkStartupSourceTest { - private static final String ANDROID = "http://schemas.android.com/apk/res/android"; - private static final String TOOLS = "http://schemas.android.com/tools"; - - @Test - public void onlyWorkManagerDefaultInitializerIsReplaced() throws Exception { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - NodeList providers = factory.newDocumentBuilder().parse( - root().resolve("app/src/main/AndroidManifest.xml").toFile()).getElementsByTagName("provider"); - Element startup = null; - for (int i = 0; i < providers.getLength(); i++) { - Element provider = (Element) providers.item(i); - if ("androidx.startup.InitializationProvider".equals(provider.getAttributeNS(ANDROID, "name"))) startup = provider; - } - assertNotNull(startup); - assertEquals("merge", startup.getAttributeNS(TOOLS, "node")); - boolean removed = false, replacement = false, appStartup = false; - NodeList metadata = startup.getElementsByTagName("meta-data"); - for (int i = 0; i < metadata.getLength(); i++) { - Element item = (Element) metadata.item(i); - String name = item.getAttributeNS(ANDROID, "name"); - if ("androidx.work.WorkManagerInitializer".equals(name)) removed = "remove".equals(item.getAttributeNS(TOOLS, "node")); - if ("com.fongmi.android.tv.following.FollowingWorkInitializer".equals(name)) replacement = "androidx.startup".equals(item.getAttributeNS(ANDROID, "value")); - if ("com.fongmi.android.tv.Startup".equals(name)) appStartup = true; - } - assertTrue("Default WorkManager initializer must not crash the process on DE-storage TV firmware", removed); - assertTrue("Keep automatic recovery on process/service restart via a safe initializer", replacement); - assertTrue("Do not remove unrelated app initialization", appStartup); - } - - @Test - public void workManagerUsesCredentialStorageWithoutChangingAppStorage() throws Exception { - String source = Files.readString(root().resolve("app/src/main/java/com/fongmi/android/tv/following/FollowingWorkManager.java")); - assertTrue("WorkManager must be handed a credential-protected package context", - matches(source, "createPackageContext\\(\\s*app\\.getPackageName\\(\\),\\s*CONTEXT_CREDENTIAL_PROTECTED_STORAGE\\)")); - assertTrue("WorkManager re-normalizes the Context, so the adapter must keep the CE wrapper as the application Context", - matches(source, "public Context getApplicationContext\\(\\)\\s*\\{[^}]*return this;")); - assertTrue("A normal credential-protected device must keep its existing WorkManager storage", - matches(source, "if \\(!app\\.isDeviceProtectedStorage\\(\\)\\) return app;")); - assertTrue("Only the credential-protected context may reach WorkManager", - matches(source, "WorkManager\\.initialize\\(\\s*workContext\\s*,")); - assertTrue("A device-protected start must defer instead of initializing", - matches(source, "!isUserUnlocked\\(app\\)")); - assertTrue("The unlock state must be re-checked after registering, because the broadcast is not replayed", - matches(source, "registerUnlockReceiver\\(app\\);\\s*if \\(!isUserUnlocked\\(app\\)\\) return;")); - } - - private static boolean matches(String source, String regex) { - return Pattern.compile(regex, Pattern.DOTALL).matcher(source).find(); - } - - @Test - public void allFollowingSchedulingAndCancellationUsesTheStorageGate() throws Exception { - String source = Files.readString(root().resolve("app/src/main/java/com/fongmi/android/tv/following/FollowingScheduler.java")); - assertFalse("A direct getter can reintroduce the crash after removing the default initializer", - source.contains("WorkManager.getInstance(")); - assertEquals(5, source.split("FollowingWorkManager.withManager\\(", -1).length - 1); - } - - private static Path root() { - Path path = Path.of("").toAbsolutePath(); - while (path != null && !Files.exists(path.resolve("app/src/main/AndroidManifest.xml"))) path = path.getParent(); - if (path == null) throw new AssertionError("repository root not found"); - return path; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/history/GlobalHistoryResumeSourceTest.java b/app/src/test/java/com/fongmi/android/tv/history/GlobalHistoryResumeSourceTest.java index 74b838f8a7..7af7322e99 100644 --- a/app/src/test/java/com/fongmi/android/tv/history/GlobalHistoryResumeSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/history/GlobalHistoryResumeSourceTest.java @@ -191,7 +191,7 @@ public class GlobalHistoryResumeSourceTest { assertTrue(resolver.contains("SiteApi.searchContent(site, history.getVodName(), false, \"1\")")); assertTrue(coordinator.contains("ProgressBar")); - assertTrue(coordinator.contains("R.style.Theme_WebHTV_Dialog")); + assertTrue(coordinator.contains("R.style.Theme_WebHTV_LightDialog")); assertTrue(coordinator.contains("setCancelable(false)")); assertTrue(coordinator.contains("dismiss(loading)")); assertFalse(coordinator.contains("Notify.show(R.string.history_source_searching)")); @@ -318,7 +318,7 @@ public class GlobalHistoryResumeSourceTest { int end = source.indexOf("private static History findPlaybackByTmdb", start); String method = source.substring(start, end); - assertTrue(method.contains("History own = findPlaybackCandidate(key, Collections.singletonList(history), flags, expectedSeason);")); + assertTrue(method.contains("return copyForPlaybackKey(history, key, flags, history);")); assertFalse(method.contains("if (isSeasonEligible(history, key, expectedSeason)) return history;")); } @@ -351,17 +351,8 @@ public class GlobalHistoryResumeSourceTest { int end = source.indexOf("private void enrichHistoryMeta", start); String method = source.substring(start, end); - assertTrue(method.contains("mHistory.setVodName(historyVodName(item))")); + assertTrue(method.contains("mHistory.setVodName(item.getName())")); assertTrue(method.contains("mHistory.setVodPic(getInitialArtwork(item))")); - // 推送片名的兜底写在 historyVodName 里,断言必须切到该方法体内:整文件搜索会命中 - // 别处同名字符串(含注释),helper 被删掉或改空也照样通过。 - int helper = source.indexOf("private String historyVodName(Vod item)"); - assertTrue("historyVodName must exist", helper > 0); - int helperEnd = source.indexOf("private History createHistory(Vod item)", helper); - assertTrue("historyVodName must be followed by createHistory", helperEnd > helper); - String body = source.substring(helper, helperEnd); - assertTrue(body.contains("PushParser.fromId(getId())")); - assertTrue(body.contains("TmdbMatchPolicy.shouldAutoMatchPushTitle(intentName)")); } } diff --git a/app/src/test/java/com/fongmi/android/tv/history/GlobalHistorySettingSourceTest.java b/app/src/test/java/com/fongmi/android/tv/history/GlobalHistorySettingSourceTest.java index cf4a403a3e..6fe208a832 100644 --- a/app/src/test/java/com/fongmi/android/tv/history/GlobalHistorySettingSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/history/GlobalHistorySettingSourceTest.java @@ -6,7 +6,6 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class GlobalHistorySettingSourceTest { @@ -18,20 +17,10 @@ public class GlobalHistorySettingSourceTest { assertTrue(source.contains("GLOBAL_HISTORY_OFF = 0")); assertTrue(source.contains("GLOBAL_HISTORY_AUTO = 1")); assertTrue(source.contains("GLOBAL_HISTORY_SEARCH = 2")); - assertTrue(source.contains("getAll().get(\"global_history_mode\")")); + assertTrue(source.contains("Prefers.getInt(\"global_history_mode\", GLOBAL_HISTORY_OFF)")); assertTrue(source.contains("Prefers.put(\"global_history_mode\"")); } - @Test - public void explicitGlobalHistoryOffIsPersistedDistinctly() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/setting/Setting.java"); - - assertTrue(source.contains("mode == GLOBAL_HISTORY_OFF ? -1 : mode")); - assertTrue(source.contains("value instanceof Boolean legacy")); - assertTrue(source.contains("value instanceof Number number")); - assertTrue(source.contains("return clampGlobalHistoryMode(number.intValue())")); - } - @Test public void bothPersonalSettingScreensExposeGlobalHistoryMode() throws Exception { String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java"); @@ -40,11 +29,6 @@ public class GlobalHistorySettingSourceTest { String leanbackLayout = read("app/src/leanback/res/layout/activity_setting_personal.xml"); assertTrue(mobile.contains("mBinding.globalHistory")); - assertTrue(mobile.contains("event.getActionMasked() == MotionEvent.ACTION_DOWN")); - assertFalse(mobile.contains("event.getActionMasked() == MotionEvent.ACTION_UP")); - assertTrue(mobile.contains("MotionEvent.ACTION_CANCEL) globalHistoryTouchStarted = false")); - assertTrue(mobile.contains("if (!globalHistoryTouchStarted) return")); - assertTrue(mobile.contains("globalHistoryTouchStarted = false")); assertTrue(mobile.contains("select_global_history_mode")); assertTrue(leanback.contains("mBinding.globalHistory")); assertTrue(leanback.contains("select_global_history_mode")); diff --git a/app/src/test/java/com/fongmi/android/tv/lab/LabClearLogSourceTest.java b/app/src/test/java/com/fongmi/android/tv/lab/LabClearLogSourceTest.java deleted file mode 100644 index 04ff1e50d9..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/lab/LabClearLogSourceTest.java +++ /dev/null @@ -1,243 +0,0 @@ -package com.fongmi.android.tv.lab; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * 输出页「清除」必须真的清干净,且不得丢新输出。 - * - *

三条不变量:清内存与截落盘文件落在同一个 {@code synchronized} 块里;清 UI 与既有 append 走同一条 - * 主线程队列;截断而非删除日志文件。这里只锁会导致回归的源码/资源形态;设备上的点按效果未做设备验证 - * (本任务的证据只有编译与单元测试)。 - * - *

凡涉及先后顺序的断言(清 UI 与清缓冲的投递顺序、{@code truncate} 的空判先于 {@code exists()}、 - * 写盘先于 flush、缓冲 append 先于回调、读者线程先于关闭线程)一律用位置比较;其余断言按花括号作用域 - * 判断「在某块之内」——文本先后会把搬到块外的调用判成通过。 - */ -public class LabClearLogSourceTest { - - private static final String ACTIVITY = "src/main/java/com/fongmi/android/tv/lab/LabOutputActivity.java"; - private static final String RUNNER = "src/main/java/com/fongmi/android/tv/lab/LabRunner.java"; - private static final String LAYOUT = "src/main/res/layout/activity_lab_output.xml"; - /** pump 与 clearLog 必须共用同一个缓冲来源,否则清除清的是没人写的那一个。 */ - private static final String BUFFER_LOOKUP = "LOGS.computeIfAbsent(key, k -> new StringBuilder())"; - - /** 清 UI 先入主线程队列,清缓冲/截文件去后台:反序会抹掉只存在于界面的行。 */ - @Test - public void clearLogPostsToTheMainThread() throws Exception { - String activity = stripComments(read(ACTIVITY)); - assertTrue("the clear button is the only entry point and must call clearLog()", - activity.contains("btnClear.setOnClickListener(v -> clearLog())")); - String body = methodBody(activity, "private void clearLog()"); - int post = body.indexOf("App.post("); - assertTrue("clearLog() must hand the clearing to App.post", post >= 0); - assertTrue("the UI clear must be queued before anything waits on the pump lock", - post < body.indexOf("new Thread(")); - assertTrue("the output text, not another view, must be cleared inside that posted runnable", - blockAfter(body, "App.post(").contains("mBinding.outputText.setText(\"\")")); - assertFalse("clearing the title would leave the log on screen", - blockAfter(body, "App.post(").contains("outputTitle.setText(")); - assertEquals("the buffer clear must run exactly once, on the background thread", - 1, countOf(body, "LabRunner.clearLog(")); - assertFalse("the shared app thread pool can be saturated by web work, so it must not gate a tap", - body.contains("Task.execute(")); - assertFalse("the click thread must only queue the UI clear, otherwise old lines flash before vanishing", - body.substring(0, post).contains("setText(\"\")")); - String background = body.substring(body.indexOf("new Thread(")); - assertTrue("the disk truncation must run off the main thread", - background.contains("LabRunner.clearLog(logKey)")); - assertTrue("the background clear must receive the key the button read, not an empty one", - body.contains("String logKey = key();")); - assertTrue("the truncation thread must actually be started, or the buffer and file are never cleared", - background.contains(".start()")); - assertFalse("the background work must not post the UI clear too, or it lands after the click", - background.contains("App.post(")); - assertFalse("backfilling would duplicate a line whose own append is already queued", - blockAfter(body, "App.post(").contains("getLog(")); - } - - /** 内存缓冲一律原地清零:换实例会让 pump 之后的输出写进没人再读的旧引用。 */ - @Test - public void clearLogKeepsTheSameBufferInstance() throws Exception { - String runner = stripComments(read(RUNNER)); - String body = methodBody(runner, "public static void clearLog(String key)"); - assertFalse("clearLog must not replace the buffer instance", body.contains("LOGS.remove(")); - assertTrue("setLength(0) keeps the reference pump already holds", body.contains("setLength(0)")); - assertTrue("clearLog must take the buffer from LOGS, not a fresh one", - body.contains(BUFFER_LOOKUP)); - String locked = blockAfter(body, "synchronized (sb)"); - assertTrue("the buffer must be cleared inside the pump lock", locked.contains("setLength(0)")); - assertTrue("the disk truncation must live in the same critical section", - locked.contains("truncate(logFileFor(key))")); - assertFalse("a get-then-branch would race pump over the startup window", - body.contains("LOGS.get(key)")); - } - - /** 截断而不是删除:删掉文件会让运行中的写者继续写进已 unlink 的 inode,日志永久消失。 */ - @Test - public void truncateKeepsTheFileInPlace() throws Exception { - String runner = stripComments(read(RUNNER)); - String body = methodBody(runner, "private static void truncate(File file)"); - assertFalse("truncate must not unlink the log file", body.contains(".delete()")); - assertFalse("unlinking through NIO escapes just as badly", - body.contains("Files.delete") || body.contains("deleteIfExists")); - assertTrue("reopening the file truncates it in place", - body.contains("new java.io.FileOutputStream(file)")); - assertFalse("an append flag would keep every byte and clear nothing", - body.contains("new java.io.FileOutputStream(file, true)")); - assertTrue("the stream must be closed even when the close itself throws", - body.contains("try (java.io.FileOutputStream")); - int guard = body.indexOf("file == null"); - int exists = body.indexOf("file.exists()"); - assertTrue("a null file must be rejected before exists() is called, or clearing an unresolvable key throws on the background clear thread", - guard >= 0 && exists > guard); - assertTrue("the null branch must return, or it only logs and still falls through to exists()", - blockAfter(body, "file == null").contains("return;")); - } - - /** 写盘与写内存同处一把锁:否则 clearLog 的窗口会丢行或让内存与落盘分叉。 */ - @Test - public void pumpWritesTheFileInsideTheBufferLock() throws Exception { - String body = methodBody(stripComments(read(RUNNER)), "private static void pump("); - assertTrue("pump must append to the buffer under its lock", body.contains("synchronized (log)")); - assertTrue("pump must lock the very buffer clearLog clears", body.contains(BUFFER_LOOKUP)); - String locked = blockAfter(body, "while ((line = reader.readLine()) != null)"); - assertTrue("the per-line file write must stay inside that lock", - blockAfter(locked, "synchronized (log)").contains("writer.write(line")); - assertTrue("the buffer append must share the same critical section", - blockAfter(locked, "synchronized (log)").contains("log.append(line)")); - String inLock = blockAfter(locked, "synchronized (log)"); - int write = inLock.indexOf("writer.write(line"); - int flush = inLock.indexOf("writer.flush()"); - assertTrue("every persisted line must be flushed inside the same critical section, after the write", - write >= 0 && flush > write); - } - - /** 投递 UI 也必须落在同一把锁里,否则清除前已读出的行会在清除之后才冒到界面与文件。 */ - @Test - public void pumpPostsOutputInsideTheBufferLock() throws Exception { - String body = methodBody(stripComments(read(RUNNER)), "private static void pump("); - String locked = blockAfter(body, "while ((line = reader.readLine()) != null)"); - String inLock = blockAfter(locked, "synchronized (log)"); - assertTrue("the callback must fire under the same lock as the buffer append", - inLock.contains("listener.onOutput(")); - assertTrue("the callback must fire after the buffer append, not before", - inLock.indexOf("log.append(line)") < inLock.indexOf("listener.onOutput(")); - } - - /** 流只能由读者线程关:waitFor 线程上的 close 会在 wrapper 先退出时掐断仍在写的 stdout。 */ - @Test - public void pumpClosesTheWriterOnTheReaderThread() throws Exception { - String body = methodBody(stripComments(read(RUNNER)), "private static void pump("); - int afterReader = body.indexOf("} finally {"); - assertTrue("the writer must be closed in the reader thread's finally", afterReader >= 0); - assertTrue("that close must run under the buffer lock", - blockAfter(body.substring(afterReader), "synchronized (log)").contains("writer.close()")); - assertEquals("the writer must be closed exactly once", 1, countOf(body, "writer.close()")); - int started = body.indexOf("out.start()"); - assertTrue("the reader thread must be started", started >= 0); - assertFalse("the waitFor watcher must not close the stream too", - body.substring(started).contains("writer.close()")); - int threadStart = body.indexOf("Thread out = new Thread("); - assertTrue("the reader thread must exist", threadStart >= 0); - assertFalse("the calling thread (main) must not touch the file", - body.substring(0, threadStart).contains("new java.io.FileWriter(")); - String open = blockAfter(body.substring(threadStart), "synchronized (log)"); - assertTrue("the stream must be opened on the reader thread", open.contains("new java.io.FileWriter(")); - assertTrue("the command header must be written under the same lock", - open.contains("writer.write(\"$ \"")); - assertTrue("the writer must keep appending: a reused pid can hit an existing log, and truncating on open would drop what an earlier run already persisted", - open.contains("new java.io.FileWriter(logFile, true)")); - } - - /** 退出/停止后 logFor 被摘掉,清除必须回落到本进程记下的文件,否则对磁盘静默失效。 */ - @Test - public void clearLogFallsBackToTheRememberedFile() throws Exception { - String runner = stripComments(read(RUNNER)); - assertTrue("pump must remember the file it writes, or a later clear cannot find it", - methodBody(runner, "private static void pump(").contains("LOG_FILES.put(key")); - String resolve = blockAfter(methodBody(runner, "private static File logFileFor(String key)"), ""); - assertTrue("clearLog must resolve the file through that fallback", resolve.contains("LOG_FILES.get(key)")); - assertTrue("reading also remembers the file, so a later clear still finds it", - resolve.contains("LOG_FILES.put(key")); - String body = methodBody(runner, "public static void clearLog(String key)"); - assertFalse("half the branches must not bypass the fallback", - body.contains("LabProcManager.logFor(key)")); - } - - /** 读也走同一把锁:本次新增了一个写者,无锁 toString() 会返回刚被清掉的快照。 */ - @Test - public void getLogReadsUnderTheBufferLock() throws Exception { - String body = methodBody(stripComments(read(RUNNER)), "public static String getLog(String key)"); - assertTrue("getLog must read the buffer under the pump lock", - blockAfter(body, "synchronized (sb)").contains("sb.toString()")); - assertTrue("the empty-buffer fallback must resolve the log file through logFileFor", - body.contains("File file = logFileFor(key)")); - assertTrue("the fallback must actually read that file, or a restart loses the log", - blockAfter(body, "if (file != null && file.exists())").contains("Files.readAllBytes(")); - } - - /** 清除是非破坏性操作,不能复用「停止按钮」的红色语义。 */ - @Test - public void clearButtonDoesNotBorrowTheStopShape() throws Exception { - String layout = stripComments(read(LAYOUT)); - int clear = layout.indexOf("@+id/btnClear"); - assertTrue("btnClear is missing", clear >= 0); - int next = layout.indexOf("android:id=", clear + "@+id/btnClear".length()); - assertTrue("the toolbar must have a control after btnClear", next > clear); - String block = layout.substring(clear, next); - assertFalse("the red stop shape means \"stop\", not \"clear\"", - block.contains("@drawable/shape_lab_stop_btn")); - assertTrue("the clear button must use the secondary button shape", - block.contains("@drawable/shape_lab_click_btn")); - } - - // ------------------------------------------------------------------ helpers - - /** 取出方法体,避免断言被同文件其它方法的文本意外满足。 */ - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue(signature + " is missing", start >= 0); - return blockAfter(source.substring(start), ""); - } - - /** 从 {@code marker} 之后的第一个 {@code {} 起截出配平的块,用于判断「在某作用域之内」。 */ - private static String blockAfter(String source, String marker) { - int start = source.indexOf(marker); - assertTrue(marker + " is missing", start >= 0); - int open = source.indexOf('{', start); - assertTrue(marker + " is not followed by a block", open >= 0); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return source.substring(open, i + 1); - } - throw new AssertionError(marker + " has no closing brace"); - } - - private static int countOf(String source, String needle) { - int count = 0; - for (int i = source.indexOf(needle); i >= 0; i = source.indexOf(needle, i + needle.length())) count++; - return count; - } - - private static String stripComments(String source) { - return source - .replaceAll("(?s)", "") - .replaceAll("(?sm)^\\s*/\\*.*?\\*/", "") - .replaceAll("(?m)^\\s*//.*$", ""); - } - - private static String read(String path) throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } -} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/lab/LabEnv7zProgressTest.java b/app/src/test/java/com/fongmi/android/tv/lab/LabEnv7zProgressTest.java deleted file mode 100644 index 25c4f0a529..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/lab/LabEnv7zProgressTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.fongmi.android.tv.lab; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; - -import static org.junit.Assert.assertEquals; - -/** - * 7zz 的 -bsp1 进度解析契约:只认行首的 1~3 位 "NN%",单调递增,且跨 read 边界不丢状态。 - */ -public final class LabEnv7zProgressTest { - - private static int feed(LabEnv.PercentScanner scanner, String text) { - byte[] buf = text.getBytes(StandardCharsets.UTF_8); - return scanner.accept(buf, buf.length); - } - - @Test - public void readsCarriageReturnRefreshedPercent() { - LabEnv.PercentScanner scanner = new LabEnv.PercentScanner(); - assertEquals(3, feed(scanner, "\r 3%")); - assertEquals(45, feed(scanner, "\r 45%")); - assertEquals(100, feed(scanner, "\r100%")); - } - - @Test - public void keepsStateAcrossBufferBoundary() { - LabEnv.PercentScanner scanner = new LabEnv.PercentScanner(); - assertEquals(-1, feed(scanner, "\r 1")); - assertEquals(12, feed(scanner, "2%")); - } - - @Test - public void ignoresDigitsInsideFileNames() { - LabEnv.PercentScanner scanner = new LabEnv.PercentScanner(); - assertEquals(-1, feed(scanner, "Extracting node-v20.1.0-linux-arm64/50%\r")); - } - - @Test - public void rejectsPercentOutsideOneToThreeDigits() { - LabEnv.PercentScanner scanner = new LabEnv.PercentScanner(); - assertEquals(-1, feed(scanner, "\r1234%")); - assertEquals(-1, feed(scanner, "\r%")); - } - - @Test - public void rejectsValuesAboveHundred() { - LabEnv.PercentScanner scanner = new LabEnv.PercentScanner(); - assertEquals(-1, feed(scanner, "\r999%")); - } - - @Test - public void rejectsNonMonotonicProgress() { - LabEnv.PercentScanner scanner = new LabEnv.PercentScanner(); - assertEquals(60, feed(scanner, "\r 60%")); - assertEquals(-1, feed(scanner, "\r 20%")); - assertEquals(-1, feed(scanner, "\r 60%")); - assertEquals(61, feed(scanner, "\r 61%")); - } - - @Test - public void backspaceReturningToLineStartIsAccepted() { - LabEnv.PercentScanner scanner = new LabEnv.PercentScanner(); - assertEquals(7, feed(scanner, "\b7%")); - } - - @Test - public void tabIndentIsStillLineStart() { - LabEnv.PercentScanner scanner = new LabEnv.PercentScanner(); - assertEquals(9, feed(scanner, "\t9%")); - } - - @Test - public void nativeBytesLeavesHundredPercentForRealCompletion() { - // 7zz 的百分比是"当前文件"进度,同包下一个文件会回到 0%,所以封顶 99%, - // 100% 只由解压真正结束时的补发给出。 - assertEquals(0L, LabEnv.nativeBytes(0, 1000)); - assertEquals(500L, LabEnv.nativeBytes(50, 1000)); - assertEquals(990L, LabEnv.nativeBytes(99, 1000)); - assertEquals(990L, LabEnv.nativeBytes(100, 1000)); - } - - @Test - public void nativeBytesWithoutTotalReportsNothing() { - // total<=0(量不出总字节)时不上报字节数,UI 停在不确定进度而不是伪造进度。 - assertEquals(0L, LabEnv.nativeBytes(50, 0)); - assertEquals(0L, LabEnv.nativeBytes(50, -1)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/lab/LabEnvTest.java b/app/src/test/java/com/fongmi/android/tv/lab/LabEnvTest.java deleted file mode 100644 index 208c697a7f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/lab/LabEnvTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.fongmi.android.tv.lab; - -import org.junit.Test; - -import java.util.Arrays; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; - -public final class LabEnvTest { - - @Test - public void packagedAbiWinsOverDeviceAbiOrder() { - assertEquals( - "arm64-v8a", - LabEnv.resolveArch("arm64_v8a", new String[]{"armeabi-v7a", "arm64-v8a"})); - assertEquals( - "armeabi-v7a", - LabEnv.resolveArch("armeabi_v7a", new String[]{"arm64-v8a", "armeabi-v7a"})); - } - - @Test - public void fallsBackToSupportedArmAbiOnlyWhenPackagedAbiIsUnknown() { - assertEquals( - "armeabi-v7a", - LabEnv.resolveArch("", new String[]{"x86_64", "armeabi-v7a"})); - } - - @Test - public void unknownArchitectureDoesNotDefaultToArm64() { - assertEquals("", LabEnv.resolveArch("x86_64", new String[]{"x86_64", "x86"})); - assertEquals("", LabEnv.resolveArch("x86_64", new String[]{"arm64-v8a", "armeabi-v7a"})); - assertEquals("", LabEnv.resolveArch("unknown", new String[]{"arm64-v8a"})); - assertEquals("", LabEnv.resolveArch(null, null)); - } - - @Test - public void downloadMustMatchSelectedAbi() { - LabModels.Item item = new LabModels.Item(); - LabModels.Download arm64 = new LabModels.Download(); - arm64.arch = "arm64-v8a"; - LabModels.Download armv7 = new LabModels.Download(); - armv7.arch = "armeabi-v7a"; - item.downloads = Arrays.asList(arm64, armv7); - - assertSame(armv7, LabEnv.findDownload(item, "armeabi_v7a")); - assertSame(arm64, LabEnv.findDownload(item, "arm64-v8a")); - assertNull(LabEnv.findDownload(item, "x86_64")); - } - - @Test - public void missingAbiDoesNotFallBackToFirstDownload() { - LabModels.Item item = new LabModels.Item(); - LabModels.Download arm64 = new LabModels.Download(); - arm64.arch = "arm64-v8a"; - item.downloads = Arrays.asList(arm64); - - assertNull(LabEnv.findDownload(item, "armeabi-v7a")); - } - - @Test - public void selectedDownloadUsesNormalizedAbiForVersion() { - LabModels.Item item = new LabModels.Item(); - item.version = "fallback"; - LabModels.Download arm64 = new LabModels.Download(); - String selectedArch = LabEnv.arch(); - assertTrue(selectedArch.equals("arm64-v8a") || selectedArch.equals("armeabi-v7a")); - arm64.arch = selectedArch.replace('-', '_'); - arm64.version = "3.12"; - item.downloads = Arrays.asList(arm64); - - assertSame(arm64, LabEnv.findDownload(item, selectedArch)); - assertEquals("3.12", LabEnv.displayVersion(item)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/lab/LabRunnerProcessCompatTest.java b/app/src/test/java/com/fongmi/android/tv/lab/LabRunnerProcessCompatTest.java deleted file mode 100644 index c51f4ad02e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/lab/LabRunnerProcessCompatTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.fongmi.android.tv.lab; - -import org.junit.Test; - -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Android 7 (API 24/25) contract for the Lab runtime. - * - *

{@code Process#isAlive()} is API 26, so the original call sites threw {@code NoSuchMethodError} - * as soon as a Lab entry with commands was installed. Because that failure is an {@code Error}, - * {@code LabAutoStart}'s {@code catch (Exception)} could not stop it and the process died. The - * replacement probes {@code exitValue()} (API 1) and must keep doing so. - */ -public class LabRunnerProcessCompatTest { - - @Test - public void probeTreatsOnlyATerminatedProcessAsDead() { - assertTrue("a still running process must be reported alive", LabRunner.isAlive(fake(false))); - assertFalse("a terminated process must be reported dead", LabRunner.isAlive(fake(true))); - assertFalse("a missing process must be reported dead", LabRunner.isAlive(null)); - } - - @Test - public void labSourcesProbeThroughExitValueInsteadOfTheApi26Call() throws Exception { - String runner = stripComments(read("app/src/main/java/com/fongmi/android/tv/lab/LabRunner.java")); - String terminal = stripComments(read("app/src/main/java/com/fongmi/android/tv/lab/LabTerminalActivity.java")); - - assertTrue("the helper must probe the API 1 exitValue() contract", - runner.contains("process.exitValue()")); - assertFalse("java.lang.Process#isAlive() is API 26 and must not be called in LabRunner", - runner.contains(".isAlive()")); - assertFalse("java.lang.Process#isAlive() is API 26 and must not be called in LabTerminalActivity", - terminal.contains(".isAlive()")); - assertTrue("LabTerminalActivity must route every liveness check through the compatible helper", - terminal.contains("LabRunner.isAlive(")); - } - - private static Process fake(boolean terminated) { - return new Process() { - @Override - public OutputStream getOutputStream() { - return OutputStream.nullOutputStream(); - } - - @Override - public InputStream getInputStream() { - return InputStream.nullInputStream(); - } - - @Override - public InputStream getErrorStream() { - return InputStream.nullInputStream(); - } - - @Override - public int waitFor() { - return 0; - } - - @Override - public int exitValue() { - if (terminated) return 0; - throw new IllegalThreadStateException("still running"); - } - - @Override - public void destroy() { - } - }; - } - - private static String stripComments(String source) { - return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//[^\n]*", " "); - } - - private static String read(String name) throws Exception { - Path path = Path.of(name); - if (!Files.exists(path)) path = Path.of("..").resolve(name).normalize(); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/lab/LabSettingsDialogThemeTest.java b/app/src/test/java/com/fongmi/android/tv/lab/LabSettingsDialogThemeTest.java deleted file mode 100644 index 804b15e28b..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/lab/LabSettingsDialogThemeTest.java +++ /dev/null @@ -1,268 +0,0 @@ -package com.fongmi.android.tv.lab; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * 实验室「配置源」设置弹窗必须内容与面板同源,并且必须跟随应用主题。 - * - *

缺陷实测(dev1 {@code 192.168.50.3:5555},mobile arm64 debug,浅色系统): - * {@code LabActivity.showSettings()} 用 Activity 的 LayoutInflater inflate - * {@code dialog_lab_settings.xml},其中的 {@code ?attr/colorOnSurface} 由固定深色的 - * {@code Theme.App.Lab}({@code colorOnSurface = #FFFFFFFF})解析成白字, - * 而弹窗面板来自 {@code ThemeOverlay.WebHTV.Dialog} 的日/夜双表(浅色 {@code #E7E8EF})。 - * 结果正文与面板对比度只有 1.05:1,默认主题下整页看不清。 - * - *

同时,这些固定白字/固定 {@code @color/accent} 都不等于冻结基线的语义角色值, - * {@link com.fongmi.android.tv.theme.ThemeBinder} 的精确匹配改写无法命中, - * 于是「主题色彩」对这个设置页完全没有作用。 - * - *

本测试锁定两条不变量:内容用弹窗主题上下文 inflate;弹窗主题的强调色是语义 token。 - * 面板/正文的对比度由设备像素实测覆盖(见任务文档),这里只锁住会导致回归的源码形态。 - */ -public class LabSettingsDialogThemeTest { - - private static final String ACTIVITY = "src/main/java/com/fongmi/android/tv/lab/LabActivity.java"; - private static final String STYLES = "src/main/res/values/lab_styles.xml"; - private static final String LAYOUT = "src/main/res/layout/dialog_lab_settings.xml"; - - /** {@code ?attr/} 颜色属性 → 该属性在语义 token 表中的角色资源。 */ - private static final Map ATTR_TO_TOKEN = new LinkedHashMap<>(); - - static { - ATTR_TO_TOKEN.put("colorOnSurface", "webhtv_color_on_surface"); - ATTR_TO_TOKEN.put("colorOnSurfaceVariant", "webhtv_color_on_surface_variant"); - ATTR_TO_TOKEN.put("colorPrimary", "webhtv_color_primary"); - } - - /** - * 弹窗内容必须用弹窗主题上下文 inflate。 - * - *

用 {@code getLayoutInflater()} 会落到 Activity 主题:{@code Theme.App.Lab} 是固定深色表, - * 正文永远是白字,而面板跟着日/夜表走 —— 这正是「默认主题下就看不清」的成因。 - */ - @Test - public void settingsContentIsInflatedWithTheDialogTheme() throws Exception { - String source = stripComments(read(ACTIVITY)); - assertTrue("the settings dialog must build a dialog-themed context", - source.contains("new ContextThemeWrapper(this, R.style.Theme_App_Lab_DayNight_Dialog)")); - assertTrue("the settings layout must be inflated from the dialog theme, not the activity", - source.contains("LayoutInflater.from(dialogContext).inflate(R.layout.dialog_lab_settings, null)")); - assertFalse("the activity inflater would re-resolve the layout against Theme.App.Lab", - source.contains("getLayoutInflater().inflate(R.layout.dialog_lab_settings")); - } - - /** - * 弹窗主题的强调色必须是语义 token。 - * - *

{@code @color/accent} 固定为 {@code #2196F3}:它既不能跟随深浅色, - * 也不满足 binder 的精确匹配改写,因此主题色永远作用不到这个弹窗的输入框描边、hint 与图标。 - */ - @Test - public void dialogThemeAccentIsASemanticToken() throws Exception { - String style = styleBody("Theme.App.Lab.DayNight.Dialog"); - assertFalse("the dialog accent must not be the fixed lab blue", - style.contains("@color/accent")); - assertTrue("the dialog accent must resolve the semantic primary token", - style.contains("@color/webhtv_color_primary")); - assertTrue("the activated control colour must be the semantic primary", - style.contains("@color/webhtv_color_primary")); - } - - /** - * 弹窗面板必须与内容读同一张表。 - * - *

面板由 {@code colorSurfaceContainerHigh}(M3 对话框容器色)决定,所以该槽必须也被 - * 映射到 {@code lab_surface},否则内容跟随 {@code lab_surface} 而面板回落到 Material 基线表, - * 又变成两套颜色。 - */ - @Test - public void dialogPanelAndContentShareOneTable() throws Exception { - String style = styleBody("Theme.App.Lab.DayNight.Dialog"); - assertTrue(style.contains("@color/lab_surface")); - assertTrue(style.contains("@color/lab_surface")); - assertTrue(style.contains("@color/lab_text_primary")); - assertTrue(style.contains("@color/lab_text_secondary")); - - String colors = read("src/main/res/values/lab_colors.xml"); - assertTrue("lab_surface must alias the semantic container role", - colors.contains("@color/webhtv_color_surface_container_high")); - assertTrue("lab_text_primary must alias the semantic foreground role", - colors.contains("@color/webhtv_color_on_surface")); - assertTrue("lab_text_secondary must alias the semantic variant role", - colors.contains("@color/webhtv_color_on_surface_variant")); - } - - /** - * 布局里的颜色属性必须都是语义角色。 - * - *

设备实测的对比度只有在「内容属性全部解析成语义 token」时成立:任何一条落回固定字面量 - * 都会重新引入一个只能在一张表里正确的颜色。 - */ - @Test - public void settingsLayoutOnlyUsesSemanticColorAttributes() throws Exception { - String layout = stripComments(read(LAYOUT)); - List unknown = new ArrayList<>(); - Matcher matcher = Pattern.compile("(?:android:)?(?:textColor|textColorHint|boxStrokeColor|hintTextColor|tint)=\"([^\"]+)\"") - .matcher(layout); - while (matcher.find()) { - String value = matcher.group(1); - if (value.startsWith("?attr/color")) { - String attr = value.substring("?attr/".length()); - if (!ATTR_TO_TOKEN.containsKey(attr)) unknown.add(value); - continue; - } - if (value.startsWith("?android:attr/")) continue; - unknown.add(value); - } - assertTrue("dialog_lab_settings.xml pins a colour outside the semantic roles: " + unknown, - unknown.isEmpty()); - } - - /** - * 语义角色必须在深浅两张表里都达到正文对比度门槛。 - * - *

这是对「面板与内容同源」这一修复的直接量化:只要两张表各自满足 4.5:1, - * 弹窗在跟随系统浅色或强制深色时都保持可读。 - */ - @Test - public void bothTablesClearTheBodyTextContrastFloor() throws Exception { - for (String file : new String[]{"src/main/res/values/webhtv_tokens.xml", - "src/main/res/values-night/webhtv_tokens.xml"}) { - Map colors = colorsOf(read(file)); - String surface = colors.get("webhtv_color_surface_container_high"); - for (String foreground : new String[]{"webhtv_color_on_surface", "webhtv_color_on_surface_variant"}) { - double ratio = contrast(colors.get(foreground), surface); - assertTrue(file + ": " + foreground + " on " + surface + " is only " - + String.format(Locale.ROOT, "%.2f:1", ratio), - ratio >= 4.5); - } - } - } - - // ------------------------------------------------------------------ helpers - - private static String styleBody(String name) throws Exception { - String styles = stripComments(read(STYLES)); - Matcher matcher = Pattern.compile("", Pattern.DOTALL) - .matcher(styles); - assertTrue(name + " is missing from lab_styles.xml", matcher.find()); - return matcher.group(1); - } - - private static Map colorsOf(String source) { - Map colors = new LinkedHashMap<>(); - Matcher matcher = Pattern.compile("([^<]+)").matcher(stripComments(source)); - while (matcher.find()) colors.put(matcher.group(1), matcher.group(2).trim()); - return colors; - } - - private static double contrast(String first, String second) { - double a = luminance(first); - double b = luminance(second); - return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); - } - - private static double luminance(String hex) { - String digits = hex.startsWith("#") ? hex.substring(1) : hex; - if (digits.length() == 8) digits = digits.substring(2); - double[] channels = new double[3]; - for (int index = 0; index < 3; index++) { - double value = Integer.parseInt(digits.substring(index * 2, index * 2 + 2), 16) / 255.0; - channels[index] = value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); - } - return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; - } - - /** - * Strips comments for the source-shape assertions. - * - *

Block comments are matched only at the start of a line: this file's production code - * contains {@code setType} with a star-slash-star pattern, whose block-comment opener would - * otherwise swallow the very method under test. - */ - private static String stripComments(String source) { - return source - .replaceAll("(?s)", "") - .replaceAll("(?sm)^\\s*/\\*.*?\\*/", "") - .replaceAll("(?m)^\\s*//.*$", ""); - } - - private static String read(String path) throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } - - /** - * 下拉菜单必须由 Material 的 AutoCompleteTextView 提供。 - * - *

{@code android.widget.AutoCompleteTextView} 不应用 - * {@code Widget.Material3.AutoCompleteTextView.OutlinedBox},因此既没有 - * {@code dropDownBackgroundTint}(= colorSurfaceContainer),也不会把 item 文字交给主题, - * 弹出列表落到 {@code @android:layout/simple_dropdown_item_1line} 的 Material 基线字色。 - * 设备实测(浅色系统、默认主题):弹出面板 {@code #F3EDF7},item 文字近白,1.76:1。 - */ - @Test - public void dropdownUsesTheThemedMaterialAutoComplete() throws Exception { - String layout = stripComments(read(LAYOUT)); - assertTrue("the dropdown must be the Material control so its popup follows the palette", - layout.contains("com.google.android.material.textfield.MaterialAutoCompleteTextView")); - assertFalse("a plain android.widget.AutoCompleteTextView would drop the themed popup", - layout.contains("{@code ArrayAdapter} 用自身 context 解析 item 布局。此前用的是 - * {@code android.R.layout.simple_dropdown_item_1line} + Activity 上下文 - * ({@code Theme.App.Lab},固定深色 → 白字),而弹出面板来自日/夜双表: - * 浅色系统下实测弹出面板 {@code #F3EDF7}、item 文字近白,1.19:1。 - */ - @Test - public void dropdownItemsFollowTheDialogPalette() throws Exception { - String activity = stripComments(read(ACTIVITY)); - assertTrue("the adapter must resolve its item layout against the dialog theme", - activity.contains("new ArrayAdapter<>(dialogContext, R.layout.item_lab_dropdown, items)")); - assertFalse("the framework item layout keeps Material's baseline text colour", - activity.contains("android.R.layout.simple_dropdown_item_1line")); - - String item = stripComments(read("src/main/res/layout/item_lab_dropdown.xml")); - assertTrue("the dropdown item must take its foreground from the semantic role", - item.contains("android:textColor=\"?attr/colorOnSurface\"")); - assertTrue("the dropdown item must expose the framework list item id", - item.contains("@android:id/text1")); - } - - /** - * 角色表必须覆盖布局真正用到的每一条颜色属性。 - * - *

上一条测试只对表内已知角色放行;这条把「表是否漏项」也锁住, - * 否则新增一条 {@code ?attr/colorX} 会被静默当成未知项而失败信息含糊。 - */ - @Test - public void attributeTableCoversEveryColourAttributeUsedByTheLayout() throws Exception { - String layout = stripComments(read(LAYOUT)); - Matcher matcher = Pattern.compile("\\?attr/(color[A-Za-z]+)").matcher(layout); - int seen = 0; - while (matcher.find()) { - seen++; - assertTrue("no expected token mapped for " + matcher.group(1), - ATTR_TO_TOKEN.containsKey(matcher.group(1))); - } - assertTrue("the layout must actually use semantic colour attributes", seen >= 5); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/lab/LabTvFocusSourceTest.java b/app/src/test/java/com/fongmi/android/tv/lab/LabTvFocusSourceTest.java deleted file mode 100644 index 35a565f1c0..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/lab/LabTvFocusSourceTest.java +++ /dev/null @@ -1,346 +0,0 @@ -package com.fongmi.android.tv.lab; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * 实验室详情页必须能用遥控器操作焦点。 - * - *

缺陷实测(leanback arm64 debug,{@code 192.168.50.3:5561},{@code mInTouchMode=false}): - * {@code LabDetailActivity} 的 2 秒轮询调用 {@code updateButtons()} → {@code commandAdapter - * .notifyDataSetChanged()},RecyclerView 回收并重新挂载全部子 View,焦点所在的「运行」按钮被移除, - * 框架把焦点丢回工具栏返回键。实测按下方向键聚焦命令行后 1.2 秒内焦点即回到工具栏, - * 遥控器按不到「运行」——用户只能靠 lab 配置里的 {@code auto_execute} 启动命令。 - * - *

本测试锁住两条不变量:命令列表只做状态差异刷新;实验室自己的可聚焦控件有聚焦态。 - * 设备上的焦点保持由任务文档记录的遥控实测覆盖,这里只锁会导致回归的源码/资源形态。 - */ -public class LabTvFocusSourceTest { - - private static final String ACTIVITY = "src/main/java/com/fongmi/android/tv/lab/LabDetailActivity.java"; - private static final String ADAPTER = "src/main/java/com/fongmi/android/tv/lab/LabCommandAdapter.java"; - private static final String DETAIL_LAYOUT = "src/main/res/layout/activity_lab_detail.xml"; - - /** 实验室自己绘制的可聚焦控件:缺 state_focused 就等于遥控看不到焦点。 */ - private static final String[] FOCUS_VISIBLE_DRAWABLES = { - "src/main/res/drawable/shape_lab_card.xml", - "src/main/res/drawable/shape_lab_run_btn.xml", - "src/main/res/drawable/shape_lab_stop_btn.xml", - "src/main/res/drawable/shape_lab_click_btn.xml", - "src/main/res/drawable/selector_lab_group_tab.xml", - "src/main/res/drawable/selector_lab_icon_btn.xml" - }; - - @Test - public void commandEditorBuildsTheFocusChain() throws Exception { - String editor = stripComments(read("src/main/java/com/fongmi/android/tv/lab/LabCommandEditDialog.java")); - String show = methodBody(editor, "dialog.setOnShowListener"); - assertTrue("the dialog must build an explicit focus chain", show.contains("collectFocusChain(root, chain);")); - assertTrue("dialog buttons are the tail of that chain", - show.contains("addToChain(chain, dialog.getButton(AlertDialog.BUTTON_POSITIVE));")); - assertTrue("the chain must take over the vertical keys", show.contains("releaseVerticalKeys(chain);")); - assertTrue("the first chain entry owns the initial focus", show.contains("chain.get(0).requestFocus();")); - // 「新建命令」不会 setNeutralButton,getButton 返回 null;直接 add 会让 null 占一个链槽, - // 上下键停在那里被静默吞掉,取消/添加按钮按不到。 - assertFalse("a missing dialog button must not occupy a chain slot", - show.contains("chain.add(dialog.getButton(")); - } - - @Test - public void commandEditorReleasesVerticalDpadKeys() throws Exception { - String editor = stripComments(read("src/main/java/com/fongmi/android/tv/lab/LabCommandEditDialog.java")); - String navigate = methodBody(editor, "private static void releaseVerticalKeys"); - assertTrue("DOWN must move the chain forward", navigate.contains("KeyEvent.KEYCODE_DPAD_DOWN")); - assertTrue("UP must move the chain backward", navigate.contains("KeyEvent.KEYCODE_DPAD_UP")); - assertTrue("the chain index decides the next target", navigate.contains("requestFocus();")); - assertTrue("autocomplete popup must keep native arrow selection", navigate.contains("isPopupShowing()")); - // 触摸模式下文本控件同样吃掉上下键,把触摸模式当跳过条件会让焦点在弹窗里出不去。 - assertFalse("touch mode must not disable the vertical-key escape", navigate.contains("isInTouchMode()")); - // 链式索引移动取代几何搜索:focusSearch 在可滚动弹窗里取决于当前滚动位置。 - assertFalse("geometric focus search must not drive the editor chain", navigate.contains("focusSearch(")); - - String collect = methodBody(editor, "private static void collectFocusChain"); - assertTrue("an unfocusable field must not occupy a chain slot", collect.contains("view.isFocusable()")); - assertTrue("a hidden row must not occupy a chain slot", collect.contains("getVisibility() != View.VISIBLE")); - // 参数行是 TextInputEditText / MaterialAutoCompleteTextView / MaterialSwitch, - // 按具体类型白名单收集会漏掉其中几类。 - assertFalse("the chain must not filter by concrete control type", collect.contains("instanceof EditText")); - assertFalse("a switch row must stay reachable through the chain", collect.contains("instanceof MaterialSwitch")); - } - - /** - * 编辑弹窗的焦点顺序只由 {@code LabCommandEditDialog} 的焦点链决定。 - * - *

布局上的 nextFocus* 会被链里的上下键拦截完全覆盖,两套机制并存时改错一处也不会有测试察觉。 - */ - @Test - public void commandEditorDoesNotDeclareNextFocus() throws Exception { - String layout = stripComments(read("src/main/res/layout/dialog_add_command.xml")); - assertTrue("the editor layout must still be the one under review", - layout.contains("android:id=\"@+id/inputCommand\"")); - assertFalse("the editor focus order is owned by the chain, not by nextFocus*", - layout.contains("android:nextFocus")); - } - - /** - * 周期刷新不能再整表重建列表。 - * - *

{@code updateButtons()} 由 2 秒轮询驱动,任何整表刷新都会周期性抢走遥控焦点。 - */ - @Test - public void periodicRefreshUsesDiffUpdatesOnly() throws Exception { - String activity = stripComments(read(ACTIVITY)); - assertTrue("updateButtons() must refresh the command list through the diff path", - activity.contains("commandAdapter.refreshStates();")); - assertFalse("a full notifyDataSetChanged() on the 2s poll steals the remote focus", - activity.contains("commandAdapter.notifyDataSetChanged()")); - } - - /** - * 差异刷新必须按行状态判断,并且只做局部通知。 - * - *

{@code notifyItemChanged} 复用同一个 ViewHolder;整表刷新会回收焦点所在的视图。 - */ - @Test - public void diffRefreshSkipsUnchangedRowsAndNotifiesSingleItems() throws Exception { - String refresh = methodBody(stripComments(read(ADAPTER)), "public void refreshStates()"); - assertTrue("refreshStates() must compare per-row state before notifying", - refresh.contains("stateOf(rows.get(i))")); - assertTrue("unchanged rows must be skipped entirely", - refresh.contains("continue;")); - assertTrue("changed rows are refreshed in place", - refresh.contains("notifyItemChanged(i)")); - assertFalse("refreshStates() must never rebuild the whole list", - refresh.contains("notifyDataSetChanged")); - } - - /** 行状态签名必须覆盖 onBindViewHolder 会改变的全部可视差异。 */ - @Test - public void rowStateSignatureCoversRunningAndLabel() throws Exception { - String state = methodBody(stripComments(read(ADAPTER)), "private String stateOf("); - assertTrue("the running state drives the dot, tag and button", - state.contains("LabRunner.isRunning(")); - assertTrue("the label is the only other visible difference", - state.contains("command.name == null ? command.description : command.name")); - } - - /** - * 实验室自绘的可聚焦控件必须都有聚焦态。 - * - *

设备实测:命令行的「运行」按钮聚焦前后像素完全一致(仅有按下态), - * 遥控用户看不到焦点落在哪一行/哪个按钮上。 - */ - @Test - public void labFocusableControlsExposeAFocusedState() throws Exception { - for (String file : FOCUS_VISIBLE_DRAWABLES) { - assertTrue(file + " must render a focused state for remote navigation", - stripComments(read(file)).contains("android:state_focused=\"true\"")); - } - } - - /** - * 命令弹窗的初始焦点必须落在按钮上。 - * - *

BottomSheetDialog 默认聚焦第一个可聚焦控件(命令预览 EditText)。实测遥控方向键在单行输入框里 - * 出不去(DOWN / RIGHT / UP 都不移动焦点,只有 TAB 能到按钮),电视遥控器没有 TAB, - * 用户打开了弹窗却按不到「运行」。 - */ - @Test - public void commandSheetAnchorsFocusOnAButton() throws Exception { - String sheet = stripComments(read("src/main/java/com/fongmi/android/tv/lab/LabCommandSheet.java")); - assertTrue("the sheet must move the initial focus onto a button", - sheet.contains("requestSheetFocus();")); - assertTrue("the run button wins when it is usable, otherwise the edit button", - sheet.contains("sheetBtnRun.getVisibility() == View.VISIBLE && sheetBtnRun.isEnabled() ? sheetBtnRun : sheetBtnEdit")); - - // 锚点必须是轮询本体(mStateRunnable 匿名类),不能用 "public void run()": - // 那个签名在本文件里第一处命中的是轮询的 run()(约 60 行),第二处才是 - // LabCommandSheet 自己的 run()(约 319 行),靠 indexOf 的先后顺序太脆。 - String poll = methodBody(sheet, "private final Runnable mStateRunnable"); - assertFalse("the 1s state poll must not steal focus back while the user types", - poll.contains("requestSheetFocus")); - } - - /** - * 「导入包」弹窗必须能用遥控器操作。 - * - *

实测(leanback arm64 debug,1920x1080):弹窗可聚焦节点只有粘贴框、`选择离线包文件`、取消、导入; - * 初始焦点落在多行粘贴框,遥控 DOWN / UP / RIGHT 都不移动焦点(被当光标键吃掉),只有 TAB 能跳出去。 - * 电视遥控器没有 TAB,用户打开弹窗后什么都按不动。 - */ - @Test - public void importDialogIsReachableWithTheRemote() throws Exception { - String activity = stripComments(read("src/main/java/com/fongmi/android/tv/lab/LabActivity.java")); - assertTrue("the paste field must release the vertical D-pad keys to focus search", - activity.contains("class LabPasteEditText extends AppCompatEditText")); - assertTrue("only the vertical keys are released, horizontal cursor movement stays", - activity.contains("keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN")); - assertTrue("the file-picker link must be focusable", - activity.contains("select.setFocusable(true);")); - assertTrue("the link must render a focused state", - activity.contains("select.setBackgroundResource(R.drawable.selector_lab_link);")); - assertTrue("the dialog must anchor the initial focus on the file-picker link", - activity.contains("select.requestFocus();")); - assertTrue("the link must hand UP back to the paste field (geometry alone does not work)", - activity.contains("select.setNextFocusUpId(edit.getId());")); - assertTrue("the link drawable must define a focused state", - stripComments(read("src/main/res/drawable/selector_lab_link.xml")) - .contains("android:state_focused=\"true\"")); - // 环色必须与链接文字色同源(LabActivity 用 R.color.webhtv_color_primary)。用固定的 - // @color/accent 会在浅色弹窗面板 #E7E8EF 上掉到 2.56:1,低于仓库既有的「焦点环 ≥3:1」门。 - String link = stripComments(read("src/main/res/drawable/selector_lab_link.xml")); - assertTrue("the link focus ring must use the theme token (5.23:1 light / 7.86:1 night)", - link.contains("@color/webhtv_color_primary")); - assertFalse("the fixed lab accent is only 2.56:1 on the light dialog panel", - link.contains("@color/accent")); - } - - /** - * 「实验室配置源」弹窗必须能用遥控器操作。 - * - *

实测(leanback arm64 debug,1920x1080):打开后焦点落在「配置源」下拉上,遥控 DOWN / UP 全都不移动 - * (被 AutoCompleteTextView / 可编辑文本当光标键吃掉),到不了文件夹图标、URL、根目录、开关和取消/确定; - * 下拉展开后 DOWN 能移动高亮,但按 OK 只关闭列表、不回调选中项(点选同一行才生效)。 - */ - @Test - public void settingsDialogReleasesVerticalKeysAndAppliesTheDropdownPick() throws Exception { - String activity = stripComments(read("src/main/java/com/fongmi/android/tv/lab/LabActivity.java")); - assertTrue("the settings dialog must install the vertical-key escape", - activity.contains("escapeVerticalKeys(cycle, dropdown, input, rootInput, proxyPort, proxyNoProxy);")); - String escape = methodBody(activity, "private void escapeVerticalKeys("); - assertTrue("vertical keys move focus", - escape.contains("view.focusSearch(")); - assertTrue("the open dropdown list is dismissed before moving focus", - escape.contains("dropdown.dismissDropDown();")); - assertTrue("the confirm key cycles the dropdown instead of using the unusable popup", - escape.contains("cycle.run();")); - assertTrue("the cycle advances to the next option", - activity.contains("+ 1) % items.length")); - assertTrue("touch picking and the remote cycle share one code path", - activity.contains("dropdown.setOnItemClickListener((parent, view, position, id) -> pick.accept(position));")); - } - - /** - * 配置源下拉必须能直接 DOWN 到「选本地配置」的文件夹按钮,且两种配置源模式都要走对。 - * - *

实测(leanback arm64 debug,1920x1080):焦点搜索按加权距离比较,文件夹在右侧、 - * 根目录在正下方,于是 DOWN 直接从下拉跳到根目录,跳过了用户想点的文件按钮。 - * - *

{@code nextFocus*} 会顺着「目标控件自己的连线」继续走、并跳过不可见目标, - * 所以把链条写成 下拉 → 文件夹 → URL 输入框 → 根目录:本地配置模式下 URL 输入框是 GONE, - * 链条自然落到根目录;网络 URL 模式下文件夹是 GONE,链条落到 URL 输入框。 - */ - @Test - public void settingsDialogWiresTheDropdownToTheFileButton() throws Exception { - String layout = stripComments(read("src/main/res/layout/dialog_lab_settings.xml")); - assertTrue("the dropdown must hand focus down to the file button", - layout.contains("android:nextFocusDown=\"@+id/folder\"")); - assertTrue("the file button must lead back up to the dropdown", - layout.contains("android:nextFocusUp=\"@+id/sourceDropdown\"")); - assertTrue("the file button must chain through the URL field", - layout.contains("android:nextFocusDown=\"@+id/input\"")); - assertTrue("the URL field must chain down to the root input", - layout.contains("android:nextFocusDown=\"@+id/rootInput\"")); - } - - /** - * 配置源弹窗的取消/确定必须按「就近」回到上方控件,不能硬钉到某个固定控件。 - * - *

实测(leanback arm64 debug,1920x1080):两个按钮在 ScrollView 之外,焦点搜索按绝对坐标比较, - * 内容滚到底时按 UP 落到「不走代理的域名」——这正是用户视觉上方的控件,属于正确行为。 - * 曾一度把它硬钉到「根目录」,反而与所见不符(用户已指出),故锁住「不得钉死」。 - */ - @Test - public void settingsDialogButtonsKeepGeometricUpFocus() throws Exception { - String activity = stripComments(read("src/main/java/com/fongmi/android/tv/lab/LabActivity.java")); - assertFalse("no button-focus pinning helper should remain", - activity.contains("wireButtonFocus(")); - assertFalse("the dialog buttons must not be pinned to a fixed target", - activity.contains("getButton(AlertDialog.BUTTON_NEGATIVE).setNextFocusUpId") - || activity.contains("getButton(AlertDialog.BUTTON_POSITIVE).setNextFocusUpId")); - } - - /** 分组标签的聚焦态必须由 selector 通过 state_focused 接上(聚焦图形本身是纯 shape)。 */ - @Test - public void groupTabSelectorWiresTheFocusedShape() throws Exception { - String selector = stripComments(read("src/main/res/drawable/selector_lab_group_tab.xml")); - assertTrue("the focused branch must come first so it wins over activated", - selector.indexOf("state_focused") < selector.indexOf("state_activated")); - assertTrue("the focused branch must point at the focused shape", - selector.contains("android:drawable=\"@drawable/shape_lab_group_tab_focused\"")); - assertTrue("the focused shape must keep the tab padding so the label does not jump", - read("src/main/res/drawable/shape_lab_group_tab_focused.xml").contains("android:left=\"12dp\"")); - } - - /** - * 详情页图标按钮必须显式声明焦点邻居。 - * - *

实测(leanback arm64 debug,小 UI 缩放):从「下载安装」按 DOWN 会直接跳到第一条命令, - * 跳过终端/新增/刷新三个图标 —— 焦点搜索按加权距离(13×主轴² + 次轴²)比较, - * 图标在右侧且行高只有 32px 时,正下方的命令行更「近」。用 nextFocus* 固定顺序即可, - * 目标控件不可见时框架会自动回退到几何搜索。 - */ - @Test - public void detailHeaderIconsDeclareFocusNeighbours() throws Exception { - String layout = stripComments(read(DETAIL_LAYOUT)); - assertTrue("the download button must hand focus down to the icon row", - layout.contains("android:nextFocusDown=\"@+id/btnTerminal\"")); - assertTrue("the terminal icon must lead back up to the download button", - layout.contains("android:nextFocusUp=\"@+id/btnDownload\"")); - assertTrue("the three icons must chain left to right", - layout.contains("android:nextFocusRight=\"@+id/btnAddCommand\"") - && layout.contains("android:nextFocusRight=\"@+id/btnRefreshCommand\"") - && layout.contains("android:nextFocusLeft=\"@+id/btnAddCommand\"")); - } - - /** 详情页顶部图标按钮必须用带聚焦态的 selector,而不是只有按下态的 borderless ripple。 */ - @Test - public void detailHeaderIconButtonsUseTheFocusSelector() throws Exception { - String layout = stripComments(read(DETAIL_LAYOUT)); - Matcher matcher = Pattern.compile("android:background=\"([^\"]+)\"").matcher(layout); - int focusSelectors = 0; - while (matcher.find()) { - if ("@drawable/selector_lab_icon_btn".equals(matcher.group(1))) focusSelectors++; - } - assertTrue("terminal / add / refresh icons must use selector_lab_icon_btn, found " + focusSelectors, - focusSelectors == 3); - assertFalse("a borderless ripple has no focused state", - layout.contains("?attr/selectableItemBackgroundBorderless")); - } - - // ------------------------------------------------------------------ helpers - - /** 取出方法体,避免断言被同文件其它方法的文本意外满足。 */ - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue(signature + " is missing", start >= 0); - int open = source.indexOf('{', start); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return source.substring(open, i + 1); - } - throw new AssertionError(signature + " has no closing brace"); - } - - private static String stripComments(String source) { - return source - .replaceAll("(?s)", "") - .replaceAll("(?sm)^\\s*/\\*.*?\\*/", "") - .replaceAll("(?m)^\\s*//.*$", ""); - } - - private static String read(String path) throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/model/SiteViewModelSubmitAfterClearTest.java b/app/src/test/java/com/fongmi/android/tv/model/SiteViewModelSubmitAfterClearTest.java deleted file mode 100644 index 81ee1d7f92..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/model/SiteViewModelSubmitAfterClearTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.fongmi.android.tv.model; - -import com.google.common.util.concurrent.ListenableFuture; - -import org.junit.Test; - -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -/** - * 播放页销毁会清掉 ViewModel,而宿主在销毁后仍可能发起请求: - * 播放页的 onRefresh()/自动选集、短剧队列的下一条预取、阅读器章节切换都会走到 - * {@link SiteViewModel} 的提交入口。此前这些提交会命中 shutdownNow() 后的单线程池并抛 - * {@code RejectedExecutionException: Task rejected from ThreadPoolExecutor [Terminated, ...]}, - * 异常顺着宿主的 UI 线程冒出去直接打崩 App。 - * - *

因此 onCleared() 之后的提交必须统一降级为「本次请求作废」,而不是把异常留给宿主。

- */ -public class SiteViewModelSubmitAfterClearTest { - - @Test - public void foregroundEntriesAfterClearDoNotThrow() { - SiteViewModel model = new SiteViewModel(); - model.onCleared(); - - model.playerContent("key", "flag", "episode"); - model.playerContent("key", "flag", "episode", 0); - model.homeContent(); - model.categoryContent("key", "tid", "1", false, null); - model.detailContent("key", "id"); - model.action("key", "act"); - model.searchContent(List.of(), "keyword", true); - } - - @Test - public void isolatedResolverAfterClearReturnsCancelledFuture() { - SiteViewModel model = new SiteViewModel(); - model.onCleared(); - - ListenableFuture future = model.playerContentIsolated("key", "flag", "episode", 0, null, null); - - assertNotNull(future); - assertTrue("清理后的隔离取流必须直接作废", future.isCancelled()); - - model.playerContentIsolated("key", "flag", "episode", 0, null, null); - model.cancelPlayerContentIsolated(); - model.stopSearch(); - } - - @Test - public void lateForegroundRequestAfterClearIsDroppedInsteadOfDelivered() { - SiteViewModel model = new SiteViewModel(); - model.onCleared(); - - model.playerContent("key", "flag", "episode"); - - assertNull("已清理的 ViewModel 不应再向播放 LiveData 投递结果", model.getPlayer().getValue()); - assertNull(model.getResult().getValue()); - assertNull(model.getAction().getValue()); - } - - /** 覆盖「检查通过后池才被关闭」的竞态:try/catch 兜底必须生效,异常不得逃逸。 */ - @Test - public void concurrentSubmitDuringClearNeverPropagatesRejection() throws Exception { - AtomicReference escaped = new AtomicReference<>(); - for (int round = 0; round < 200 && escaped.get() == null; round++) { - SiteViewModel model = new SiteViewModel(); - CountDownLatch start = new CountDownLatch(1); - Thread submitter = new Thread(() -> { - try { - start.await(); - for (int i = 0; i < 50; i++) { - model.playerContent("key", "flag", "episode"); - model.playerContentIsolated("key", "flag", "episode", 0, null, null); - } - } catch (Throwable e) { - escaped.compareAndSet(null, e); - } - }, "submit-during-clear"); - submitter.setDaemon(true); - submitter.start(); - start.countDown(); - model.onCleared(); - submitter.join(TimeUnit.SECONDS.toMillis(10)); - } - Throwable error = escaped.get(); - assertNull(error == null ? null : String.valueOf(error) + "\n" + firstFrame(error), error); - } - - private static String firstFrame(Throwable error) { - StackTraceElement[] frames = error.getStackTrace(); - return frames.length == 0 ? "" : frames[0].toString(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/node/NodeBundleInstallTest.java b/app/src/test/java/com/fongmi/android/tv/node/NodeBundleInstallTest.java deleted file mode 100644 index c46d5e48a6..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/node/NodeBundleInstallTest.java +++ /dev/null @@ -1,465 +0,0 @@ -package com.fongmi.android.tv.node; - -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.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; - -/** - * 猫源本地包的安装、缓存判定和失败回滚——这些是有状态的路径,出错的后果是「装了 A 跑 B」 - * 或者「缓存永久坏掉只能清数据」,所以要真建目录、真装、真读磁盘来验证。 - * - *

{@code NodeBundle} 的这些入口都接一个运行目录(而不是 Context),正是为了能这样测。 - */ -public class NodeBundleInstallTest { - - @Rule - public TemporaryFolder folder = new TemporaryFolder(); - - private File runtime; - - private File runtime() throws IOException { - if (runtime == null) runtime = folder.newFolder("node-bundle"); - return runtime; - } - - @Test - public void installsLocalDirectoryAndMarksItCached() throws IOException { - File dir = runtime(); - File source = pack("pkg", "module.exports={a:1};", "module.exports={site:1};"); - - assertNull("完整的本地目录包必须能装上", NodeBundle.ensure(dir, source.getAbsolutePath(), null)); - assertEquals("module.exports={a:1};", read(new File(dir, "index.js"))); - assertEquals("module.exports={site:1};", read(new File(dir, "index.config.js"))); - assertTrue("来源键必须落盘,否则下次判不出缓存归属", read(new File(dir, "source.key")).startsWith("local:")); - assertEquals("stamp 必须是实际内容的 md5", md5("module.exports={a:1};"), read(new File(dir, "index.js.md5"))); - } - - /** 内容变了必须真的换掉运行目录里的文件,否则改完配置仍跑旧 config。 */ - @Test - public void reinstallingChangedSourceReplacesRuntimeFiles() throws IOException { - File dir = runtime(); - File source = pack("pkg", "module.exports={a:1};", "module.exports={site:1};"); - assertNull(NodeBundle.ensure(dir, source.getAbsolutePath(), null)); - String firstKey = read(new File(dir, "source.key")); - - Files.write(new File(source, "index.config.js").toPath(), "module.exports={site:2};".getBytes(StandardCharsets.UTF_8)); - assertNull(NodeBundle.ensure(dir, source.getAbsolutePath(), null)); - - assertEquals("新 config 必须生效", "module.exports={site:2};", read(new File(dir, "index.config.js"))); - assertFalse("来源键必须随内容变化", firstKey.equals(read(new File(dir, "source.key")))); - } - - /** - * 解压出来的目录是用户可写的,改 index.config.js 换站点是本地包的主要用法,marker - * 不会跟着更新。硬校验会把这批当前可用的包判死,且用户没有自救路径。 - */ - @Test - public void markerMismatchInDirectoryDoesNotBlockInstall() throws IOException { - File dir = runtime(); - File source = pack("pkg", "module.exports={a:1};", "module.exports={site:1};"); - Files.write(new File(source, "index.js.md5").toPath(), "ffffffffffffffffffffffffffffffff".getBytes(StandardCharsets.UTF_8)); - - assertNull("marker 与内容不符只该告警,不该阻断", NodeBundle.ensure(dir, source.getAbsolutePath(), null)); - assertEquals("装进去的必须是实际内容", "module.exports={a:1};", read(new File(dir, "index.js"))); - assertEquals("stamp 记的必须是实际内容的 md5,而不是那个过期 marker", - md5("module.exports={a:1};"), read(new File(dir, "index.js.md5"))); - } - - /** - * md5sum 生成的 marker 是「hash 文件名」两段格式。zip 形态是硬校验,认不出这个格式 - * 就会把整包判成「校验值无效」——而这类包在改动前是能正常安装的。 - */ - @Test - public void md5sumStyleMarkerIsAcceptedInZip() throws IOException { - File dir = runtime(); - String body = "module.exports={a:1};"; - File zip = zip("md5sum.zip", md5(body) + " index.js", body, "module.exports={site:1};"); - - assertNull("md5sum 格式的 marker 必须能用", NodeBundle.ensure(dir, zip.getAbsolutePath(), null)); - assertEquals(body, read(new File(dir, "index.js"))); - } - - /** zip 里的内容用户改不了,marker 不符就是包损坏,必须拒绝而不是装进去。 */ - @Test - public void corruptZipIsRejected() throws IOException { - File dir = runtime(); - File zip = zip("bad.zip", "ffffffffffffffffffffffffffffffff", "module.exports={a:1};", "module.exports={site:1};"); - - String error = NodeBundle.ensure(dir, zip.getAbsolutePath(), null); - assertNotNull("marker 与内容不符的 zip 必须拒绝", error); - assertFalse("拒绝后不能留下运行文件", new File(dir, "index.js").isFile()); - } - - @Test - public void installsZipPackage() throws IOException { - File dir = runtime(); - String body = "module.exports={a:2};"; - File zip = zip("ok.zip", md5(body), body, "module.exports={site:9};"); - - assertNull(NodeBundle.ensure(dir, zip.getAbsolutePath(), null)); - assertEquals(body, read(new File(dir, "index.js"))); - assertTrue(read(new File(dir, "source.key")).startsWith("local:")); - } - - @Test - public void missingConfigIsRejectedWithActionableMessage() throws IOException { - File dir = runtime(); - File source = folder.newFolder("partial"); - Files.write(new File(source, "index.js.md5").toPath(), md5("x").getBytes(StandardCharsets.UTF_8)); - Files.write(new File(source, "index.js").toPath(), "x".getBytes(StandardCharsets.UTF_8)); - - String error = NodeBundle.ensure(dir, source.getAbsolutePath(), null); - assertNotNull("缺 config 的包必须报错", error); - assertTrue("错误要指明缺什么:" + error, error.contains("index.config.js")); - assertFalse("失败时不能留下半截运行文件", new File(dir, "index.js").isFile()); - } - - /** 装到一半被杀会留下 staging 目录和临时文件,不清会一直吃内部存储。 */ - @Test - public void ensureSweepsLeftoversFromKilledProcess() throws IOException { - File dir = runtime(); - File stage = new File(dir, ".stage-abandoned"); - stage.mkdirs(); - Files.write(new File(stage, "index.js").toPath(), "leftover".getBytes(StandardCharsets.UTF_8)); - File backup = new File(dir, "index.js.backup-123.tmp"); - Files.write(backup.toPath(), "old".getBytes(StandardCharsets.UTF_8)); - - File source = pack("pkg", "module.exports={a:1};", "module.exports={site:1};"); - assertNull(NodeBundle.ensure(dir, source.getAbsolutePath(), null)); - - assertFalse("staging 残留必须被清掉", stage.exists()); - assertFalse("backup 残留必须被清掉", backup.exists()); - } - - @Test - public void sweepKeepsInstalledFilesAndRemovesOnlyLeftovers() throws IOException { - File dir = runtime(); - File source = pack("pkg", "module.exports={a:1};", "module.exports={site:1};"); - assertNull(NodeBundle.ensure(dir, source.getAbsolutePath(), null)); - - File stage = new File(dir, ".stage-x"); - stage.mkdirs(); - File temp = new File(dir, "index.js.999.tmp"); - Files.write(temp.toPath(), "t".getBytes(StandardCharsets.UTF_8)); - - NodeBundle.sweep(dir); - - assertFalse(stage.exists()); - assertFalse("writeAtomic 的临时文件也要清", temp.exists()); - assertTrue("已装好的 bundle 不能被清掉", new File(dir, "index.js").isFile()); - assertTrue(new File(dir, "index.config.js").isFile()); - assertTrue(new File(dir, "source.key").isFile()); - } - - // ---- isCached:缓存判定的每条拒绝理由都要真的生效,否则会把不对的内容当就绪跑起来 ---- - - @Test - public void isCachedAcceptsOnlySelfConsistentVerifiedFiles() throws IOException { - File dir = runtime(); - String body = "module.exports={a:1};"; - File target = new File(dir, "index.js"); - File stamp = new File(dir, "index.js.md5"); - File source = new File(dir, "source.key"); - Files.write(target.toPath(), body.getBytes(StandardCharsets.UTF_8)); - Files.write(stamp.toPath(), md5(body).getBytes(StandardCharsets.UTF_8)); - Files.write(source.toPath(), "remote:https://h/x:k".getBytes(StandardCharsets.UTF_8)); - - assertTrue("内容、stamp、来源键、服务端校验值全部一致才算命中", - NodeBundle.isCached(target, stamp, source, "remote:https://h/x:k", md5(body))); - assertTrue("服务端校验值拿不到时退回自洽判定", - NodeBundle.isCached(target, stamp, source, "remote:https://h/x:k", "")); - - assertFalse("来源键为空不能命中", NodeBundle.isCached(target, stamp, source, "", md5(body))); - assertFalse("来源键不属于这份缓存不能命中", - NodeBundle.isCached(target, stamp, source, "remote:https://h/other:k", md5(body))); - assertFalse("服务端校验值与实际内容不符不能命中", - NodeBundle.isCached(target, stamp, source, "remote:https://h/x:k", "ffffffffffffffffffffffffffffffff")); - assertFalse("目标文件不存在不能命中", - NodeBundle.isCached(new File(dir, "absent.js"), stamp, source, "remote:https://h/x:k", md5(body))); - - Files.write(stamp.toPath(), "ffffffffffffffffffffffffffffffff".getBytes(StandardCharsets.UTF_8)); - assertFalse("stamp 与实际内容不符不能命中(回滚失败留下的混合态)", - NodeBundle.isCached(target, stamp, source, "remote:https://h/x:k", "")); - - Files.write(stamp.toPath(), "TODO".getBytes(StandardCharsets.UTF_8)); - assertFalse("stamp 不是合法 md5 不能命中", - NodeBundle.isCached(target, stamp, source, "remote:https://h/x:k", "")); - } - - @Test - public void isCachedRejectsEmptyTarget() throws IOException { - File dir = runtime(); - File target = new File(dir, "index.js"); - File stamp = new File(dir, "index.js.md5"); - File source = new File(dir, "source.key"); - Files.write(target.toPath(), new byte[0]); - Files.write(stamp.toPath(), md5("").getBytes(StandardCharsets.UTF_8)); - Files.write(source.toPath(), "k".getBytes(StandardCharsets.UTF_8)); - - assertFalse("0 字节的目标文件不是有效缓存", NodeBundle.isCached(target, stamp, source, "k", "")); - } - - // ---- 来源键补齐:不补会让每次加载配置都白白重启一遍 :node 子进程 ---- - - /** - * 旧版本落盘的来源键没有内容指纹。两个文件都命中缓存时要就地补成当前身份, - * 否则 servesCurrentSource 每次都判「身份不符」→ 每次都重启子进程。 - */ - @Test - public void legacySourceKeyIsRefreshedOnceContentIsVerified() throws IOException { - File dir = runtime(); - String bundle = "module.exports={a:1};"; - String config = "module.exports={site:1};"; - String url = "https://host/cat/index.js.md5"; - seedInstalled(dir, bundle, config, "remote:https://host/cat/index.js"); - String key = NodeBundle.remoteSourceKey(url, md5(bundle), md5(config)); - - // 旧格式的键仍然「属于」这个地址,所以缓存判定本身能命中——这是补齐的前提。 - assertTrue("旧格式的键必须仍被认作该地址的缓存", - NodeBundle.installedIsRemoteOf(read(new File(dir, "source.key")), url)); - assertTrue("内容与服务端一致时缓存必须命中,不该重下", - NodeBundle.isCached(new File(dir, "index.js"), new File(dir, "index.js.md5"), - new File(dir, "source.key"), read(new File(dir, "source.key")), md5(bundle))); - - assertTrue("来源键必须被补成当前身份", NodeBundle.refreshSourceKey(dir, key)); - assertEquals(key, read(new File(dir, "source.key"))); - assertFalse("已经是当前身份时不该重复改写", NodeBundle.refreshSourceKey(dir, key)); - assertFalse("空身份不能覆盖掉已有的键", NodeBundle.refreshSourceKey(dir, "")); - assertEquals("拒绝改写时键必须原样保留", key, read(new File(dir, "source.key"))); - } - - /** - * 守卫:身份里的摘要必须与两个 stamp 一致才允许写入。只靠调用点的前置条件的话, - * 将来有人从别处调它就会静默写入一个与内容不符的身份,让下次复用判定认错。 - */ - @Test - public void refreshRejectsKeyThatDoesNotMatchInstalledStamps() throws IOException { - File dir = runtime(); - String bundle = "module.exports={a:1};"; - String config = "module.exports={site:1};"; - String url = "https://host/cat/index.js.md5"; - seedInstalled(dir, bundle, config, "remote:https://host/cat/index.js"); - String stale = "ffffffffffffffffffffffffffffffff"; - - assertFalse("bundle 摘要与 stamp 不符时不能写入", - NodeBundle.refreshSourceKey(dir, NodeBundle.remoteSourceKey(url, stale, md5(config)))); - assertFalse("config 摘要不符同样不能写入", - NodeBundle.refreshSourceKey(dir, NodeBundle.remoteSourceKey(url, md5(bundle), stale))); - assertFalse("拆不出摘要的旧格式身份不能写入", - NodeBundle.refreshSourceKey(dir, "remote:https://host/cat/index.js")); - assertEquals("被拒绝时键必须原样保留", - "remote:https://host/cat/index.js", read(new File(dir, "source.key"))); - - assertTrue("摘要与两个 stamp 都一致时才允许写入", - NodeBundle.refreshSourceKey(dir, NodeBundle.remoteSourceKey(url, md5(bundle), md5(config)))); - } - - /** - * 服务端只改 index.config.js(最常规的更新形态)时,index.js 一字未变,必须仍判命中。 - * 把两个文件的指纹揉进一个键、再拿它当单文件的缓存键,就会每次配置更新都重下整包。 - */ - @Test - public void updatingOnlyConfigKeepsBundleCached() throws IOException { - File dir = runtime(); - String bundle = "module.exports={a:1};"; - String oldConfig = "module.exports={site:1};"; - String newConfig = "module.exports={site:2};"; - String url = "https://host/cat/index.js.md5"; - seedInstalled(dir, bundle, oldConfig, NodeBundle.remoteSourceKey(url, md5(bundle), md5(oldConfig))); - - // 服务端公布的新校验值:bundle 没变、config 变了 - boolean[] cached = NodeBundle.remoteCacheState(dir, url, md5(bundle), md5(newConfig)); - assertTrue("index.js 没变就必须命中,不能因为 config 更新了而重下整包", cached[0]); - assertFalse("index.config.js 变了必须判未命中", cached[1]); - } - - /** 两个都没变时必须全命中,否则每次加载都白跑一遍下载。 */ - @Test - public void unchangedRemoteBundleIsFullyCached() throws IOException { - File dir = runtime(); - String bundle = "module.exports={a:1};"; - String config = "module.exports={site:1};"; - String url = "https://host/cat/index.js.md5"; - seedInstalled(dir, bundle, config, NodeBundle.remoteSourceKey(url, md5(bundle), md5(config))); - - boolean[] cached = NodeBundle.remoteCacheState(dir, url, md5(bundle), md5(config)); - assertTrue(cached[0]); - assertTrue(cached[1]); - } - - /** 旧格式来源键也要能命中,否则升级后白重下几十 MB。 */ - @Test - public void legacyKeyStillYieldsCacheHits() throws IOException { - File dir = runtime(); - String bundle = "module.exports={a:1};"; - String config = "module.exports={site:1};"; - String url = "https://host/cat/index.js.md5"; - seedInstalled(dir, bundle, config, "remote:https://host/cat/index.js"); - - boolean[] cached = NodeBundle.remoteCacheState(dir, url, md5(bundle), md5(config)); - assertTrue("旧格式键的缓存必须仍可命中", cached[0]); - assertTrue(cached[1]); - } - - /** 缓存属于别的地址时一律不能命中,否则会拿 A 源的 bundle 当 B 源用。 */ - @Test - public void cacheBelongingToAnotherUrlNeverHits() throws IOException { - File dir = runtime(); - String bundle = "module.exports={a:1};"; - String config = "module.exports={site:1};"; - seedInstalled(dir, bundle, config, - NodeBundle.remoteSourceKey("https://host/cat/index.js.md5", md5(bundle), md5(config))); - - boolean[] cached = NodeBundle.remoteCacheState(dir, "https://host/other/index.js.md5", md5(bundle), md5(config)); - assertFalse(cached[0]); - assertFalse(cached[1]); - } - - // ---- installedDigests:离线交叉校验的期望值来源,拆错会让校验静默失效 ---- - - @Test - public void installedDigestsExtractsVerifiedChecksums() { - String a = "b24cea4ad00908b04d0fbe8d0a01999e"; - String b = "ffffffffffffffffffffffffffffffff"; - - String[] parsed = NodeBundle.installedDigests("remote:https://host/cat/index.js:" + a + ":" + b); - assertEquals(a, parsed[0]); - assertEquals(b, parsed[1]); - - assertEquals("带端口的地址不能影响拆分", a, - NodeBundle.installedDigests("remote:http://host:8080/x/index.js:" + a + ":" + b)[0]); - - for (String bad : new String[]{"remote:https://host/cat/index.js", "remote:", ":", "", null, - "remote:https://host/a:b:c"}) { - String[] empty = NodeBundle.installedDigests(bad); - assertEquals("旧格式/畸形输入必须拆不出摘要:" + bad, "", empty[0]); - assertEquals("", empty[1]); - } - } - - // ---- install 的回滚:失败后必须回到旧缓存,或把缓存标记为不可信,绝不留下混合态 ---- - - @Test - public void installReplacesBothFilesAndStamps() throws IOException { - File dir = runtime(); - // 先铺一份旧的,否则 install 里 backup 分支根本不会执行,下面的 backup 断言就是空的。 - seedInstalled(dir, "old-bundle", "old-config", "local:/old:a:b"); - NodeBundle.PreparedFile bundle = staged(dir, "index.js", "new-bundle"); - NodeBundle.PreparedFile config = staged(dir, "index.config.js", "new-config"); - - assertNull(NodeBundle.install(dir, bundle, config, "local:/pkg:a:b")); - assertEquals("new-bundle", read(new File(dir, "index.js"))); - assertEquals("new-config", read(new File(dir, "index.config.js"))); - assertEquals(md5("new-bundle"), read(new File(dir, "index.js.md5"))); - assertEquals(md5("new-config"), read(new File(dir, "index.config.js.md5"))); - assertEquals("local:/pkg:a:b", read(new File(dir, "source.key"))); - // staging 目录由 ensure 的 finally 清理,install 只负责不留自己的 backup 临时文件。 - assertFalse("发布成功后必须删掉 backup", hasTempLeftover(dir)); - } - - /** - * config 的 staging 文件缺失(进程被杀、磁盘满)时 install 必然失败。此时 bundle 已经 - * 发布出去了,必须把旧的搬回来——否则下次启动跑的是新 JS + 旧 config 的混合态。 - */ - @Test - public void installRollsBackWhenSecondFileCannotBePublished() throws IOException { - File dir = runtime(); - seedInstalled(dir, "old-bundle", "old-config", "local:/old:a:b"); - - NodeBundle.PreparedFile bundle = staged(dir, "index.js", "new-bundle"); - NodeBundle.PreparedFile missing = new NodeBundle.PreparedFile(new File(dir, ".stage-gone/index.config.js"), md5("x")); - - assertNotNull("staging 文件缺失必须报错", NodeBundle.install(dir, bundle, missing, "local:/new:c:d")); - assertEquals("bundle 必须回滚成旧内容", "old-bundle", read(new File(dir, "index.js"))); - assertEquals("config 不能被动过", "old-config", read(new File(dir, "index.config.js"))); - assertEquals("stamp 必须回滚", md5("old-bundle"), read(new File(dir, "index.js.md5"))); - assertEquals("来源键必须回滚", "local:/old:a:b", read(new File(dir, "source.key"))); - assertFalse("回滚后不能留下 backup 残留", hasTempLeftover(dir)); - } - - /** 首次安装(无旧缓存)失败时不能留下半截文件,否则 isCached 可能把它当有效缓存。 */ - @Test - public void installLeavesNothingBehindWhenFirstInstallFails() throws IOException { - File dir = runtime(); - NodeBundle.PreparedFile bundle = staged(dir, "index.js", "new-bundle"); - NodeBundle.PreparedFile missing = new NodeBundle.PreparedFile(new File(dir, ".stage-gone/index.config.js"), md5("x")); - - assertNotNull(NodeBundle.install(dir, bundle, missing, "local:/new:c:d")); - assertFalse("已发布的 bundle 必须被撤销", new File(dir, "index.js").isFile()); - assertFalse("不能留下 stamp", new File(dir, "index.js.md5").isFile()); - assertFalse("不能留下来源键", new File(dir, "source.key").isFile()); - } - - private NodeBundle.PreparedFile staged(File dir, String name, String body) throws IOException { - File staging = new File(dir, ".stage-" + name); - staging.mkdirs(); - File file = new File(staging, name); - Files.write(file.toPath(), body.getBytes(StandardCharsets.UTF_8)); - return new NodeBundle.PreparedFile(file, md5(body)); - } - - private void seedInstalled(File dir, String bundle, String config, String key) throws IOException { - Files.write(new File(dir, "index.js").toPath(), bundle.getBytes(StandardCharsets.UTF_8)); - Files.write(new File(dir, "index.config.js").toPath(), config.getBytes(StandardCharsets.UTF_8)); - Files.write(new File(dir, "index.js.md5").toPath(), md5(bundle).getBytes(StandardCharsets.UTF_8)); - Files.write(new File(dir, "index.config.js.md5").toPath(), md5(config).getBytes(StandardCharsets.UTF_8)); - Files.write(new File(dir, "source.key").toPath(), key.getBytes(StandardCharsets.UTF_8)); - } - - private static boolean hasTempLeftover(File dir) { - File[] files = dir.listFiles(); - if (files == null) return false; - for (File file : files) if (file.getName().endsWith(".tmp")) return true; - return false; - } - - private File pack(String name, String bundle, String config) throws IOException { - File dir = folder.newFolder(name); - Files.write(new File(dir, "index.js").toPath(), bundle.getBytes(StandardCharsets.UTF_8)); - Files.write(new File(dir, "index.config.js").toPath(), config.getBytes(StandardCharsets.UTF_8)); - Files.write(new File(dir, "index.js.md5").toPath(), md5(bundle).getBytes(StandardCharsets.UTF_8)); - return dir; - } - - private File zip(String name, String marker, String bundle, String config) throws IOException { - File file = new File(folder.getRoot(), name); - try (java.util.zip.ZipOutputStream out = new java.util.zip.ZipOutputStream(new java.io.FileOutputStream(file))) { - entry(out, "index.js.md5", marker); - entry(out, "index.js", bundle); - entry(out, "index.config.js", config); - } - return file; - } - - private static void entry(java.util.zip.ZipOutputStream out, String name, String body) throws IOException { - out.putNextEntry(new java.util.zip.ZipEntry(name)); - out.write(body.getBytes(StandardCharsets.UTF_8)); - out.closeEntry(); - } - - private static String read(File file) throws IOException { - return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8).trim(); - } - - private static String md5(String text) { - try { - byte[] digest = java.security.MessageDigest.getInstance("MD5").digest(text.getBytes(StandardCharsets.UTF_8)); - StringBuilder value = new StringBuilder(); - for (byte item : digest) value.append(String.format(java.util.Locale.ROOT, "%02x", item & 0xff)); - return value.toString(); - } catch (Exception e) { - throw new IllegalStateException(e); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/node/NodeLocalBundleTest.java b/app/src/test/java/com/fongmi/android/tv/node/NodeLocalBundleTest.java index 871f2fcb3a..2ba252a976 100644 --- a/app/src/test/java/com/fongmi/android/tv/node/NodeLocalBundleTest.java +++ b/app/src/test/java/com/fongmi/android/tv/node/NodeLocalBundleTest.java @@ -8,11 +8,9 @@ import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.util.Locale; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -34,69 +32,6 @@ public class NodeLocalBundleTest { assertEquals("目录里有 index.js.md5 就是本地包", dir, NodeBundle.localDir(dir.getAbsolutePath(), null)); } - @Test - public void md5MarkersMustContainOnlyHexDigits() { - assertTrue(NodeBundle.isMd5("b24cea4ad00908b04d0fbe8d0a01999e")); - assertFalse("校验值不能只看长度", NodeBundle.isMd5("b24cea4ad00908b04d0fbe8d0a01999g")); - assertFalse(NodeBundle.isMd5("b24cea4ad00908b04d0fbe8d0a01999")); - } - - /** - * {@code md5sum > index.js.md5} 生成的是「{@code index.js}」两段格式。 - * 只按整行判定会把这种包判成「校验值无效」,而它在改动前是能正常安装的。 - */ - @Test - public void markerAcceptsMd5sumOutputFormat() throws Exception { - String hash = "b24cea4ad00908b04d0fbe8d0a01999e"; - java.lang.reflect.Method marker = NodeBundle.class.getDeclaredMethod("marker", String.class); - marker.setAccessible(true); - - assertEquals("裸校验值要原样返回", hash, marker.invoke(null, hash)); - assertEquals("md5sum 双空格格式要取第一段", hash, marker.invoke(null, hash + " index.js")); - assertEquals("单空格格式也要支持", hash, marker.invoke(null, hash + " index.js")); - assertEquals("制表符分隔同样处理", hash, marker.invoke(null, hash + "\tindex.js")); - assertEquals("前后换行/空白要去掉", hash, marker.invoke(null, " " + hash + "\n")); - assertEquals("", marker.invoke(null, "")); - assertEquals("", marker.invoke(null, (Object) null)); - } - - @Test - public void localSourceKeyChangesWhenBundleContentChanges() throws IOException { - File dir = pack("fingerprint"); - String before = NodeBundle.sourceKey(dir.getAbsolutePath(), null); - Files.write(new File(dir, "index.js").toPath(), "module.exports={changed:true};".getBytes(StandardCharsets.UTF_8)); - String after = NodeBundle.sourceKey(dir.getAbsolutePath(), null); - - assertTrue("本地包内容变化后不能继续使用旧来源指纹", before.startsWith("local:") && !before.equals(after)); - } - - /** config 变了、index.js 没变,也必须换身份——否则改完配置仍跑旧 config。 */ - @Test - public void localSourceKeyChangesWhenConfigContentChanges() throws IOException { - File dir = pack("config-fingerprint"); - String before = NodeBundle.sourceKey(dir.getAbsolutePath(), null); - Files.write(new File(dir, "index.config.js").toPath(), "module.exports={site:2};".getBytes(StandardCharsets.UTF_8)); - - assertNotEquals("config 内容变化也必须换来源身份", before, NodeBundle.sourceKey(dir.getAbsolutePath(), null)); - } - - /** 本地来源永远不能与远端来源撞身份,否则换源时会复用彼此的运行缓存。 */ - @Test - public void localAndRemoteKeysNeverCollide() throws IOException { - File dir = pack("namespaced"); - String local = NodeBundle.sourceKey(dir.getAbsolutePath(), null); - String md5 = "b24cea4ad00908b04d0fbe8d0a01999e"; - String remote = NodeBundle.remoteSourceKey("https://host/cat/index.js.md5", md5, md5); - - assertTrue("本地身份必须有 local: 前缀", local.startsWith("local:")); - assertTrue("远端身份必须有 remote: 前缀", remote.startsWith("remote:")); - assertNotEquals("本地与远端身份不能相等", local, remote); - assertFalse("本地身份不能被误判成某个远端地址的缓存", - NodeBundle.installedIsRemoteOf(local, "https://host/cat/index.js.md5")); - assertEquals("非法地址不能产出身份", "", NodeBundle.sourceKey("", null)); - assertEquals("", NodeBundle.sourceKey(null, null)); - } - @Test public void recognizesFileInsidePackageAndReturnsItsDirectory() throws IOException { File dir = pack("pkg"); @@ -151,69 +86,6 @@ public class NodeLocalBundleTest { assertFalse(NodeBundle.isRemote("")); } - /** - * 远端身份必须带上服务端公布的校验值。只用 URL 的话,服务端原地更新 bundle - * (地址不变、md5 变了)后仍会命中「同一来源」,继续跑旧 JS 直到进程被杀。 - */ - @Test - public void remoteSourceKeyTracksServerPublishedChecksums() { - String url = "https://host/cat/index.js.md5"; - String a = "b24cea4ad00908b04d0fbe8d0a01999e"; - String b = "ffffffffffffffffffffffffffffffff"; - - String first = NodeBundle.remoteSourceKey(url, a, a); - assertTrue("远端身份要有 remote: 前缀", first.startsWith("remote:")); - assertNotEquals("bundle 校验值变化必须换身份", first, NodeBundle.remoteSourceKey(url, b, a)); - assertNotEquals("config 校验值变化必须换身份", first, NodeBundle.remoteSourceKey(url, a, b)); - assertEquals("同样的校验值必须得到同样的身份", first, NodeBundle.remoteSourceKey(url, a, a)); - assertEquals("带不带 .md5 后缀是同一个来源", - first, NodeBundle.remoteSourceKey("https://host/cat/index.js", a, a)); - assertEquals("校验值大小写不该影响身份", first, NodeBundle.remoteSourceKey(url, a.toUpperCase(Locale.ROOT), a)); - } - - /** 拿不到校验值时必须返回空身份,让上层走「重新准备」而不是凭地址复用。 */ - @Test - public void remoteSourceKeyIsEmptyWithoutValidChecksums() { - String url = "https://host/cat/index.js.md5"; - String ok = "b24cea4ad00908b04d0fbe8d0a01999e"; - assertEquals("", NodeBundle.remoteSourceKey(url, "", ok)); - assertEquals("", NodeBundle.remoteSourceKey(url, ok, "")); - assertEquals("非法 md5 不能产出身份", "", NodeBundle.remoteSourceKey(url, "TODO", ok)); - assertEquals("", NodeBundle.remoteSourceKey(url, null, ok)); - } - - /** - * 断网降级只允许复用「确实属于该地址」的缓存。前缀比较必须带分隔符, - * 否则 {@code https://host/cat} 的缓存会被 {@code https://host/cat2} 命中。 - */ - @Test - public void offlineFallbackOnlyAcceptsCacheBelongingToTheSameUrl() { - String md5 = "b24cea4ad00908b04d0fbe8d0a01999e"; - String installed = NodeBundle.remoteSourceKey("https://host/cat/index.js.md5", md5, md5); - - assertTrue(NodeBundle.installedIsRemoteOf(installed, "https://host/cat/index.js.md5")); - assertTrue("带不带 .md5 后缀是同一个来源", NodeBundle.installedIsRemoteOf(installed, "https://host/cat/index.js")); - assertFalse("别的地址不能复用这份缓存", NodeBundle.installedIsRemoteOf(installed, "https://host/other/index.js.md5")); - assertFalse("前缀相同但不是同一地址也不行", NodeBundle.installedIsRemoteOf(installed, "https://host/cat/index.js2")); - assertFalse("本地来源不能被当成远端缓存", NodeBundle.installedIsRemoteOf("local:/sdcard/pkg:a:b", "https://host/cat/index.js.md5")); - assertFalse(NodeBundle.installedIsRemoteOf("", "https://host/cat/index.js.md5")); - } - - /** - * 旧版本落盘的来源键没有内容指纹({@code remote:})。不认它的话,老用户升级后在 - * 断网状态下会被判成「没有该地址的缓存」,尽管两个文件都完好躺在磁盘上。 - */ - @Test - public void offlineFallbackStillAcceptsLegacySourceKeys() { - String url = "https://host/cat/index.js.md5"; - assertTrue("旧格式(无内容指纹)必须仍被认作该地址的缓存", - NodeBundle.installedIsRemoteOf("remote:https://host/cat/index.js", url)); - assertFalse("旧格式同样不能跨地址复用", - NodeBundle.installedIsRemoteOf("remote:https://host/other/index.js", url)); - assertFalse("前缀是子串但不是同一地址也不行", - NodeBundle.installedIsRemoteOf("remote:https://host/cat/index.js2", url)); - } - @Test public void rejectsRemoteAndMissingPaths() throws IOException { assertNull(NodeBundle.localDir(null, null)); @@ -230,64 +102,6 @@ public class NodeLocalBundleTest { assertNull("zip 不是解压目录,localDir 不该认它", NodeBundle.localDir(zip.getAbsolutePath(), null)); } - /** - * 指纹和安装必须用同一个 root 解析路径。相对地址在 {@code /path} 和 {@code /path} - * 两处都存在时,若一边传 null 一边传 root,就会「算 A 包的指纹、装 B 包的内容」。 - */ - @Test - public void sourceKeyResolvesPathsWithTheSameRootAsInstall() throws IOException { - File root = folder.newFolder("sdcard"); - // 带随机后缀,避免依赖开发机上恰好没有 /Download/pkg(resolve 的最后一级回退是 "/" + path) - String relative = "Download/pkg-" + java.util.UUID.randomUUID(); - File dir = new File(root, relative); - write(dir); - - String viaRoot = NodeBundle.sourceKey(relative, root); - assertTrue("相对路径要能按 root 解析出指纹", viaRoot.startsWith("local:")); - assertTrue("指纹必须指向 root 下解析到的那个包", viaRoot.contains(dir.getCanonicalPath())); - assertEquals("同一个 root 解析同一个地址必须稳定", viaRoot, NodeBundle.sourceKey(relative, root)); - assertEquals("换掉 root 后该相对路径不再存在,不能仍然返回旧指纹", - "", NodeBundle.sourceKey(relative, folder.newFolder("other"))); - } - - /** 目录和 zip 是同一份包的两种形态,来源身份必须都带内容指纹。 */ - @Test - public void zipSourceKeyCoversContentNotJustPath() throws IOException { - File zip = zip("keyed.zip", true); - String key = NodeBundle.sourceKey(zip.getAbsolutePath(), null); - assertTrue("zip 也要有本地来源指纹", key.startsWith("local:")); - assertTrue("指纹必须包含 zip 自身路径", key.contains(zip.getCanonicalPath())); - - // 同一个路径重新打一份内容不同的 zip:身份必须变,否则改完包仍跑旧 JS。 - try (java.util.zip.ZipOutputStream out = new java.util.zip.ZipOutputStream(new java.io.FileOutputStream(zip))) { - entry(out, "index.js.md5", "b24cea4ad00908b04d0fbe8d0a01999e"); - entry(out, "index.js", "module.exports={changed:true};"); - entry(out, "index.config.js", "module.exports={};"); - } - assertNotEquals("zip 内容变化必须换来源身份", key, NodeBundle.sourceKey(zip.getAbsolutePath(), null)); - } - - /** marker 内容不是合法 md5 的 zip 不能产出来源身份,否则伪造 marker 就能命中缓存。 */ - @Test - public void zipWithBadMarkerHasNoSourceKey() throws IOException { - File file = new File(folder.getRoot(), "bad.zip"); - try (java.util.zip.ZipOutputStream out = new java.util.zip.ZipOutputStream(new java.io.FileOutputStream(file))) { - entry(out, "index.js.md5", "TODO"); - entry(out, "index.js", "module.exports={};"); - entry(out, "index.config.js", "module.exports={};"); - } - assertEquals("marker 非法时不能算出来源身份", "", NodeBundle.sourceKey(file.getAbsolutePath(), null)); - } - - /** 缺 index.config.js 的目录不是完整包,不能算出来源身份。 */ - @Test - public void directoryMissingConfigHasNoSourceKey() throws IOException { - File dir = folder.newFolder("partial"); - Files.write(new File(dir, "index.js.md5").toPath(), "b24cea4ad00908b04d0fbe8d0a01999e".getBytes(StandardCharsets.UTF_8)); - Files.write(new File(dir, "index.js").toPath(), "module.exports={};".getBytes(StandardCharsets.UTF_8)); - assertEquals("缺 config 的包不能算出来源身份", "", NodeBundle.sourceKey(dir.getAbsolutePath(), null)); - } - @Test public void rejectsUnrelatedZipAndNonZipFile() throws IOException { assertNull("不含 index.js.md5 的 zip 只是普通压缩包", NodeBundle.localZip(zip("other.zip", false).getAbsolutePath(), null)); diff --git a/app/src/test/java/com/fongmi/android/tv/node/NodePortSelectionTest.java b/app/src/test/java/com/fongmi/android/tv/node/NodePortSelectionTest.java index 375e60a5c3..ba386363ad 100644 --- a/app/src/test/java/com/fongmi/android/tv/node/NodePortSelectionTest.java +++ b/app/src/test/java/com/fongmi/android/tv/node/NodePortSelectionTest.java @@ -111,131 +111,7 @@ public class NodePortSelectionTest { assertTrue("NodeRuntime 启动必须有超时", runtime.contains("START_TIMEOUT_MS")); assertTrue("启动超时必须停止 NodeService", runtime.contains("NodeService.stop(context)")); assertTrue("启动超时必须复位 STARTING", runtime.contains("STARTING.compareAndSet(true, false)")); - assertTrue("CatSource 等待必须使用集中定义的启动预算", source.contains("latch.await(NodeRuntime.START_TIMEOUT_MS, TimeUnit.MILLISECONDS)")); - assertTrue("NodeRuntime 必须给首次加载足够的启动预算", runtime.contains("START_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(12)")); - assertTrue("NodeService 必须给端口就绪探测独立预算", runtime.contains("READY_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(3)")); - assertTrue("NodeService 必须给单次 /config 探测独立短超时", runtime.contains("READY_PROBE_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(2)")); - } - - @Test - public void readyProbeUsesBoundedPerRequestTimeoutAndLongerOverallBudget() throws IOException { - String runtime = read("com/fongmi/android/tv/node/NodeRuntime.java"); - String service = read("com/fongmi/android/tv/node/NodeService.java"); - assertTrue("就绪探测必须使用独立单次超时", service.contains("callTimeout(NodeRuntime.READY_PROBE_TIMEOUT_MS") - && service.contains("probeClient.newCall") && service.contains("NodeRuntime.READY_PROBE_TIMEOUT_MS")); - assertTrue("就绪探测必须使用整体 deadline,不能固定短轮数", service.contains("NodeRuntime.READY_TIMEOUT_MS") && service.contains("SystemClock.elapsedRealtime()")); - assertTrue("NodeService 不得仍使用旧的 225 轮固定上限", !service.contains("for (int i = 0; i < 225; i++)")); - } - - @Test - public void timeoutInvalidatesLateRepliesAndKeepsTheStoppingServiceVisible() throws IOException { - String runtime = read("com/fongmi/android/tv/node/NodeRuntime.java"); - int timeout = runtime.indexOf("private static synchronized void timeout("); - int stop = runtime.indexOf("NodeService.stop(context);", timeout); - int invalidate = runtime.indexOf("START_GENERATION.incrementAndGet();", timeout); - int clearUrl = runtime.indexOf("servingUrl = \"\";", timeout); - int nextMethod = runtime.indexOf("private static boolean same(", timeout); - - assertTrue("timeout must invalidate the old generation before stopping its service", - timeout >= 0 && invalidate > timeout && invalidate < stop); - assertTrue("timeout must keep the stopping service visible until the next start waits for process death", - clearUrl < timeout || clearUrl > nextMethod); - assertTrue("start and timeout must serialize the state transition so a retry cannot race the generation invalidation", - runtime.contains("public static synchronized void start(") - && runtime.contains("private static synchronized void timeout(")); - } - - @Test - public void nodeServiceStopsAfterReportingStartupError() throws IOException { - String service = read("com/fongmi/android/tv/node/NodeService.java"); - int method = service.indexOf("private void sendError("); - int send = service.indexOf("reply.send(msg)", method); - int stop = service.indexOf("stopSelf();", method); - - assertTrue("报告启动错误后必须停止前台 Service,不能让失败的 :node 常驻", - method >= 0 && send > method && stop > send); - } - - @Test - public void staleSuppressionCreditsAccumulateAcrossReaderClosures() throws IOException { - String router = read("com/fongmi/android/tv/ui/novel/NovelRouter.java"); - assertTrue("多次关闭时不能覆盖尚未消费的迟到结果额度", - router.contains("staleChapterResults.addAndGet(pending);") - && router.contains("Math.max(staleUntil, readerClosedAt + PENDING_CHAPTER_TTL)")); - } - - /** - * 复用运行中的 Node 必须过 {@code servesCurrentSource}(比完整来源身份), - * 而不是自己拼一个只看地址的条件——后者在服务端原地更新 bundle 后会继续跑旧 JS。 - * {@code servingSourceKey} 只能在 READY 时从磁盘读实际安装值,不能提前写请求值。 - */ - @Test - public void runtimeReuseGoesThroughFullSourceIdentityCheck() throws IOException { - String runtime = read("com/fongmi/android/tv/node/NodeRuntime.java"); - int reuse = runtime.indexOf("NodeBundle.servesCurrentSource(url, servingSourceKey)"); - int start = runtime.indexOf("if (!STARTING.compareAndSet(false, true))"); - - assertTrue("复用判定必须调用 servesCurrentSource", reuse >= 0); - assertTrue("复用判定必须在真正启动之前", start > reuse); - - // 枚举所有赋值,而不是排除某一种拼写:右值只允许「清空」或「从磁盘读实际安装值」。 - // 否则任何新写法(比如把请求侧算出的 key 直接塞进来)都能绕过检查。 - java.util.List assigned = new java.util.ArrayList<>(); - java.util.regex.Matcher matcher = java.util.regex.Pattern - .compile("servingSourceKey\\s*=\\s*([^;]+);").matcher(runtime); - while (matcher.find()) assigned.add(matcher.group(1).trim()); - - assertTrue("必须有 servingSourceKey 的赋值", !assigned.isEmpty()); - assertTrue("已安装来源键只能在 READY 后从磁盘读取", - assigned.contains("NodeBundle.installedSourceKey(App.get())")); - for (String value : assigned) { - assertTrue("servingSourceKey 不能被赋成 " + value + "(只允许 \"\" 或 installedSourceKey)", - value.equals("\"\"") || value.equals("NodeBundle.installedSourceKey(App.get())")); - } - } - - @Test - public void remoteMetadataIsParallelAndBundleTransfersUseLongerTimeout() throws IOException { - String bundle = read("com/fongmi/android/tv/node/NodeBundle.java"); - String runtime = read("com/fongmi/android/tv/node/NodeRuntime.java"); - assertTrue("远端 md5 必须并行获取", bundle.contains("remoteMd5Pair") - && bundle.contains("cat-md5-bundle") && bundle.contains("cat-md5-config")); - assertTrue("每个 md5 请求必须有完整调用超时,不能只限制连接阶段", bundle.contains("callTimeout(METADATA_TIMEOUT_MS")); - assertTrue("bundle 下载必须使用独立传输预算", bundle.contains("NodeRuntime.TRANSFER_TIMEOUT_MS")); - assertTrue("NodeLib 下载必须使用更长的运行时传输预算", - read("com/fongmi/android/tv/node/NodeLib.java").contains("NodeRuntime.LIB_TRANSFER_TIMEOUT_MS")); - assertTrue("运行时必须定义传输预算", runtime.contains("TRANSFER_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(3)")); - } - - @Test - public void bundleMetadataAndFirstDownloadsUseBoundedFailClosedValidation() throws IOException { - String bundle = read("com/fongmi/android/tv/node/NodeBundle.java"); - int metadata = bundle.indexOf("private static String remoteMd5(String url)"); - int download = bundle.indexOf("private static PreparedFile download("); - - assertTrue("远端 md5 响应必须限长读取,避免错误响应造成无界内存占用", - metadata >= 0 - && bundle.indexOf("MAX_METADATA_BYTES", metadata) > metadata - && bundle.indexOf("readLimited", metadata) > metadata - && !bundle.contains("OkHttp.string(md5Url(url))")); - assertTrue("首次下载必须要求有效 md5,不能在元数据失败时接受任意响应", - download >= 0 - && bundle.indexOf("if (!isMd5(expected))", download) > download - && bundle.indexOf("if (isMd5(expected) && !expected.equalsIgnoreCase(actual))", download) > download); - } - - /** - * {@code ByteArrayOutputStream.toString(Charset)} 是 API 33 才有的重载,而 minSdk 是 24, - * {@code desugar_jdk_libs_nio} 也不覆盖 {@code java.io.ByteArrayOutputStream}。用了它, - * Android 13 以下一进 readLimited 就 NoSuchMethodError——而 source.key/stamp 的读取全走这里, - * 等于整个猫源功能在旧系统上必崩。单元测试跑在 JVM 上,测不出来,只能锁源码。 - */ - @Test - public void textDecodingStaysWithinMinSdkApiSurface() throws IOException { - String bundle = read("com/fongmi/android/tv/node/NodeBundle.java"); - assertTrue("readLimited 必须用 API 24 就有的 toString(String),不能用 API 33 的 toString(Charset)", - bundle.contains("output.toString(StandardCharsets.UTF_8.name())") - && !bundle.contains("output.toString(StandardCharsets.UTF_8)")); + assertTrue("CatSource 等待必须有时间上限", source.contains("latch.await(60, TimeUnit.SECONDS)")); } @Test diff --git a/app/src/test/java/com/fongmi/android/tv/node/NodeRuntimeServingTest.java b/app/src/test/java/com/fongmi/android/tv/node/NodeRuntimeServingTest.java deleted file mode 100644 index e52cafcd81..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/node/NodeRuntimeServingTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.fongmi.android.tv.node; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.After; -import org.junit.Test; - -import java.lang.reflect.Field; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -/** - * 锁定 {@link NodeRuntime#isServing(String)} 的存活语义:只比对 servingUrl 是不够的—— - * timeout() 会把 running 置 false、port 置 0,:node 子进程被杀时 running 还停在 true。 - * 旧实现两种情况下都返回 true,于是 CatSource.reuse 会给出 http://127.0.0.1:0/config 这种无效地址。 - * - *

进程存活那一半在本 JVM 里无法制造(serviceAlive() 拿不到 App 就 fail-open 返回 true), - * 因此运行时用例覆盖 running/port/servingUrl 三个真实字段,serviceAlive() 的参与用源码契约固定。 - */ -public class NodeRuntimeServingTest { - - private static final String URL = "http://127.0.0.1:9988/cat/index.js"; - - @After - public void resetRuntimeState() throws Exception { - set("servingUrl", ""); - set("running", false); - set("port", 0); - } - - @Test - public void servingRequiresMatchingUrlAndLiveRuntime() throws Exception { - set("servingUrl", URL); - set("running", true); - set("port", 9988); - assertTrue("同一 bundle 且运行时存活时必须返回 true", NodeRuntime.isServing(URL)); - - set("port", 0); - assertFalse("timeout() 之后 port 被清 0,必须判定为不可复用", NodeRuntime.isServing(URL)); - - set("port", 9988); - set("running", false); - assertFalse("运行时已被标记停止时必须判定为不可复用", NodeRuntime.isServing(URL)); - - set("running", true); - set("servingUrl", ""); - assertFalse("没有服务中的 bundle 时必须判定为不可复用", NodeRuntime.isServing(URL)); - - set("servingUrl", "http://127.0.0.1:9988/cat/other.js"); - assertFalse("换了 bundle 必须判定为不可复用", NodeRuntime.isServing(URL)); - } - - @Test - public void isServingProbesProcessLivenessWithoutBlocking() { - String source = read("com/fongmi/android/tv/node/NodeRuntime.java"); - int start = source.indexOf("public static boolean isServing(String url)"); - int end = source.indexOf("private static boolean same(", start); - assertTrue("必须能定位 isServing", start > 0 && end > start); - String body = source.substring(start, end); - assertTrue("isServing 必须复用 serviceAlive() 探进程", body.contains("serviceAlive()")); - assertTrue("isServing 必须校验 port > 0", body.contains("port <= 0")); - assertTrue("isServing 必须校验 running", body.contains("!running")); - assertTrue("isServing 不得引入阻塞等待", !body.contains("sleep") && !body.contains("await") && !body.contains("wait(")); - } - - private static void set(String name, Object value) throws Exception { - Field field = NodeRuntime.class.getDeclaredField(name); - field.setAccessible(true); - field.set(null, value); - } - - private static String read(String relative) { - Path dir = Paths.get("").toAbsolutePath(); - for (int i = 0; i < 6 && dir != null; i++, dir = dir.getParent()) { - Path candidate = dir.resolve(Paths.get("app", "src", "main", "java")).resolve(relative.replace('/', java.io.File.separatorChar)); - if (Files.isRegularFile(candidate)) { - try { - return new String(Files.readAllBytes(candidate), StandardCharsets.UTF_8); - } catch (java.io.IOException e) { - throw new AssertionError("无法读取 " + candidate, e); - } - } - } - throw new IllegalStateException("app/src/main/java 下找不到 " + relative); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackEpisodeIdTest.java b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackEpisodeIdTest.java deleted file mode 100644 index 1a8f165daa..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackEpisodeIdTest.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.fongmi.android.tv.playback; - -import com.google.gson.JsonObject; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 集消歧字段({@code episodeId})的契约。 - * - *

背景:只读 API({@code apiSafe()})不输出 {@code episodeUrl}——那是带时效 - * 签名的播放地址。但对端要区分同一剧的不同集必须有稳定的集身份,否则同名集 - * (如两条线路都有"第01集")会塌缩成同一条记录、互相覆盖。 - * {@code episodeId} 就是 {@code episodeUrl} 的 SHA-256,只给指纹不给地址。 - */ -public class PlaybackEpisodeIdTest { - - @Test - public void emptyUrlProducesEmptyIdInsteadOfHashOfEmptyString() { - // 空串必须映射到空串:对端据此就能区分"没有单集地址"与"有一个地址", - // 不必再猜。若返回 sha256("") 的常量,对端会把它当成一个真实集身份。 - assertEquals("", PlaybackRecord.episodeId("")); - assertEquals("", PlaybackRecord.episodeId(null)); - } - - @Test - public void nonEmptyUrlProducesStableSixtyFourHexChars() { - String url = "https://cdn.example.com/live/abc.m3u8?sign=deadbeef&t=123"; - String id = PlaybackRecord.episodeId(url); - - assertEquals(64, id.length()); - assertTrue(id.matches("[0-9a-f]{64}")); - // 确定性:同一输入必须得到同一指纹(对端与本地各算一次也要一致)。 - assertEquals(id, PlaybackRecord.episodeId(url)); - } - - @Test - public void differentUrlsProduceDifferentIds() { - // 这是本字段存在的理由:不同集必须可区分。 - String first = PlaybackRecord.episodeId("https://cdn.example.com/ep01.m3u8"); - String second = PlaybackRecord.episodeId("https://cdn.example.com/ep02.m3u8"); - - assertFalse(first.equals(second)); - } - - @Test - public void apiSafeExposesEpisodeIdButNotEpisodeUrl() { - PlaybackFieldPolicy policy = PlaybackFieldPolicy.apiSafe(); - - // 集身份必须可得,否则对端无法消歧。 - assertTrue("apiSafe 必须暴露 episodeId", policy.includes("episodeId")); - // 播放地址必须不可得:它常带 CDN 签名/token,对端只需要"是不是同一集"。 - assertFalse("apiSafe 不得暴露 episodeUrl", policy.includes("episodeUrl")); - } - - @Test - public void recordSerializesEpisodeIdUnderApiSafe() { - PlaybackRecord record = new PlaybackRecord(); - record.siteKey = "site"; - record.vodId = "vod"; - record.episodeUrl = "https://cdn.example.com/ep01.m3u8"; - record.episodeId = PlaybackRecord.episodeId(record.episodeUrl); - - JsonObject json = record.toJson(PlaybackFieldPolicy.apiSafe()); - - assertTrue(json.has("episodeId")); - assertEquals(record.episodeId, json.get("episodeId").getAsString()); - assertFalse("apiSafe 序列化不得写出 episodeUrl", json.has("episodeUrl")); - } - - @Test - public void episodeIdIsDerivedFromEpisodeUrlAtConstructionSites() { - // 生产路径:PlaybackRecord.from() 会调 episodeId(episodeUrl) 自动补指纹。 - // 但 from() 依赖 Config.find()/VodConfig(Android 运行时),JVM 单测里不可用, - // 因此这里只锁"赋值契约":给定 url 时字段必须等于 episodeId(url)。 - // from() 的接线由源码断言锁(见下一条用例)。 - PlaybackRecord record = new PlaybackRecord(); - record.episodeUrl = "https://cdn.example.com/ep01.m3u8"; - record.episodeId = PlaybackRecord.episodeId(record.episodeUrl); - - assertEquals(PlaybackRecord.episodeId("https://cdn.example.com/ep01.m3u8"), record.episodeId); - assertFalse(record.episodeId.isEmpty()); - } - - @Test - public void fromWiresEpisodeIdFromEpisodeUrl() throws Exception { - // 源码级接线锁:from() 必须真的调用 episodeId(...)。 - // - // 为什么用源码断言而不是行为断言:from() 依赖 Config.find() 与 VodConfig, - // 两者都需要 Android 运行时;本仓库已有同类先例(如 - // GlobalHistoryResumeSourceTest 用源码断言锁接线)。 - // 断言前剥掉注释行,避免"把语句注释掉"也能通过。 - String source = readSource("app/src/main/java/com/fongmi/android/tv/playback/PlaybackRecord.java"); - assertTrue("from() 必须从 episodeUrl 派生 episodeId", - source.contains("record.episodeId = episodeId(record.episodeUrl);")); - assertTrue("toJson 必须在 apiSafe 下写出 episodeId", - source.contains("if (policy.includes(\"episodeId\") && !TextUtils.isEmpty(episodeId)) object.addProperty(\"episodeId\", episodeId);")); - } - - private static String readSource(String relative) throws Exception { - // 从 user.dir 逐级向上找项目根:Gradle 的 JVM 单测工作目录可能是仓库根, - // 也可能是模块目录(app/);写死单层回退会在换一种布局后静默失效。 - // 仓库既有先例:ExoVideoHardwareDecodeTest.readSource 用同样的向上查找。 - java.nio.file.Path root = java.nio.file.Paths.get(System.getProperty("user.dir")).toAbsolutePath(); - while (root != null && !java.nio.file.Files.isRegularFile(root.resolve(relative))) root = root.getParent(); - if (root == null) throw new IllegalStateException("找不到项目根,无法读取 " + relative); - StringBuilder builder = new StringBuilder(); - for (String line : java.nio.file.Files.readAllLines(root.resolve(relative), java.nio.charset.StandardCharsets.UTF_8)) { - String trimmed = line.trim(); - if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue; - builder.append(line).append('\n'); - } - return builder.toString(); - } - - @Test - public void inputParsesEpisodeIdAndDerivesItFromUrlWhenAbsent() { - // 对端可能只给 url(旧形态)或只给 id(apiSafe 形态),两种都必须能收。 - PlaybackProgressInput fromId = PlaybackProgressInput.fromJson( - "{\"episodeId\":\"abc123\",\"siteKey\":\"s\",\"vodId\":\"v\"}"); - assertEquals("abc123", fromId.episodeId); - - PlaybackProgressInput fromUrl = PlaybackProgressInput.fromJson( - "{\"episodeUrl\":\"https://cdn.example.com/ep01.m3u8\",\"siteKey\":\"s\",\"vodId\":\"v\"}"); - assertEquals(PlaybackRecord.episodeId("https://cdn.example.com/ep01.m3u8"), fromUrl.episodeId); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryDiagnosticsWiringTest.java b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryDiagnosticsWiringTest.java deleted file mode 100644 index 8e8045a82d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryDiagnosticsWiringTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.fongmi.android.tv.playback; - -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * 「按站点读历史」这条新读取路径的失败必须同时留在「诊断日志」和「Logcat」两条通道上。 - * - *

为什么需要这个守卫:{@code SpiderDebug.log(tag, …)} 的每一条重载先是 - * {@code DebugLogStore.acceptsTag(tag)},而它等于 {@code enabled && …}; - * {@code DebugLogStore.enabled} 默认是 {@code false}(要用户在设置里打开「调试日志」)。 - * 因此只用 {@code SpiderDebug} 记录读库失败,在默认设置下等同于**完全静默**。 - * - *

而这条路径恰恰是「对端看到空历史却查不出原因」的唯一解释点: - * 空数组既可能是「这个站点真没历史」,也可能是「库挂了」或「单条记录坏了被跳过」。 - * 两者在响应里长得一模一样,只有日志能区分。仓库既有同类口径: - * {@code PyLoaderDiagnosticsWiringTest}(C73 修复)与 {@code JsLoader}。 - */ -public class PlaybackHistoryDiagnosticsWiringTest { - - private static final Path API = Path.of("app/src/main/java/com/fongmi/android/tv/playback/PlaybackApi.java"); - private static final Path ENDPOINT = Path.of("app/src/main/java/com/fongmi/android/tv/server/process/PlaybackHistoryApi.java"); - - /** - * 去掉注释行后再断言:否则把语句注释掉({@code // e.printStackTrace();})也能满足 - * 「有兜底日志」的断言,守卫就退化成恒真检查。 - * - *

路径从 {@code user.dir} 逐级向上找项目根:Gradle 的 JVM 单测工作目录可能是 - * 仓库根,也可能是模块目录({@code app/},见 {@code PyLoaderDiagnosticsWiringTest} - * 用模块相对路径),写死一种布局会在换布局后静默失效。 - */ - private static String code(Path relative) throws Exception { - Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath(); - while (root != null && !Files.isRegularFile(root.resolve(relative))) root = root.getParent(); - if (root == null) throw new IllegalStateException("找不到项目根,无法读取 " + relative); - StringBuilder result = new StringBuilder(); - for (String line : Files.readString(root.resolve(relative)).replace("\r\n", "\n").split("\n", -1)) { - String trimmed = line.trim(); - if (!trimmed.startsWith("//") && !trimmed.startsWith("*") && !trimmed.startsWith("/*")) { - result.append(line).append('\n'); - } - } - return result.toString(); - } - - /** - * 取第 {@code index} 个 {@code catch (Throwable e) {} } 块体。 - * - *

为什么必须按块取:整批读库失败与单条坏记录是两个相邻的 catch。 - * 若只做「从某处往后找 printStackTrace」,第一个 catch 被删掉时断言会匹配到 - * **第二个** catch 的兜底日志而恒真——这正是本守卫第一版踩过的坑。 - */ - private static String catchBlock(String source, int index) { - int start = -1; - for (int i = 0; i <= index; i++) start = source.indexOf("catch (Throwable e) {", start + 1); - if (start < 0) throw new IllegalStateException("找不到第 " + index + " 个 catch 块"); - // 用花括号配平定位块尾,而不是匹配字面缩进(原先找 "\n }")。 - // 字面缩进有两个失效面:失配时回退到 source.length() 会让块体延伸到文件尾, - // 于是"块里含某段文本"对后续任何 catch 都恒真;即便改成显式失败,缩进一变 - // 也会匹配到**更靠后的**同缩进花括号,块体同样被撑大。配平扫描对缩进免疫。 - int open = source.indexOf('{', start); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return source.substring(start, i + 1); - } - throw new IllegalStateException("第 " + index + " 个 catch 块缺少配对的结束花括号"); - } - - @Test - public void databaseReadFailureReachesLogcatWithoutDebugLoggingEnabled() throws Exception { - String block = catchBlock(code(API), 0); - - assertTrue("读库失败必须进诊断日志", block.contains("SpiderDebug.log(\"playback-api\", e);")); - assertTrue("读库失败还必须无条件进 Logcat(SpiderDebug 默认不落盘)", block.contains("e.printStackTrace();")); - } - - @Test - public void perRecordFailureReachesLogcatToo() throws Exception { - String block = catchBlock(code(API), 1); - - // 单条坏记录被跳过是「历史里少了一条」的唯一解释点,同样必须有兜底通道。 - assertTrue("单条坏记录必须进诊断日志", block.contains("SpiderDebug.log(\"playback-api\", e);")); - assertTrue("单条坏记录的跳过路径也必须留下无条件日志", block.contains("e.printStackTrace();")); - } - - @Test - public void endpointFailureReachesLogcat() throws Exception { - String block = catchBlock(code(ENDPOINT), 0); - - assertTrue("端点 500 必须进诊断日志", block.contains("SpiderDebug.log(\"playback-api\", e);")); - assertTrue("端点 500 还必须无条件进 Logcat", block.contains("e.printStackTrace();")); - } - - @Test - public void emptyResultIsStillDistinguishableFromFailureInTheResponse() throws Exception { - // 契约的另一半:读库失败返回空数组是**有意的**(对端据此不推进游标), - // 因此不能靠「把失败改成 500」来省掉日志——两者必须同时成立。 - String block = catchBlock(code(API), 0); - - assertTrue("读库失败后仍返回空数组,不抛给调用方", block.contains("return Collections.emptyList();")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryLimitTest.java b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryLimitTest.java deleted file mode 100644 index b297bbdadd..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryLimitTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.fongmi.android.tv.playback; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -/** - * 按站点读历史时的条数夹取契约。 - * - *

为什么值得单独锁:{@code limit} 是对端能直接控制的输入。若不做上限夹取, - * 一次 {@code limit=999999} 就能让本机一次性把整张表读进内存并序列化成响应体。 - */ -public class PlaybackHistoryLimitTest { - - @Test - public void missingOrNonPositiveLimitFallsBackToDefault() { - assertEquals(PlaybackApi.DEFAULT_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(0)); - assertEquals(PlaybackApi.DEFAULT_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(-1)); - assertEquals(PlaybackApi.DEFAULT_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(Integer.MIN_VALUE)); - } - - @Test - public void limitAboveMaximumIsClamped() { - assertEquals(PlaybackApi.MAX_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(PlaybackApi.MAX_HISTORY_LIMIT + 1)); - assertEquals(PlaybackApi.MAX_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(999_999)); - assertEquals(PlaybackApi.MAX_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(Integer.MAX_VALUE)); - } - - @Test - public void limitWithinRangeIsPreserved() { - assertEquals(1, PlaybackApi.effectiveHistoryLimit(1)); - assertEquals(PlaybackApi.DEFAULT_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(PlaybackApi.DEFAULT_HISTORY_LIMIT)); - assertEquals(PlaybackApi.MAX_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(PlaybackApi.MAX_HISTORY_LIMIT)); - } - - @Test - public void defaultIsNotAboveMaximum() { - // 常量本身必须自洽:默认值若超过上限,夹取逻辑就自相矛盾。 - org.junit.Assert.assertTrue(PlaybackApi.DEFAULT_HISTORY_LIMIT <= PlaybackApi.MAX_HISTORY_LIMIT); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackProgressWriterEpisodeTest.java b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackProgressWriterEpisodeTest.java index 3b3a5ab52f..2c82cd05f0 100644 --- a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackProgressWriterEpisodeTest.java +++ b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackProgressWriterEpisodeTest.java @@ -4,7 +4,6 @@ import com.fongmi.android.tv.bean.History; import org.junit.Test; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -26,152 +25,6 @@ public class PlaybackProgressWriterEpisodeTest { assertFalse(PlaybackProgressWriter.isSameEpisode(local, input)); } - @Test - public void sourceBindingIsCopiedIntoPersistedHistory() { - History history = new History(); - PlaybackProgressInput input = new PlaybackProgressInput(); - input.sourceBindingKey = "stable-interface-key"; - - PlaybackProgressWriter.applySourceBindingKey(history, input); - - assertTrue("stable-interface-key".equals(history.getSourceBindingKey())); - assertTrue("stable-interface-key".equals(history.copy().getSourceBindingKey())); - } - - @Test - public void fingerprintProvesSameEpisodeWhenPeerHasNoUrl() { - // 只读 API 故意不输出 episodeUrl,只给指纹;对端原样回推时就是这种形态。 - // 没有季集号的内容(非 TMDB)只有指纹能证明「这是同一集」。 - History local = history("源站第1集", "https://cdn.example.com/ep01.m3u8", -1, -1); - PlaybackProgressInput input = input("源站第1集", ""); - input.episodeId = PlaybackRecord.episodeId("https://cdn.example.com/ep01.m3u8"); - - assertTrue(PlaybackProgressWriter.sameEpisodeFingerprint(local, input)); - } - - @Test - public void differentFingerprintIsNotTheSameEpisode() { - // 对端推来的可能是另一集:此时不能把本地地址当成新集的地址保留。 - History local = history("源站第1集", "https://cdn.example.com/ep01.m3u8", -1, -1); - PlaybackProgressInput input = input("源站第2集", ""); - input.episodeId = PlaybackRecord.episodeId("https://cdn.example.com/ep02.m3u8"); - - assertFalse(PlaybackProgressWriter.sameEpisodeFingerprint(local, input)); - } - - @Test - public void oneSidedFingerprintIsNotEnough() { - // 单侧有指纹无法证明同一集(对端可能是另一条线路的同名集),必须保守。 - History local = history("源站第1集", "https://cdn.example.com/ep01.m3u8", -1, -1); - PlaybackProgressInput input = input("源站第1集", ""); - - assertFalse(PlaybackProgressWriter.sameEpisodeFingerprint(local, input)); - assertFalse(PlaybackProgressWriter.sameEpisodeFingerprint(null, input)); - } - - @Test - public void peerRoundTripWithoutUrlKeepsTheLocalPlaybackUrl() { - // 端到端决策:对端把只读 API 读到的记录原样回推(没有 episodeUrl,只有指纹), - // 本地已落库的播放地址必须保留——它是「从历史继续播放」定位集条目的依据。 - String url = "https://cdn.example.com/ep01.m3u8"; - History local = history("源站第1集", url, -1, -1); - PlaybackProgressInput input = input("源站第1集", ""); - input.episodeId = PlaybackRecord.episodeId(url); - - assertEquals(url, PlaybackProgressWriter.resolveEpisodeUrl(local, input, false)); - } - - @Test - public void peerPushingAnotherEpisodeDoesNotKeepTheStaleUrl() { - // 反向约束:对端推的确是另一集时,不能把上一集的地址挂到新集的进度上。 - History local = history("源站第1集", "https://cdn.example.com/ep01.m3u8", -1, -1); - PlaybackProgressInput input = input("源站第2集", ""); - input.episodeId = PlaybackRecord.episodeId("https://cdn.example.com/ep02.m3u8"); - - assertEquals("", PlaybackProgressWriter.resolveEpisodeUrl(local, input, false)); - } - - @Test - public void incomingUrlAlwaysWins() { - // 入参带了地址就是权威(对端在播放、地址是新的),直接采用。 - History local = history("源站第1集", "https://cdn.example.com/old.m3u8", -1, -1); - PlaybackProgressInput input = input("源站第1集", "https://cdn.example.com/new.m3u8"); - - assertEquals("https://cdn.example.com/new.m3u8", - PlaybackProgressWriter.resolveEpisodeUrl(local, input, false)); - } - - @Test - public void sameEpisodeNameKeepsTheLocalUrl() { - // 既有容错语义(sameEpisode=true)也必须保留本地地址,不能只认指纹。 - History local = history("源站第1集", "https://cdn.example.com/ep01.m3u8", -1, -1); - PlaybackProgressInput input = input("源站第1集", ""); - - assertEquals("https://cdn.example.com/ep01.m3u8", - PlaybackProgressWriter.resolveEpisodeUrl(local, input, true)); - } - - @Test - public void firstTimeWriteWithoutUrlStaysEmpty() { - // 本地本来就没有记录:没有地址可保留,落空串而不是 null。 - PlaybackProgressInput input = input("源站第1集", ""); - - assertEquals("", PlaybackProgressWriter.resolveEpisodeUrl(null, input, false)); - } - - @Test - public void episodeWithoutTmdbIdentityKeepsTheConfirmedLocalSeason() { - // 入参没有 TMDB 季集身份但集号可解析时,不得把本地已确认季号抹成 0。 - History local = history("源站第5集", "https://cdn.example.com/ep05.m3u8", 2, 5); - History history = local.copy(); - PlaybackProgressInput input = input("源站第6集", "https://cdn.example.com/ep06.m3u8"); - input.episodeNumber = 6; - - PlaybackProgressWriter.applyEpisodePosition(history, local, input, false); - - assertFalse(input.hasTmdbEpisodeIdentity()); - assertEquals(2, history.getTmdbSeasonNumber()); - assertEquals(6, history.getTmdbEpisodeNumber()); - } - - @Test - public void episodeWithoutTmdbIdentityFallsBackToTheIncomingSeasonWhenLocalHasNone() { - History local = history("源站第5集", "https://cdn.example.com/ep05.m3u8", -1, -1); - History history = local.copy(); - PlaybackProgressInput input = input("源站第6集", "https://cdn.example.com/ep06.m3u8"); - input.seasonNumber = 4; - input.episodeNumber = 6; - - PlaybackProgressWriter.applyEpisodePosition(history, local, input, false); - - assertEquals(4, history.getTmdbSeasonNumber()); - assertEquals(6, history.getTmdbEpisodeNumber()); - } - - @Test - public void episodeWithoutTmdbIdentityAndWithoutNumberStillClearsThePosition() { - History local = history("源站第5集", "https://cdn.example.com/ep05.m3u8", 2, 5); - History history = local.copy(); - PlaybackProgressInput input = input("下一集", "https://cdn.example.com/ep06.m3u8"); - - PlaybackProgressWriter.applyEpisodePosition(history, local, input, false); - - assertEquals(0, history.getTmdbSeasonNumber()); - assertEquals(0, history.getTmdbEpisodeNumber()); - } - - @Test - public void sameEpisodeKeepsThePersistedTmdbPositionUntouched() { - History local = history("源站第5集", "https://cdn.example.com/ep05.m3u8", 2, 5); - History history = local.copy(); - PlaybackProgressInput input = input("源站第5集", ""); - - PlaybackProgressWriter.applyEpisodePosition(history, local, input, true); - - assertEquals(2, history.getTmdbSeasonNumber()); - assertEquals(5, history.getTmdbEpisodeNumber()); - } - private static History history(String remarks, String url, int season, int episode) { History history = new History(); history.setVodRemarks(remarks); diff --git a/app/src/test/java/com/fongmi/android/tv/playback/SubtitleRestorePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/playback/SubtitleRestorePolicyTest.java deleted file mode 100644 index 6febaf435f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/playback/SubtitleRestorePolicyTest.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.fongmi.android.tv.playback; - -import com.fongmi.android.tv.bean.Sub; - -import org.junit.Test; - -import java.util.Set; -import java.util.function.Predicate; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class SubtitleRestorePolicyTest { - - private static final String EPISODE = "https://site/ep12.m3u8"; - - @Test - public void restoresLocalSubtitleWhenFileStillExists() { - SubtitleRestorePolicy.Decision decision = decide( - source("/storage/emulated/0/a.srt", EPISODE), EPISODE, false, "/storage/emulated/0/a.srt"); - - assertTrue(decision.restore()); - assertFalse(decision.clear()); - } - - @Test - public void dropsLocalSubtitleWhenFileIsGone() { - SubtitleRestorePolicy.Decision decision = decide( - source("/data/cache/subtitle_asset/a.srt", EPISODE), EPISODE, false); - - assertFalse(decision.restore()); - assertTrue("失效记录必须清掉,否则每次起播都白跑一次文件检查", decision.clear()); - assertEquals("file-missing", decision.reason()); - } - - @Test - public void restoresRemoteSubtitleWithoutTouchingTheFilesystem() { - SubtitleRestorePolicy.Decision decision = decide( - source("https://sub.example/a.srt", EPISODE), EPISODE, false); - - assertTrue(decision.restore()); - assertFalse(decision.clear()); - } - - @Test - public void keepsPreferenceWhenPlayingAnotherEpisode() { - SubtitleRestorePolicy.Decision decision = decide( - source("/storage/emulated/0/a.srt", "https://site/ep11.m3u8"), - EPISODE, false, "/storage/emulated/0/a.srt"); - - assertFalse("上一集的字幕挂到下一集会让时间轴完全错位", decision.restore()); - assertFalse("换集只是这一次不适用,清掉的话切回 ep11 就没字幕了", decision.clear()); - assertEquals("episode-changed", decision.reason()); - } - - @Test - public void dropsLegacyRecordWithoutEpisodeUrl() { - SubtitleRestorePolicy.Decision decision = decide( - source("/storage/emulated/0/a.srt", ""), EPISODE, false, "/storage/emulated/0/a.srt"); - - assertFalse(decision.restore()); - assertTrue(decision.clear()); - assertEquals("episode-unknown", decision.reason()); - } - - @Test - public void keepsOriginalPreferenceOnCrossSourcePlayback() { - SubtitleRestorePolicy.Decision decision = decide( - source("/storage/emulated/0/a.srt", EPISODE), EPISODE, true, "/storage/emulated/0/a.srt"); - - assertFalse(decision.restore()); - assertFalse("跨源不注入,但原源的偏好要留着", decision.clear()); - assertEquals("cross-source", decision.reason()); - } - - @Test - public void absentSourceNeedsNoWriteBack() { - SubtitleRestorePolicy.Decision decision = decide(null, EPISODE, false); - - assertFalse(decision.restore()); - assertFalse(decision.clear()); - assertEquals("absent", decision.reason()); - } - - private static SubtitleRestorePolicy.Decision decide( - SubtitleSource source, String episodeUrl, boolean crossSource, String... existing) { - Set files = Set.of(existing); - Predicate exists = files::contains; - return SubtitleRestorePolicy.decide(source, episodeUrl, crossSource, exists); - } - - private static SubtitleSource source(String url, String episodeUrl) { - return SubtitleSource.of(Sub.create("字幕", url, "zh", "application/x-subrip"), episodeUrl); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/playback/SubtitleSourceTest.java b/app/src/test/java/com/fongmi/android/tv/playback/SubtitleSourceTest.java deleted file mode 100644 index d597c38ab9..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/playback/SubtitleSourceTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.fongmi.android.tv.playback; - -import com.fongmi.android.tv.bean.Sub; - -import org.junit.Test; - -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; - -public class SubtitleSourceTest { - - @Test - public void encodeDecodeKeepsEveryField() { - SubtitleSource source = SubtitleSource.of( - Sub.create("简体中文", "/storage/emulated/0/Movies/a.srt", "zh", "application/x-subrip"), - "https://site/ep12.m3u8"); - - SubtitleSource restored = SubtitleSource.decode(SubtitleSource.encode(source)); - - assertNotNull(restored); - assertEquals("简体中文", restored.getName()); - assertEquals("/storage/emulated/0/Movies/a.srt", restored.getUrl()); - assertEquals("zh", restored.getLang()); - assertEquals("application/x-subrip", restored.getFormat()); - assertEquals("https://site/ep12.m3u8", restored.getEpisodeUrl()); - assertEquals(SubtitleSource.MODE_EXTERNAL, restored.getMode()); - assertEquals(source.getTime(), restored.getTime()); - } - - @Test - public void remoteIsDetectedFromUrlScheme() { - assertTrue(source("https://sub.example/a.srt").isRemote()); - assertFalse(source("/storage/emulated/0/a.srt").isRemote()); - assertFalse(source("/data/user/0/pkg/cache/subtitle_asset/x.srt").isRemote()); - } - - @Test - public void ofRejectsUnusableSubtitle() { - assertNull(SubtitleSource.of(null, "ep")); - assertNull(SubtitleSource.of(Sub.create("name", "", "zh", "text/plain"), "ep")); - } - - @Test - public void encodeReturnsEmptyForUnusableSource() { - assertEquals("", SubtitleSource.encode(null)); - assertEquals("", SubtitleSource.encode(new SubtitleSource())); - } - - @Test - public void decodeSurvivesDirtyData() { - assertNull(SubtitleSource.decode(null)); - assertNull(SubtitleSource.decode("")); - assertNull(SubtitleSource.decode("{")); - assertNull(SubtitleSource.decode("not json at all")); - assertNull(SubtitleSource.decode("[1,2,3]")); - // 结构合法但没有 url,不能当成可用偏好。 - assertNull(SubtitleSource.decode("{\"mode\":\"external\",\"name\":\"x\"}")); - } - - @Test - public void toSubRoundTripsThroughSub() { - Sub sub = source("/storage/emulated/0/a.ass").toSub(); - - assertNotNull(sub); - assertEquals("/storage/emulated/0/a.ass", sub.getUrl()); - assertEquals("名字", sub.getName()); - assertEquals("zh", sub.getLang()); - } - - @Test - public void missingEpisodeUrlBecomesEmptyNotNull() { - assertEquals("", SubtitleSource.of( - Sub.create("n", "/a.srt", "zh", "text/plain"), null).getEpisodeUrl()); - } - - private static SubtitleSource source(String url) { - return SubtitleSource.of(Sub.create("名字", url, "zh", "application/x-subrip"), "ep"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/playback/TmdbSeasonProgressStoreTest.java b/app/src/test/java/com/fongmi/android/tv/playback/TmdbSeasonProgressStoreTest.java index 00ea50af0b..66b3e21952 100644 --- a/app/src/test/java/com/fongmi/android/tv/playback/TmdbSeasonProgressStoreTest.java +++ b/app/src/test/java/com/fongmi/android/tv/playback/TmdbSeasonProgressStoreTest.java @@ -113,30 +113,6 @@ public class TmdbSeasonProgressStoreTest { assertEquals(List.of(fromA), filtered); } - @Test - public void fromHistoryUsesTheRealNewestOfCreateTimeAndUpdateTime() throws Exception { - History touched = history("tv", 88, 2, 5); - touched.setKey("site-a@@@vod-a"); - touched.setCreateTime(100); - setUpdateTime(touched, 900); - History stale = history("tv", 88, 2, 5); - stale.setKey("site-a@@@vod-a"); - stale.setCreateTime(900); - setUpdateTime(stale, 100); - History untouched = history("tv", 88, 2, 5); - untouched.setKey("site-a@@@vod-a"); - - assertEquals(900, TmdbSeasonProgressStore.fromHistory(touched).updatedAt); - assertEquals(900, TmdbSeasonProgressStore.fromHistory(stale).updatedAt); - assertTrue(TmdbSeasonProgressStore.fromHistory(untouched).updatedAt > 0); - } - - private static void setUpdateTime(History history, long value) throws Exception { - java.lang.reflect.Field field = History.class.getDeclaredField("updateTime"); - field.setAccessible(true); - field.setLong(history, value); - } - private static History history(String mediaType, int tmdbId, int season, int episode) { History history = new History(); history.setMediaType(mediaType); diff --git a/app/src/test/java/com/fongmi/android/tv/player/AudioPlaybackDiagnosticsTest.java b/app/src/test/java/com/fongmi/android/tv/player/AudioPlaybackDiagnosticsTest.java deleted file mode 100644 index ddaee3588f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/AudioPlaybackDiagnosticsTest.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.fongmi.android.tv.player; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; - -import org.junit.Test; - -public class AudioPlaybackDiagnosticsTest { - - @Test - public void formatsDtsHdCoreDowngradeAsCurrentPassthrough() { - AudioPlaybackDiagnostics.Track source = track("DTS-HD MA", 6, 48_000, 4_000_000); - AudioPlaybackDiagnostics.Snapshot snapshot = new AudioPlaybackDiagnostics.Snapshot( - source, source.withCodec("DTS Core"), - AudioPlaybackDiagnostics.DecodeMode.NONE, "", - AudioPlaybackDiagnostics.OutputMode.PASSTHROUGH, - 6, 48_000, false, "dts-hd-core"); - - assertEquals("DTS-HD MA 5.1/降级DTS Core 5.1 · 直通 · 48kHz · 4.0Mbps", - AudioPlaybackDiagnostics.format(snapshot)); - } - - @Test - public void formatsAutomaticStereoTrackDowngradeAndSoftwarePcm() { - AudioPlaybackDiagnostics.Snapshot snapshot = new AudioPlaybackDiagnostics.Snapshot( - track("DTS-HD MA", 6, 48_000, 4_000_000), - track("AAC", 2, 48_000, 192_000), - AudioPlaybackDiagnostics.DecodeMode.SOFTWARE, "aac", - AudioPlaybackDiagnostics.OutputMode.PCM, - 2, 48_000, false, "same-language-stereo"); - - assertEquals("DTS-HD MA 5.1/降级AAC 2.0 · 软解 · PCM 2.0 · 48kHz · 192kbps", - AudioPlaybackDiagnostics.format(snapshot)); - } - - @Test - public void formatsObservedHardwarePcm() { - AudioPlaybackDiagnostics.Track track = track("E-AC-3", 6, 48_000, 768_000); - AudioPlaybackDiagnostics.Snapshot snapshot = new AudioPlaybackDiagnostics.Snapshot( - track, track, AudioPlaybackDiagnostics.DecodeMode.HARDWARE, - "c2.vendor.eac3.decoder", AudioPlaybackDiagnostics.OutputMode.PCM, - 6, 48_000, false, ""); - - assertEquals("E-AC-3 5.1 · 硬解 · PCM 5.1 · 48kHz · 768kbps", - AudioPlaybackDiagnostics.format(snapshot)); - } - - @Test - public void formatsOffloadWithoutClaimingPassthrough() { - AudioPlaybackDiagnostics.Track track = track("E-AC-3", 6, 48_000, 768_000); - AudioPlaybackDiagnostics.Snapshot snapshot = new AudioPlaybackDiagnostics.Snapshot( - track, track, AudioPlaybackDiagnostics.DecodeMode.NONE, "", - AudioPlaybackDiagnostics.OutputMode.OFFLOAD, - 6, 48_000, false, ""); - - assertEquals("E-AC-3 5.1 · 硬解 · 卸载 · 48kHz · 768kbps", - AudioPlaybackDiagnostics.format(snapshot)); - } - - @Test - public void mapsMpvOutputAndDtsCoreTrack() { - AudioPlaybackDiagnostics.Track source = track("DTS-HD MA", 6, 48_000, 0); - - assertEquals(AudioPlaybackDiagnostics.OutputMode.PASSTHROUGH, - AudioPlaybackDiagnostics.mpvOutputMode("spdif-dts")); - assertEquals("DTS Core", - AudioPlaybackDiagnostics.passthroughTrack(source, "spdif-dts").codec()); - assertEquals(AudioPlaybackDiagnostics.OutputMode.PCM, - AudioPlaybackDiagnostics.mpvOutputMode("float")); - assertEquals(AudioPlaybackDiagnostics.OutputMode.COMPRESSED_DIRECT, - AudioPlaybackDiagnostics.mpvOutputMode("aac")); - assertEquals(AudioPlaybackDiagnostics.OutputMode.COMPRESSED_DIRECT, - AudioPlaybackDiagnostics.mpvOutputMode("mp3")); - assertEquals(AudioPlaybackDiagnostics.OutputMode.OFFLOAD, - AudioPlaybackDiagnostics.mpvOutputMode("aac", "offload")); - assertEquals(AudioPlaybackDiagnostics.OutputMode.COMPRESSED_DIRECT, - AudioPlaybackDiagnostics.mpvOutputMode("aac", "compressed-direct")); - } - - @Test - public void mapsConsumerChannelLabels() { - assertEquals("2.0", AudioPlaybackDiagnostics.channelLabel(2)); - assertEquals("5.1", AudioPlaybackDiagnostics.channelLabel(6)); - assertEquals("7.1", AudioPlaybackDiagnostics.channelLabel(8)); - } - - @Test - public void separatesRuntimeStatesFromDecisionLevels() { - AudioPlaybackDiagnostics.Snapshot empty = AudioPlaybackDiagnostics.Snapshot.empty(); - assertEquals(AudioPlaybackDiagnostics.RuntimeState.UNKNOWN, empty.runtimeState()); - assertNull(empty.decisionLevel()); - - AudioPlaybackDiagnostics.Track aac = track("AAC", 2, 48_000, 192_000); - AudioPlaybackDiagnostics.Snapshot pending = new AudioPlaybackDiagnostics.Snapshot( - aac, aac, AudioPlaybackDiagnostics.DecodeMode.UNKNOWN, "", - AudioPlaybackDiagnostics.OutputMode.UNKNOWN, 0, 0, false, ""); - assertEquals(AudioPlaybackDiagnostics.RuntimeState.PENDING, pending.runtimeState()); - assertNull(pending.decisionLevel()); - assertEquals("AAC 2.0 · 输出初始化中 · 48kHz · 192kbps", - AudioPlaybackDiagnostics.format(pending)); - - AudioPlaybackDiagnostics.Snapshot active = new AudioPlaybackDiagnostics.Snapshot( - aac, aac, AudioPlaybackDiagnostics.DecodeMode.HARDWARE, - "c2.vendor.aac.decoder", AudioPlaybackDiagnostics.OutputMode.PCM, - 2, 48_000, false, ""); - assertEquals(AudioPlaybackDiagnostics.RuntimeState.ACTIVE, active.runtimeState()); - assertEquals(AudioPlaybackDiagnostics.DecisionLevel.HARDWARE_PCM, - active.decisionLevel()); - - AudioPlaybackDiagnostics.Snapshot failed = new AudioPlaybackDiagnostics.Snapshot( - aac, aac, AudioPlaybackDiagnostics.DecodeMode.HARDWARE, - "c2.vendor.aac.decoder", AudioPlaybackDiagnostics.OutputMode.UNKNOWN, - 0, 0, false, "", - AudioPlaybackDiagnostics.DecisionLevel.HARDWARE_PCM, - AudioPlaybackDiagnostics.RuntimeState.FAILED, - AudioPlaybackDiagnostics.FailureReason.DECODER_INIT); - assertEquals("AAC 2.0 · 输出失败 · decoder-init · 48kHz · 192kbps", - AudioPlaybackDiagnostics.format(failed)); - } - - @Test - public void mapsDecisionAndStableFailureCodes() { - assertEquals(AudioPlaybackDiagnostics.DecisionLevel.EXACT_PASSTHROUGH, - AudioPlaybackDiagnostics.decisionLevel( - AudioPlaybackDiagnostics.OutputMode.COMPRESSED_DIRECT, - AudioPlaybackDiagnostics.DecodeMode.NONE, "")); - assertEquals(AudioPlaybackDiagnostics.DecisionLevel.COMPRESSED_OFFLOAD, - AudioPlaybackDiagnostics.decisionLevel( - AudioPlaybackDiagnostics.OutputMode.OFFLOAD, - AudioPlaybackDiagnostics.DecodeMode.NONE, "")); - assertEquals(AudioPlaybackDiagnostics.DecisionLevel.SAME_TRACK_COMPATIBLE, - AudioPlaybackDiagnostics.decisionLevel( - AudioPlaybackDiagnostics.OutputMode.PASSTHROUGH, - AudioPlaybackDiagnostics.DecodeMode.NONE, "dts-hd-core")); - assertEquals(AudioPlaybackDiagnostics.DecisionLevel.LANGUAGE_OR_STEREO_FALLBACK, - AudioPlaybackDiagnostics.decisionLevel( - AudioPlaybackDiagnostics.OutputMode.PCM, - AudioPlaybackDiagnostics.DecodeMode.SOFTWARE, - "same-language-stereo")); - assertEquals(AudioPlaybackDiagnostics.FailureReason.DIRECT_OUTPUT_INIT, - AudioPlaybackDiagnostics.failureReason( - androidx.media3.common.PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED)); - assertEquals(AudioPlaybackDiagnostics.FailureReason.OFFLOAD_WRITE, - AudioPlaybackDiagnostics.failureReason( - androidx.media3.common.PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_WRITE_FAILED)); - assertEquals(AudioPlaybackDiagnostics.FailureReason.DECODER_RUNTIME, - AudioPlaybackDiagnostics.failureReason( - androidx.media3.common.PlaybackException.ERROR_CODE_DECODING_FAILED)); - assertEquals(AudioPlaybackDiagnostics.FailureReason.UNKNOWN, - AudioPlaybackDiagnostics.failureReason(123456)); - } - - private static AudioPlaybackDiagnostics.Track track( - String codec, int channels, int sampleRate, int bitrate) { - return new AudioPlaybackDiagnostics.Track(codec, channels, sampleRate, "", bitrate); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/DolbyVisionFormatLabelTest.java b/app/src/test/java/com/fongmi/android/tv/player/DolbyVisionFormatLabelTest.java index 93869a1d46..4bda31876c 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/DolbyVisionFormatLabelTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/DolbyVisionFormatLabelTest.java @@ -31,7 +31,7 @@ public class DolbyVisionFormatLabelTest { @Test public void p81ConversionMarksUpgradeBesideTheSourceCodec() { PlayerEngine.VideoPlaybackDetails details = new PlayerEngine.VideoPlaybackDetails( - "dvhe.07.06", 8, 6, "dvhe.08.06", + "dvhe.07.06", 7, 6, "dvhe.08.06", "c2.qti.hevc.decoder", "", null, false, true); assertEquals("Dolby Vision DV.07", diff --git a/app/src/test/java/com/fongmi/android/tv/player/FelBindProbeControlTest.java b/app/src/test/java/com/fongmi/android/tv/player/FelBindProbeControlTest.java deleted file mode 100644 index 8adc893987..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/FelBindProbeControlTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.fongmi.android.tv.player; - -import org.junit.Test; - -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.List; - -import static org.junit.Assert.*; - -public class FelBindProbeControlTest { - private final ArrayDeque queue = new ArrayDeque<>(); - private final List commands = new ArrayList<>(); - private final FelBindProbeControl control = new FelBindProbeControl("player-1"); - - private void ready() { - control.connect(queue::add, action -> { commands.add(action); return true; }); - control.begin(); - control.observed("0:ready"); - } - private void drain() { while (!queue.isEmpty()) queue.remove().run(); } - - @Test public void explicitRequestOnlyAndExclusive() { - ready(); drain(); assertTrue(commands.isEmpty()); - control.start(); assertTrue(control.active()); assertTrue(commands.isEmpty()); - assertThrows(IllegalStateException.class, control::start); - drain(); assertEquals(List.of("start"), commands); - control.observed("1:completed"); assertFalse(control.active()); - } - - @Test public void queuedStartCannotFollowAnotherMedia() { - ready(); control.start(); control.begin(); control.observed("0:ready"); drain(); - assertTrue(commands.isEmpty()); assertEquals("ready", control.state()); - } - - @Test public void queuedCancellationDoesNotStartNativeWork() { - ready(); control.start(); control.cancel(); drain(); - assertTrue(commands.isEmpty()); assertEquals("cancelled", control.state()); - } - - @Test public void cancellationRemainsExclusiveUntilNativeCleanup() { - ready(); control.start(); drain(); control.observed("1:running"); - control.cancel(); drain(); assertTrue(control.active()); - assertEquals(List.of("start", "cancel"), commands); - assertThrows(IllegalStateException.class, control::start); - control.observed("1:cancelled"); assertFalse(control.active()); - } - - @Test public void staleTerminalCannotFinishNewRequest() { - ready(); control.start(); drain(); control.observed("1:completed"); - control.start(); control.observed("1:completed"); - assertTrue(control.active()); drain(); - control.observed("2:completed"); assertFalse(control.active()); - } - - @Test public void endInvalidatesPostedCommandsAndReadbacks() { - ready(); control.start(); control.end(); drain(); control.observed("1:running"); - assertTrue(commands.isEmpty()); assertEquals("unavailable", control.state()); - } - - @Test public void nativeRejectionIsReportedAndCanRetry() { - ready(); control.connect(queue::add, action -> false); control.start(); drain(); - assertEquals("failed", control.state()); assertFalse(control.active()); - control.start(); assertEquals("queued", control.state()); - } - - @Test public void unknownUnavailableAndMalformedStatesCannotArm() { - control.begin(); control.observed("oops"); control.observed("3:bogus"); - assertThrows(IllegalStateException.class, control::start); - control.observed("-1:ready"); assertEquals("unavailable", control.state()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/IntroSkipActivityWiringTest.java b/app/src/test/java/com/fongmi/android/tv/player/IntroSkipActivityWiringTest.java deleted file mode 100644 index 36c2cfac46..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/IntroSkipActivityWiringTest.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.fongmi.android.tv.player; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertTrue; - -public class IntroSkipActivityWiringTest { - - @Test - public void everyPlaybackActivityInvalidatesIntroSkipOnDestroy() throws Exception { - for (String path : new String[] { - "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java", - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java", - "app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java" - }) { - String source = read(path); - int destroy = source.lastIndexOf("protected void onDestroy()"); - int reset = source.indexOf(path.contains("TmdbDetailActivity") - ? "introSkipPlayback.reset();" : "mIntroSkipPlayback.reset();", destroy); - int parent = source.indexOf("super.onDestroy();", destroy); - assertTrue(path, destroy >= 0 && reset > destroy && reset < parent); - } - } - - @Test - public void confirmationDialogsReportDismissalAndMobileHonorsReverseOrder() throws Exception { - String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - String leanback = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - String detail = read("app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); - - assertTrue(mobile.contains("setOnDismissListener")); - assertTrue(leanback.contains("setOnDismissListener")); - assertTrue(detail.contains("setOnDismissListener")); - assertTrue("手机取消按钮应将当前片段标为本集拒绝", mobile.contains("mIntroSkipPlayback.declineConfirmation(segment)")); - assertTrue("TV 取消按钮应将当前片段标为本集拒绝", leanback.contains("mIntroSkipPlayback.declineConfirmation(segment)")); - assertTrue("内嵌播放取消按钮应将当前片段标为本集拒绝", detail.contains("introSkipPlayback.declineConfirmation(segment)")); - assertTrue(mobile.contains("mHistory.isRevPlay() ? -1 : 1")); - } - - @Test - public void tmdbIntroSkipCallbacksRequireLiveOwnedPlayback() throws Exception { - String detail = read("app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); - int loaded = detail.indexOf("private void onIntroSkipPlanLoaded()"); - int loadedEnd = detail.indexOf("private void preloadAdjacentIntroSkipPlans()", loaded); - int apply = detail.indexOf("private boolean applyAutoIntroSkip()"); - int applyEnd = detail.indexOf("private IntroSkipService.Query buildIntroSkipQuery()", apply); - - assertTrue("TMDB plan callback must reject a dead or borrowed player", - loaded >= 0 && loadedEnd > loaded - && detail.substring(loaded, loadedEnd).contains("isFinishing()") - && detail.substring(loaded, loadedEnd).contains("isOwner()")); - assertTrue("TMDB auto skip must reject a dead or borrowed player", - apply >= 0 && applyEnd > apply - && detail.substring(apply, applyEnd).contains("isFinishing()") - && detail.substring(apply, applyEnd).contains("isOwner()")); - } - - @Test - public void tmdbIntroSkipCallbacksAlsoRequireInlinePlaybackState() throws Exception { - String detail = read("app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); - int loaded = detail.indexOf("private void onIntroSkipPlanLoaded()"); - int loadedEnd = detail.indexOf("private void preloadAdjacentIntroSkipPlans()", loaded); - int apply = detail.indexOf("private boolean applyAutoIntroSkip()"); - int applyEnd = detail.indexOf("private IntroSkipService.Query buildIntroSkipQuery()", apply); - - assertTrue("a late TMDB plan must be ignored outside inline playback", - loaded >= 0 && loadedEnd > loaded - && detail.substring(loaded, loadedEnd).contains("inlineStarted") - && detail.substring(loaded, loadedEnd).contains("player().isReleased()")); - assertTrue("auto skip must be gated by the active inline playback state", - apply >= 0 && applyEnd > apply - && detail.substring(apply, applyEnd).contains("inlineStarted") - && detail.substring(apply, applyEnd).contains("player().isReleased()")); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of(path.substring("app/".length())), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/IntroSkipPlaybackStateTest.java b/app/src/test/java/com/fongmi/android/tv/player/IntroSkipPlaybackStateTest.java deleted file mode 100644 index 7da351b6be..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/IntroSkipPlaybackStateTest.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.fongmi.android.tv.player; - -import com.fongmi.android.tv.service.IntroSkipService; -import com.fongmi.android.tv.service.IntroSkipService.Segment; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class IntroSkipPlaybackStateTest { - - @Test - public void cancelingConfirmationAllowsTheSameSegmentToBeAskedAgain() { - IntroSkipPlayback playback = new IntroSkipPlayback(); - Segment segment = segment(); - - assertTrue(playback.beginConfirmation(segment)); - assertTrue(playback.isConfirmationPending(segment)); - - playback.cancelConfirmation(segment); - - assertFalse(playback.isSegmentHandled(segment)); - assertFalse(playback.isConfirmationPending(segment)); - assertTrue(playback.beginConfirmation(segment)); - } - - @Test - public void oneConfirmationLeaseIsSharedByAllPagesUsingTheSamePlaybackSession() { - Object session = new Object(); - IntroSkipPlayback first = new IntroSkipPlayback(); - IntroSkipPlayback second = new IntroSkipPlayback(); - Segment segment = segment(); - - assertTrue(first.beginConfirmation(session, segment)); - assertTrue("同一播放会话已有确认框时,后续页面必须等待", second.hasConfirmation(session)); - assertFalse("另一个播放页不能为同一会话再弹一个框", second.beginConfirmation(session, segment)); - - first.cancelConfirmation(segment); - - assertFalse(first.hasConfirmation(session)); - assertTrue("前一个框关闭后,当前页面仍可重新询问", second.beginConfirmation(session, segment)); - second.reset(); - assertFalse(second.hasConfirmation(session)); - } - - @Test - public void decliningConfirmationHandlesSegmentUntilReset() { - IntroSkipPlayback playback = new IntroSkipPlayback(); - Segment segment = segment(); - - assertTrue(playback.beginConfirmation(segment)); - playback.declineConfirmation(segment); - - assertTrue(playback.isSegmentHandled(segment)); - assertFalse(playback.isConfirmationPending(segment)); - assertFalse("本集内拒绝后不能再次询问同一片段", playback.beginConfirmation(segment)); - - playback.reset(); - assertFalse(playback.isSegmentHandled(segment)); - assertTrue("换集 reset 后应允许再次询问", playback.beginConfirmation(segment)); - } - - @Test - public void completingConfirmationMarksOnlyThatStableSegmentHandled() { - IntroSkipPlayback playback = new IntroSkipPlayback(); - Segment segment = segment(); - - assertTrue(playback.beginConfirmation(segment)); - playback.completeConfirmation(segment); - - assertTrue(playback.isSegmentHandled(segment)); - assertFalse(playback.isConfirmationPending(segment)); - assertFalse(playback.beginConfirmation(segment)); - } - - @Test - public void unknownTrailingEndCannotAdvanceBeforeDurationIsKnown() { - Segment segment = IntroSkipService.parseTheIntroDb( - "{\"credits\":[{\"start_ms\":1380000,\"end_ms\":null}]}", 0) - .getEndings().get(0); - - assertFalse(IntroSkipPlayback.endsWithFile(segment, 0)); - assertTrue(IntroSkipPlayback.endsWithFile(segment, 1_500_000)); - } - - /** - * 两家 provider 的同类片段不得共享一次性状态。 - * - *

别名归一(credits→outro)只该消解同一家内部的字段名差异。若跨 provider 也折进同一个 - * id,两家对片尾给出的不同边界就共享 {@code skipped}:服务层按时间轴判为两段而各自保留时, - * 跳过其中一段会让另一段被 {@code isSegmentHandled} 永久吞掉,用户再也跳不到真正的片尾。 - */ - @Test - public void differentProvidersDoNotShareSegmentIdentity() { - IntroSkipPlayback playback = new IntroSkipPlayback(); - Segment theIntroDb = IntroSkipService.parseTheIntroDb( - "{\"duration_ms\":2700000,\"credits\":[{\"start_ms\":2640000,\"end_ms\":2695000}]}", 2_700_000) - .getEndings().get(0); - Segment introDb = IntroSkipService.parseIntroDb( - "{\"duration_ms\":2700000,\"outro\":{\"start_ms\":2500000,\"end_ms\":2560000}}", 2_700_000) - .getEndings().get(0); - - playback.beginConfirmation(theIntroDb); - playback.completeConfirmation(theIntroDb); - - assertTrue(playback.isSegmentHandled(theIntroDb)); - assertFalse("另一家 provider 的片尾不能被连带标记为已处理", playback.isSegmentHandled(introDb)); - } - - private static Segment segment() { - return IntroSkipService.parseTheIntroDb( - "{\"intro\":[{\"start_ms\":0,\"end_ms\":45000}]}", 2_700_000) - .getOpenings().get(0); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/LiveActivitySourceFallbackSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/LiveActivitySourceFallbackSourceTest.java deleted file mode 100644 index 3c6f421649..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/LiveActivitySourceFallbackSourceTest.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.fongmi.android.tv.player; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import org.junit.Test; - -public class LiveActivitySourceFallbackSourceTest { - - @Test - public void eachFetchReplacesPreviousBufferingTimeout() throws Exception { - assertFetchTimeoutIsReplaced(Path.of("src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java")); - assertFetchTimeoutIsReplaced(Path.of("src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java")); - } - - @Test - public void sessionGatePreventsConsecutiveSourceChangesOnRepeatedFailure() throws Exception { - for (Path path : List.of( - Path.of("src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"), - Path.of("src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"))) { - String source = read(path); - assertTrue(path.toString(), source.contains("private boolean mFailedThisSession;")); - assertTrue(path.toString(), source.contains("mFailedThisSession = true;")); - assertTrue(path.toString(), source.contains("mFailedThisSession = false;")); - } - } - - @Test - public void failedAttemptIsReleasedOnlyWhenReplacementStartsBuffering() throws Exception { - for (Path path : List.of( - Path.of("src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"), - Path.of("src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"))) { - String source = read(path); - assertTrue(path.toString(), source.contains( - "case Player.STATE_BUFFERING:\n mFailedThisSession = false;")); - assertFalse(path.toString(), source.contains("if (mFailedThisSession) return;")); - assertFalse(path.toString(), source.contains( - "player().stop();\n mFailedThisSession = false;\n resetAdapter();")); - assertFalse(path.toString(), source.contains( - "mFailedThisSession = false;\n clearPendingReload();")); - } - } - - @Test - public void mobileSelectionWaitsForAsyncParseBeforeDecidingFallback() throws Exception { - Path path = Path.of("src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"); - String source = read(path); - String body = section(source, "private void getLive()", "private void renderLive(Live live)"); - - assertTrue(path.toString(), body.contains("Live live = getHome();")); - assertTrue(path.toString(), body.contains("if (!live.getGroups().isEmpty()) renderLive(live);")); - assertTrue(path.toString(), body.contains("mViewModel.parse(live);")); - assertFalse(path.toString(), body.contains("renderLive(getHome());")); - } - - @Test - public void selectingCurrentSourceKeepsRenderedGroupsUntilParseCompletes() throws Exception { - for (Path path : List.of( - Path.of("src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"), - Path.of("src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"))) { - String source = read(path); - assertFalse(path.toString(), source.contains("if (item.isSelected()) item.getGroups().clear();")); - } - } - - private static void assertFetchTimeoutIsReplaced(Path path) throws Exception { - String source = read(path); - String expected = "App.removeCallbacks(mBufferingTimeout);\n" - + " App.post(mBufferingTimeout, LIVE_BUFFERING_TIMEOUT);"; - assertEquals(path.toString(), 2, occurrences(source, expected)); - } - - private static String read(Path path) throws Exception { - // 工作区行尾随平台而变(Windows autocrlf=true 时为 CRLF),归一化后再比对, - // 让多行 contains/occurrences 断言在 LF/CRLF 检出下行为一致(CI ubuntu 与本地 Windows 都能通过)。 - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } - - private static int occurrences(String source, String target) { - int count = 0; - int offset = 0; - while ((offset = source.indexOf(target, offset)) >= 0) { - count++; - offset += target.length(); - } - return count; - } - - private static String section(String source, String start, String end) { - int from = source.indexOf(start); - int to = source.indexOf(end, from); - return from < 0 || to < 0 ? "" : source.substring(from, to); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/LiveSourceFallbackPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/LiveSourceFallbackPolicyTest.java deleted file mode 100644 index fdd111a1ab..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/LiveSourceFallbackPolicyTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.fongmi.android.tv.player; - -import static com.fongmi.android.tv.player.LiveSourceFallbackPolicy.Action.NEXT_LINE; -import static com.fongmi.android.tv.player.LiveSourceFallbackPolicy.Action.NEXT_SOURCE; -import static com.fongmi.android.tv.player.LiveSourceFallbackPolicy.Action.NONE; -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -public class LiveSourceFallbackPolicyTest { - - // ---------- 线路播放失败:decideLineFailure ---------- - - @Test - public void playbackFailurePrefersNextLineOfCurrentChannel() { - assertEquals(NEXT_LINE, LiveSourceFallbackPolicy.decideLineFailure(true, true, true)); - } - - @Test - public void playbackFailureOnTheLastLineStillRollsToTheNextLine() { - // 最后一条线路失败也要绕回换线(绕圈停止由 Activity 的 anchor 保证),绝不直接跳接口。 - assertEquals(NEXT_LINE, LiveSourceFallbackPolicy.decideLineFailure(true, true, true)); - } - - @Test - public void playbackFailureDoesNotJumpToAnotherLiveSource() { - // 播放失败没有任何参数组合可以产生 NEXT_SOURCE:即使接口回退开启且有下一个接口。 - assertEquals(NEXT_LINE, LiveSourceFallbackPolicy.decideLineFailure(true, true, true)); - assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(false, true, true)); - assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(true, false, true)); - assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(true, true, false)); - } - - @Test - public void playbackFailureWithDisabledAutoLineChangeStops() { - assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(false, true, true)); - } - - @Test - public void playbackFailureOnSingleLineChannelStops() { - assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(true, true, false)); - } - - @Test - public void playbackFailureWithoutChannelStops() { - assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(true, false, true)); - } - - // ---------- 节目列表拉取失败:decideSourceFailure ---------- - - @Test - public void sourceListFailureJumpsToNextLiveSourceWhenAvailable() { - assertEquals(NEXT_SOURCE, LiveSourceFallbackPolicy.decideSourceFailure(true, true)); - } - - @Test - public void sourceListFailureStopsWhenNoNextSourceExists() { - assertEquals(NONE, LiveSourceFallbackPolicy.decideSourceFailure(true, false)); - } - - @Test - public void sourceListFailureStopsWhenSourceFallbackDisabled() { - assertEquals(NONE, LiveSourceFallbackPolicy.decideSourceFailure(false, true)); - assertEquals(NONE, LiveSourceFallbackPolicy.decideSourceFailure(false, false)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/MediaBadgeResolverTest.java b/app/src/test/java/com/fongmi/android/tv/player/MediaBadgeResolverTest.java deleted file mode 100644 index 0e6695df6d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/MediaBadgeResolverTest.java +++ /dev/null @@ -1,367 +0,0 @@ -package com.fongmi.android.tv.player; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import androidx.media3.common.C; -import androidx.media3.common.ColorInfo; -import androidx.media3.common.Format; - -import com.fongmi.android.tv.player.MediaBadgeCatalog.Group; -import com.fongmi.android.tv.player.engine.PlayerEngine; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * 徽标解析的 JVM 单测:只覆盖纯逻辑,不加载 Android 框架。 - *

- * 断言分两层:运行时事实(真的在播什么)与文件名事实(片源叫什么)。 - */ -public class MediaBadgeResolverTest { - - @Test - public void fourKHevcHdrKeepsTheDisplayOrder() { - Format video = video(3840, 2160, "video/hevc", 23.976f, 15_000_000, C.COLOR_TRANSFER_ST2084); - List badges = MediaBadgeResolver.resolve(video, null, null, null, null); - - assertEquals(List.of(Group.RESOLUTION, Group.DYNAMIC_RANGE, Group.VIDEO_CODEC, Group.FRAME_RATE, Group.BITRATE), groups(badges)); - assertEquals(Arrays.asList("gr_4k", "gv_hdr10", "ge_hevc", null, null), plates(badges)); - assertEquals(List.of("23.976FPS", "15MBPS"), texts(badges)); - } - - /** 牌自己已经写明了事实时右侧不再重复同一段文字,否则会出现 "4K 4K"。 */ - @Test - public void plateBadgesDoNotRepeatTheirOwnText() { - Format video = video(3840, 2160, "video/hevc", 0f, 0, C.COLOR_TRANSFER_SDR); - assertEquals(List.of(), texts(MediaBadgeResolver.resolve(video, null, null, null, null))); - } - - @Test - public void dolbyVisionReplacesTheHdrBadge() { - PlayerEngine.VideoPlaybackDetails details = new PlayerEngine.VideoPlaybackDetails( - "dvhe.07.06", 8, 6, "dvhe.08.06", "c2.qti.hevc.decoder", "", null, false); - Format video = video(3840, 2160, "video/dolby-vision", 0f, 0, C.COLOR_TRANSFER_ST2084); - List badges = MediaBadgeResolver.resolve(video, details, null, null, null); - - assertEquals(Arrays.asList("gr_4k", "gv_dolby_vision", "ge_hevc"), plates(badges)); - // DV 牌只写 "Dolby Vision",Profile 号仍作为文本保留,信息不丢。 - assertEquals(List.of("P8"), texts(badges)); - assertFalse(plates(badges).contains("gv_hdr10")); - } - - @Test - public void fullHdH264KeepsIntegerFrameRate() { - Format video = video(1920, 1080, "video/avc", 60f, 0, C.COLOR_TRANSFER_SDR); - List badges = MediaBadgeResolver.resolve(video, null, null, null, null); - - assertEquals(Arrays.asList("gr_1080p", "ge_avc", null), plates(badges)); - assertEquals(List.of("60FPS"), texts(badges)); - } - - @Test - public void hlgIsNamedAndSdrIsOmitted() { - Format hlg = video(1920, 1080, "video/hevc", 0f, 0, C.COLOR_TRANSFER_HLG); - Format sdr = video(1920, 1080, "video/hevc", 0f, 0, C.COLOR_TRANSFER_SDR); - - assertEquals(Arrays.asList("gr_1080p", "gv_hlg", "ge_hevc"), plates(MediaBadgeResolver.resolve(hlg, null, null, null, null))); - assertEquals(Arrays.asList("gr_1080p", "ge_hevc"), plates(MediaBadgeResolver.resolve(sdr, null, null, null, null))); - } - - /** 竖屏素材按长边分档,不能被短边压成低清档位。 */ - @Test - public void portraitVideoIsGradedByItsLongSide() { - Format video = video(656, 1280, "video/hevc", 29.97f, 0, C.COLOR_TRANSFER_SDR); - List badges = MediaBadgeResolver.resolve(video, null, null, null, null); - - assertEquals("gr_720p", badges.get(0).plate()); - assertEquals("ge_hevc", badges.get(1).plate()); - assertEquals("29.97FPS", badges.get(2).text()); - } - - /** 低分辨率档位也全部有牌,不能退化成 "256X144" 这种纯文本。 */ - @Test - public void lowResolutionTiersHaveTheirOwnPlates() { - assertEquals("gr_1440p", MediaBadgeResolver.resolve(video(2560, 1440, "video/hevc", 0f, 0, C.COLOR_TRANSFER_SDR), null, null, null, null).get(0).plate()); - assertEquals("gr_576p", MediaBadgeResolver.resolve(video(1024, 576, "video/hevc", 0f, 0, C.COLOR_TRANSFER_SDR), null, null, null, null).get(0).plate()); - assertEquals("gr_480p", MediaBadgeResolver.resolve(video(854, 480, "video/hevc", 0f, 0, C.COLOR_TRANSFER_SDR), null, null, null, null).get(0).plate()); - assertEquals("gr_360p", MediaBadgeResolver.resolve(video(640, 360, "video/hevc", 0f, 0, C.COLOR_TRANSFER_SDR), null, null, null, null).get(0).plate()); - assertEquals("gr_240p", MediaBadgeResolver.resolve(video(426, 240, "video/hevc", 0f, 0, C.COLOR_TRANSFER_SDR), null, null, null, null).get(0).plate()); - assertEquals("gr_144p", MediaBadgeResolver.resolve(video(256, 144, "video/hevc", 0f, 0, C.COLOR_TRANSFER_SDR), null, null, null, null).get(0).plate()); - } - - /** 只显示可证事实:认不出的尺寸宁可写 "长边X短边",也不硬套一个档位牌。 */ - @Test - public void unknownSizeFallsBackToTextWithoutAPlate() { - List badges = MediaBadgeResolver.resolve(video(200, 120, "video/hevc", 0f, 0, C.COLOR_TRANSFER_SDR), null, null, null, null); - assertEquals(null, badges.get(0).plate()); - assertEquals("200X120", badges.get(0).text()); - } - - @Test - public void audioBadgeCarriesCodecAndRateFacts() { - AudioPlaybackDiagnostics.Track audio = new AudioPlaybackDiagnostics.Track("E-AC-3", 6, 48000, "en", 640000); - List badges = MediaBadgeResolver.resolve(null, null, audio, null, null); - - assertEquals(List.of(Group.AUDIO, Group.CHANNELS), groups(badges)); - assertEquals(Arrays.asList("ga_dolby_digital_plus", "gc_5_1"), plates(badges)); - assertEquals(List.of("48KHZ 640KBPS"), texts(badges)); - } - - @Test - public void audioBadgeOmitsFactsThatAreMissing() { - AudioPlaybackDiagnostics.Track audio = new AudioPlaybackDiagnostics.Track("AAC", 2, 0, "", 0); - List badges = MediaBadgeResolver.resolve(null, null, audio, null, null); - - assertEquals(Arrays.asList("ga_aac", "gc_2_0"), plates(badges)); - assertEquals(List.of(), texts(badges)); - } - - @Test - public void audioBadgeWithoutAPlateKeepsTheCodecAsText() { - AudioPlaybackDiagnostics.Track audio = new AudioPlaybackDiagnostics.Track("Vorbis", 2, 0, "", 0); - List badges = MediaBadgeResolver.resolve(null, null, audio, null, null); - - assertEquals(Arrays.asList((String) null, "gc_2_0"), plates(badges)); - assertEquals(List.of("Vorbis"), texts(badges)); - } - - /** media3 对 MP4 里的 AAC 上报 RFC 6381 串 mp4a.40.2,徽标必须归一成品牌名。 */ - @Test - public void mp4aCodecIsShownAsAac() { - AudioPlaybackDiagnostics.Track audio = new AudioPlaybackDiagnostics.Track("mp4a.40.2", 2, 44100, "", 64000); - List badges = MediaBadgeResolver.resolve(null, null, audio, null, null); - - assertEquals("ga_aac", badges.get(0).plate()); - assertEquals(List.of("44.1KHZ 64KBPS"), texts(badges)); - } - - @Test - public void containerComesFromTheMimeType() { - Format video = new Format.Builder().setContainerMimeType("video/x-matroska").build(); - List badges = MediaBadgeResolver.resolve(video, null, null, null, null); - - assertEquals(List.of("MKV"), texts(badges)); - assertEquals(List.of(Group.CONTAINER), groups(badges)); - } - - @Test - public void streamingManifestNeverProducesAContainerBadge() { - Format video = new Format.Builder().setContainerMimeType("application/x-mpegURL").build(); - - assertEquals(List.of(), texts(MediaBadgeResolver.resolve(video, null, null, "https://example.com/list/index.m3u8", null))); - assertEquals(List.of(), texts(MediaBadgeResolver.resolve(null, null, null, "https://example.com/list/index.mpd", null))); - } - - @Test - public void missingMimeTypeFallsBackToTheFileExtension() { - assertEquals(List.of("MKV"), texts(MediaBadgeResolver.resolve(null, null, null, "https://example.com/movie.mkv", null))); - assertEquals(List.of(), texts(MediaBadgeResolver.resolve(null, null, null, "https://example.com/play/123456", null))); - assertEquals(List.of("MKV"), texts(MediaBadgeResolver.resolve(null, null, null, "https://example.com/movie.mkv?token=abc.ts", null))); - } - - @Test - public void emptyFactsProduceNoBadges() { - assertEquals(List.of(), MediaBadgeResolver.resolve(null, null, null, null, null)); - assertEquals(List.of(), MediaBadgeResolver.resolve(new Format.Builder().build(), - PlayerEngine.VideoPlaybackDetails.empty(), AudioPlaybackDiagnostics.Track.empty(), "", "")); - } - - @Test - public void duplicateFactsAreCollapsed() { - Format video = video(3840, 2160, "dvhe.07.06", 0f, 0, C.COLOR_TRANSFER_SDR); - PlayerEngine.VideoPlaybackDetails details = new PlayerEngine.VideoPlaybackDetails( - "dvhe.07.06", 7, 6, "dvhe.07.06", "c2.qti.hevc.decoder", "", null, false); - List badges = MediaBadgeResolver.resolve(video, details, null, null, null); - - assertEquals(Arrays.asList("gr_4k", "gv_dolby_vision", "ge_hevc"), plates(badges)); - assertEquals(List.of("P7"), texts(badges)); - } - - /** - * 文件名事实:发布来源与画质只能由片源名证明,运行时事实给不了。 - * 这是「全量实现参考徽标」的核心用例。 - */ - @Test - public void fileNameSuppliesSourceAndQualityPlates() { - String url = "https://example.com/Movie.2024.2160p.WEB-DL.DV.HDR10+.DDP5.1.Atmos.HEVC-FraMeSToR.mkv"; - List badges = MediaBadgeResolver.resolve(null, null, null, url, null); - List plates = plates(badges); - - assertTrue(plates.contains("gq_web_dl")); - assertTrue(plates.contains("ga_dolby_atmos_digital_plus")); - // 运行时事实缺失时,文件名里写明的分辨率/编码/声道也要补上。 - assertTrue(plates.contains("gr_4k")); - assertTrue(plates.contains("ge_hevc")); - assertTrue(plates.contains("gc_5_1")); - // 非运行时组每个分组最多一枚,不会把整行 OSD 塞满。 - assertFalse(plates.contains("gr_1080p")); - } - - @Test - public void remuxBluRayAndSpecialTagsComeFromTheFileName() { - // REMUX 牌靠发布组名辨认,与参考实现一致:没有已知发布组就只剩 Remux 画质牌。 - String url = "https://example.com/Movie.2024.2160p.UHD.BluRay.REMUX.HDR10.DTS-X.TrueHD.7.1.Atmos.PROPER-FraMeSToR.mkv"; - List badges = MediaBadgeResolver.resolve(null, null, null, url, null); - List plates = plates(badges); - - assertTrue(plates.contains("gms_remux_1")); - assertTrue(plates.contains("gst_proper")); - assertTrue(plates.contains("gc_7_1")); - assertEquals(1, countGroup(badges, Group.SPECIAL_TAG)); - assertEquals(1, countGroup(badges, Group.MEDIA_SOURCE)); - } - - @Test - public void streamingPlatformIsRecognisedFromTheReleaseName() { - String url = "https://example.com/Anime.S02E03.1080p.CR.WEB-DL.AAC2.0.H.264.mkv"; - List badges = MediaBadgeResolver.resolve(null, null, null, url, null); - - assertTrue(plates(badges).contains("gs_crunchyroll")); - } - - @Test - public void imaxEnhancedIsRecognisedFromTheTitle() { - List badges = MediaBadgeResolver.resolve(null, null, null, - "https://example.com/play/9988", "IMAX Enhanced 演示片"); - assertTrue(plates(badges).contains("imax_imax_enhanced")); - } - - /** 运行时事实优先:真的在播 1080p 时,不能因为文件名写了 2160p 就显示 4K。 */ - @Test - public void runtimeFactsWinOverTheFileName() { - String url = "https://example.com/Movie.2024.2160p.WEB-DL.HEVC.mkv"; - Format video = video(1920, 1080, "video/avc", 0f, 0, C.COLOR_TRANSFER_SDR); - List plates = plates(MediaBadgeResolver.resolve(video, null, null, url, null)); - - assertTrue(plates.contains("gr_1080p")); - assertFalse(plates.contains("gr_4k")); - assertTrue(plates.contains("ge_avc")); - assertFalse(plates.contains("ge_hevc")); - } - - /** 普通直链不带任何版本标签时,只出运行时事实,不凭空编造来源牌。 */ - @Test - public void plainUrlAddsNoSourcePlates() { - List badges = MediaBadgeResolver.resolve( - video(1920, 1080, "video/hevc", 0f, 0, C.COLOR_TRANSFER_SDR), null, null, - "https://example.com/path/plain/index.m3u8", null); - - assertEquals(2, badges.size()); - assertEquals(Arrays.asList("gr_1080p", "ge_hevc"), plates(badges)); - } - - - /** 扩充包:8K 分辨率、12bit 位深、VVC 编码都来自播放器运行时事实。 */ - @Test - public void expansionRuntimeFactsAreRecognised() { - Format video = new Format.Builder() - .setWidth(7680) - .setHeight(4320) - .setCodecs("video/vvc") - .setColorInfo(new ColorInfo.Builder().setColorTransfer(C.COLOR_TRANSFER_SDR).setLumaBitdepth(12).setChromaBitdepth(12).build()) - .build(); - List plates = plates(MediaBadgeResolver.resolve(video, null, null, null, null)); - - assertTrue(plates.contains("gr_8k")); - assertFalse(plates.contains("gr_4k")); - assertTrue(plates.contains("gv_12bit")); - assertTrue(plates.contains("ge_vvc")); - } - - /** 扩充包:中文版别、HDR Vivid、3D、高码率、ISO 原盘镜像只由片源名证明。 */ - @Test - public void expansionFileNameFactsAreRecognised() { - List edition = plates(MediaBadgeResolver.resolve(null, null, null, - "https://example.com/Movie.2024.港版.1080p.BluRay.mkv", null)); - assertTrue(edition.contains("ged_hk")); - assertEquals(1, countGroup(MediaBadgeResolver.resolve(null, null, null, - "https://example.com/Movie.2024.港版.台版.1080p.mkv", null), Group.EDITION)); - - // 非运行时分组每组只留一枚,HDR Vivid 排在 3D 之前,同一文件名里只出前者。 - List videoTech = plates(MediaBadgeResolver.resolve(null, null, null, - "https://example.com/Movie.2024.HDR.Vivid.3D.1080p.mkv", null)); - assertTrue(videoTech.contains("gvf_hdrvivid")); - assertFalse(videoTech.contains("gvf_3d")); - assertTrue(plates(MediaBadgeResolver.resolve(null, null, null, - "https://example.com/Movie.2024.3D.1080p.mkv", null)).contains("gvf_3d")); - - assertTrue(plates(MediaBadgeResolver.resolve(null, null, null, - "https://example.com/Movie.2024.高码率.1080p.mkv", null)).contains("gms_high_bitrate")); - assertTrue(plates(MediaBadgeResolver.resolve(null, null, null, - "https://example.com/Movie.2024.1080p.ISO", null)).contains("gms_iso")); - } - - /** 扩充包:LPCM / 无损与高度声道只能靠文件名兜底,运行时 Track 给不出这些事实。 */ - @Test - public void expansionAudioFactsComeFromTheFileName() { - List lossless = plates(MediaBadgeResolver.resolve(null, null, null, - "https://example.com/Movie.2024.1080p.LPCM.2.0.mkv", null)); - assertTrue(lossless.contains("ga_lpcm")); - - List heightChannels = plates(MediaBadgeResolver.resolve(null, null, null, - "https://example.com/Movie.2024.1080p.TrueHD.7.1.4.Atmos.mkv", null)); - assertTrue(heightChannels.contains("gc_7_1_4")); - assertFalse(heightChannels.contains("gc_7_1")); - } - - /** 全表自检:每一条牌的图形键都能在 Catalog 里找到,且运行时标记与分组一致。 */ - @Test - public void catalogIsConsistent() { - MediaBadgeCatalog.Rule[] rules = MediaBadgeCatalog.rules(); - assertEquals(127, rules.length); - assertTrue(MediaBadgeCatalog.patterns().length == rules.length); - List keys = new ArrayList<>(); - for (MediaBadgeCatalog.Rule rule : rules) { - keys.add(rule.plate()); - boolean runtimeGroup = rule.group() == Group.RESOLUTION || rule.group() == Group.DYNAMIC_RANGE - || rule.group() == Group.VIDEO_CODEC || rule.group() == Group.AUDIO || rule.group() == Group.CHANNELS; - assertEquals(rule.plate(), runtimeGroup, rule.runtime()); - } - assertEquals(keys.size(), keys.stream().distinct().count()); - } - - private static int countGroup(List badges, Group group) { - int count = 0; - for (MediaBadgeResolver.MediaBadge badge : badges) if (badge.group() == group) count++; - return count; - } - - /** 牌已写明的空文本不参与断言。 */ - private static List texts(List badges) { - List values = new ArrayList<>(); - for (MediaBadgeResolver.MediaBadge badge : badges) { - if (badge.text() == null || badge.text().isEmpty()) continue; - values.add(badge.text()); - } - return values; - } - - private static List plates(List badges) { - List values = new ArrayList<>(); - for (MediaBadgeResolver.MediaBadge badge : badges) values.add(badge.plate()); - return values; - } - - private static List groups(List badges) { - List values = new ArrayList<>(); - for (MediaBadgeResolver.MediaBadge badge : badges) values.add(badge.group()); - return values; - } - - private static Format video(int width, int height, String codecs, float frameRate, int bitrate, int colorTransfer) { - Format.Builder builder = new Format.Builder() - .setWidth(width) - .setHeight(height) - .setCodecs(codecs) - .setColorInfo(new ColorInfo.Builder().setColorTransfer(colorTransfer).build()); - // media3 的 setFrameRate 只接受正数或 NO_VALUE;0 表示测试里没有帧率事实。 - if (frameRate > 0) builder.setFrameRate(frameRate); - if (bitrate > 0) builder.setAverageBitrate(bitrate); - return builder.build(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/MpvOutputShutterSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/MpvOutputShutterSourceTest.java index 21aac232a4..5c565197c0 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/MpvOutputShutterSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/MpvOutputShutterSourceTest.java @@ -13,8 +13,8 @@ import static org.junit.Assert.assertTrue; /** * The automatic MPV output shutter hides the direct probe frame before a GPU * rebuild. That frame only exists when automatic output can actually reach - * surface direct, so when the per-device zero-copy guard blocks direct output - * the shutter would only delay the first frame while audio already plays. + * surface direct, so while the stability guard pins automatic mode to GPU the + * shutter would only delay the first frame while audio already plays. */ public class MpvOutputShutterSourceTest { @@ -33,26 +33,6 @@ public class MpvOutputShutterSourceTest { method.contains("!mpvAutoOutputProbeGaveUp")); } - /** - * 快门必须与 {@code resolveSurfaceDirect} 同口径,否则会在手机版上白等。 - * - *

{@code isAutoSurfaceDirectEnabled()} 只回答「设备是否被零拷贝保护拦住」, - * 它**不**包含电视版与硬解两个条件;而 {@code resolveSurfaceDirect()} 要求 - * {@code leanback && autoEligible} 才可能返回 true。手机版上前者为真、后者恒假, - * 快门因此会遮住一个永远不会出现的探测帧——声音先出来、画面晚几秒, - * 正是 {@code 6a458ec11c} 修掉的回归(该提交当时靠全局开关恒 false 才遮住)。 - */ - @Test - public void shutterAsksTheSameGatesAsTheDirectOutputDecision() throws Exception { - String method = methodBody(readPlayerManager(), - "public boolean shouldKeepVideoShutterClosed()", "public boolean isExo()"); - - assertTrue("phones never reach surface direct, so the shutter must not close there", - method.contains("Util.isLeanback()")); - assertTrue("a software-decoded session cannot reach surface direct either", - method.contains("engine.isHard()")); - } - @Test public void newItemClosesShutterOnlyWhenTheShutterPolicyAgrees() throws Exception { String method = methodBody(readPlayerManager(), diff --git a/app/src/test/java/com/fongmi/android/tv/player/MpvStabilityGuardSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/MpvStabilityGuardSourceTest.java index b7f5713ebf..80ce0d6005 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/MpvStabilityGuardSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/MpvStabilityGuardSourceTest.java @@ -13,7 +13,7 @@ import static org.junit.Assert.assertTrue; public class MpvStabilityGuardSourceTest { @Test - public void runtimeAutoEvaluationAppliesDeviceGuardBeforeTransition() throws Exception { + public void runtimeAutoEvaluationAppliesStabilityGuardBeforeTransition() throws Exception { String method = methodBody(readPlayerManager(), "private boolean evaluateMpvAutoOutput()", "private boolean hasRequestedSubtitle"); assertTrue(method.contains("boolean effectiveEligible = MpvPerformanceSetting.isAutoSurfaceDirectEnabled() && decision.eligible();")); @@ -21,31 +21,12 @@ public class MpvStabilityGuardSourceTest { } @Test - public void stickyAutoDirectRequiresDeviceGuardPermission() throws Exception { + public void stickyAutoDirectRequiresStabilityGuardPermission() throws Exception { String method = methodBody(readPlayerManager(), "private void prepareMpvOutputForNewItem()", "private void resetMpvOutputRuntime()"); assertTrue(method.contains("automaticOutput && MpvPerformanceSetting.isAutoSurfaceDirectEnabled()")); } - /** - * 全局暂停开关必须保持删除状态。 - * - *

该开关(`SURFACE_AUTO_STABILITY_GUARD_ENABLED`)会让自动档在电视上永久退化为 - * GPU 合成 + 拷贝,与 22f2333317「生产环境保留自动直出」的结论相反;只要它被重新 - * 引入,自动直出就会再次静默失效,因此这里做源码级回归保护。 - */ - @Test - public void automaticSurfaceDirectIsNotPermanentlyPausedByAGlobalSwitch() throws Exception { - String source = readMpvPerformanceSetting(); - - assertFalse("a global pause switch silently disables automatic TV direct output", - source.contains("SURFACE_AUTO_STABILITY_GUARD_ENABLED")); - String method = methodBody(source, - "public static boolean isAutoSurfaceDirectEnabled()", "public static boolean isZeroCopyBlocked()"); - assertTrue("the only remaining veto must be the per-device zero-copy guard", - method.contains("return !isZeroCopyBlocked();")); - } - @Test public void engineOverrideCannotBypassZeroCopyDeviceGuard() throws Exception { String method = methodBody(readMpvPlayerEngine(), "private MpvPlayerConfig buildConfig()", "private void applySoftDecodeOptions"); @@ -93,13 +74,6 @@ public class MpvStabilityGuardSourceTest { if (!Files.exists(source)) source = root.resolve(Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "player", "engine", "MpvPlayerEngine.java")); return Files.readString(source, StandardCharsets.UTF_8).replace("\r\n", "\n"); } - private static String readMpvPerformanceSetting() throws IOException { - Path root = Path.of("").toAbsolutePath(); - Path source = root.resolve(Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "setting", "MpvPerformanceSetting.java")); - if (!Files.exists(source)) source = root.resolve(Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "setting", "MpvPerformanceSetting.java")); - return Files.readString(source, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } - private static String readPlayerManager() throws IOException { Path root = Path.of("").toAbsolutePath(); Path source = root.resolve(Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "player", "PlayerManager.java")); diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlaybackDiagnosticSessionTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlaybackDiagnosticSessionTest.java deleted file mode 100644 index a0f9652145..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/PlaybackDiagnosticSessionTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.fongmi.android.tv.player; - -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import org.junit.Test; -import java.util.ArrayList; -import java.util.List; -import static org.junit.Assert.*; - -public class PlaybackDiagnosticSessionTest { - @Test public void readyAndFirstFrameRemainControllerSignals() { - Recorder recorder = new Recorder(); - PlaybackDiagnosticSession session = new PlaybackDiagnosticSession(recorder); - session.begin("p-a-1", 100); - session.stage("prepare", "player=0 decode=1", 110); - session.stage("ready", "player=0", 120); - session.stage("first-frame", "source=mpv-playback-restart player=0", 130); - JsonObject last = recorder.last(); - assertEquals("play.lifecycle", last.get("event").getAsString()); - assertEquals("mpv-playback-restart", last.getAsJsonObject("observed").getAsJsonObject("signalSource").get("value").getAsString()); - for (String field : List.of("physicalVideo", "audibility")) { - JsonObject fact = last.getAsJsonObject("observed").getAsJsonObject(field); - assertEquals("not-observable", fact.get("status").getAsString()); - assertFalse(fact.has("value")); - } - session.end("clear"); - assertEquals("unknown", recorder.last().getAsJsonObject("observed").getAsJsonObject("outcome").get("status").getAsString()); - } - - @Test public void enablingLateAndClearingProduceNewCaptureBaselines() { - Recorder recorder = new Recorder(); recorder.enabled = false; - PlaybackDiagnosticSession session = new PlaybackDiagnosticSession(recorder); - session.begin("p-a-1", 100); session.stage("request", "player=0", 100); - assertTrue(recorder.events.isEmpty()); - recorder.enabled = true; - session.stage("ready", "player=0", 200); - assertTrue(recorder.events.get(0).getAsJsonObject("observed").getAsJsonObject("captureStartedLate").get("value").getAsBoolean()); - recorder.epoch++; - session.stage("first-frame", "source=media3", 300); - assertEquals(2, recorder.events.stream().filter(e -> e.get("event").getAsString().equals("diag.session.begin")).count()); - } - - @Test public void controllerInstancesAndMediaGenerationsStaySeparate() { - Recorder recorder = new Recorder(); - PlaybackDiagnosticSession first = new PlaybackDiagnosticSession(recorder), second = new PlaybackDiagnosticSession(recorder); - first.begin("p-a-1", 10); - String instance1 = recorder.last().get("playerInstanceId").getAsString(); - second.begin("p-a-2", 20); - String instance2 = recorder.last().get("playerInstanceId").getAsString(); - assertNotEquals(instance1, instance2); - first.begin("p-a-3", 30); - assertEquals("p-a-3", recorder.last().get("trace").getAsString()); - assertEquals(2, recorder.last().get("mediaGeneration").getAsLong()); - JsonObject previousEnd = recorder.events.get(recorder.events.size() - 2); - assertEquals("play.attempt.end", previousEnd.get("event").getAsString()); - assertEquals("p-a-1", previousEnd.get("trace").getAsString()); - } - - @Test public void repeatedPrepareCreatesSeparateDiagnosticAttempts() { - Recorder recorder = new Recorder(); PlaybackDiagnosticSession session = new PlaybackDiagnosticSession(recorder); - session.begin("p-a-1", 10); - session.stage("prepare", "player=0 decode=1", 20); - session.stage("prepare", "player=0 decode=1", 30); - assertEquals(2, recorder.last().get("attemptId").getAsLong()); - assertEquals(1, recorder.events.stream().filter(e -> e.get("event").getAsString().equals("play.attempt.end")).count()); - } - - private static final class Recorder implements PlaybackDiagnosticSession.Sink { - final List events = new ArrayList<>(); - boolean enabled = true; - long epoch = 1; - public boolean enabled() { return enabled; } - public long generation() { return epoch; } - public void emit(DiagnosticEvent event) { events.add(JsonParser.parseString(event.json()).getAsJsonObject()); } - JsonObject last() { return events.get(events.size() - 1); } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlaybackErrorClassifierTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlaybackErrorClassifierTest.java index 499ed35cac..91418a0e20 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/PlaybackErrorClassifierTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/PlaybackErrorClassifierTest.java @@ -1,7 +1,6 @@ package com.fongmi.android.tv.player; import androidx.media3.common.PlaybackException; -import androidx.media3.common.util.StuckPlayerException; import androidx.media3.mpvplayer.MpvPlayer; import android.os.Bundle; @@ -10,7 +9,6 @@ import org.junit.Test; import java.io.IOException; import java.net.ConnectException; -import java.net.SocketTimeoutException; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -109,52 +107,6 @@ public class PlaybackErrorClassifierTest { assertFalse(summary.contains("token")); } - @Test - public void typedPlaybackStall_isNeitherNetworkNorUnprovenAudioFailure() { - PlaybackRoute.Resolution route = PlaybackRoute.resolve("https://cdn.example/video"); - for (int type : new int[]{StuckPlayerException.STUCK_PLAYING_NO_PROGRESS, - StuckPlayerException.STUCK_BUFFERING_NO_PROGRESS, - StuckPlayerException.STUCK_PLAYING_NOT_ENDING}) { - PlaybackErrorClassifier.Failure failure = PlaybackErrorClassifier.classify( - error("timeout", new IllegalStateException(new StuckPlayerException(type, 10_000)), - PlaybackException.ERROR_CODE_TIMEOUT), route); - assertEquals(PlaybackErrorClassifier.Stage.UNKNOWN, failure.stage()); - assertEquals("playback-stuck", failure.evidence()); - } - } - - @Test - public void untypedTimeout_preservesLegacyIjkAndNetworkClassification() { - PlaybackRoute.Resolution route = PlaybackRoute.resolve("https://cdn.example/video"); - assertEquals(PlaybackErrorClassifier.Stage.NETWORK_IO, PlaybackErrorClassifier.classify( - error("timeout", null, PlaybackException.ERROR_CODE_TIMEOUT), route).stage()); - assertEquals(PlaybackErrorClassifier.Stage.NETWORK_IO, PlaybackErrorClassifier.classify( - error("timeout", new SocketTimeoutException(), PlaybackException.ERROR_CODE_TIMEOUT), route).stage()); - assertEquals(PlaybackErrorClassifier.Stage.NETWORK_IO, PlaybackErrorClassifier.classify( - error("Player stuck", null, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT), route).stage()); - } - - @Test - public void transientSourceFailuresAreSeparatedFromUnsupportedContent() { - // 取不到流(网络/本机端点/manifest 空体)→ 重试本项、不换内核。 - assertEquals(true, PlaybackErrorClassifier.isTransientSourceFailure( - PlaybackErrorClassifier.Stage.NETWORK_IO, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS)); - assertEquals(true, PlaybackErrorClassifier.isTransientSourceFailure( - PlaybackErrorClassifier.Stage.LOCAL_ENDPOINT, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED)); - assertEquals(true, PlaybackErrorClassifier.isTransientSourceFailure( - PlaybackErrorClassifier.Stage.MEDIA_PARSING, PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED)); - // 内容能力边界(容器不支持/损坏、编码器失败)必须保留换核机会。 - assertEquals(false, PlaybackErrorClassifier.isTransientSourceFailure( - PlaybackErrorClassifier.Stage.MEDIA_PARSING, PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED)); - assertEquals(false, PlaybackErrorClassifier.isTransientSourceFailure( - PlaybackErrorClassifier.Stage.MEDIA_PARSING, PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED)); - assertEquals(false, PlaybackErrorClassifier.isTransientSourceFailure( - PlaybackErrorClassifier.Stage.MEDIA_PARSING, PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED)); - assertEquals(false, PlaybackErrorClassifier.isTransientSourceFailure( - PlaybackErrorClassifier.Stage.DECODER, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED)); - assertEquals(false, PlaybackErrorClassifier.isTransientSourceFailure(null, PlaybackException.ERROR_CODE_UNSPECIFIED)); - } - private static PlaybackException error(String message, Throwable cause, int code) { return new TestPlaybackException(message, cause, code); } diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlaybackExperimentPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlaybackExperimentPolicyTest.java index 461e8b909e..cc5243add2 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/PlaybackExperimentPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/PlaybackExperimentPolicyTest.java @@ -59,8 +59,6 @@ public class PlaybackExperimentPolicyTest { assertTrue(state.allows( PlaybackExperimentPolicy.Action.EXO_AUTO_PRELOAD)); - assertTrue(state.allows( - PlaybackExperimentPolicy.Action.EXO_SHORT_DRAMA_QUEUE)); assertTrue(state.allows( PlaybackExperimentPolicy.Action.MPV_HLS_RUNTIME_RELOAD)); assertTrue(state.allows( diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlaybackOwnershipKeysTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlaybackOwnershipKeysTest.java deleted file mode 100644 index 37b46470e2..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/PlaybackOwnershipKeysTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.fongmi.android.tv.player; - -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; - -public class PlaybackOwnershipKeysTest { - - private static final String HISTORY = "php_\u84dd\u8393@@@7694249839906786328@@@3"; - private static final String EPISODE = "VE:" + HISTORY + "|\u84dd\u8393\u1d38\u1d50\u00b7\u6807\u6e05|3"; - - @Test - public void identicalKeysBelongToTheSameSession() { - assertTrue(PlaybackOwnershipKeys.sameSession(HISTORY, HISTORY)); - assertTrue(PlaybackOwnershipKeys.sameSession(EPISODE, EPISODE)); - } - - @Test - public void episodeKeyBelongsToItsHistorySession() { - assertTrue("the browse-next episode key must stay the same playback session", - PlaybackOwnershipKeys.sameSession(HISTORY, EPISODE)); - assertTrue(PlaybackOwnershipKeys.sameSession(EPISODE, HISTORY)); - } - - @Test - public void episodeKeyIsNormalisedToItsHistoryKey() { - assertEquals(HISTORY, PlaybackOwnershipKeys.sessionKeyOf(EPISODE)); - assertEquals(HISTORY, PlaybackOwnershipKeys.sessionKeyOf(HISTORY)); - } - - @Test - public void anotherVodOrSiteIsNotTheSameSession() { - assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, "php_other@@@7694249839906786328@@@3")); - assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, "VE:php_other@@@7694249839906786328@@@3|flag|3")); - assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, HISTORY + "0")); - } - - @Test - public void episodeKeyForAnotherVodIsNotTheSameSession() { - String other = "VE:php_other@@@1@@@2|flag|3"; - assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, other)); - } - - @Test - public void barePrefixIsNotAcceptedAsTheSameSession() { - // 裸前缀匹配是错的:php_a 不能吃掉 php_ab 的会话。 - assertFalse(PlaybackOwnershipKeys.sameSession("VE:php_a", "VE:php_ab|flag|3")); - assertFalse(PlaybackOwnershipKeys.sameSession("php_a", "VE:php_ab|flag|3")); - } - - @Test - public void missingExpectedSideNeverReportsMismatch() { - // 既有语义:expected 为空 = 不限制归属;actual 为空但 expected 非空必须判否。 - assertTrue(PlaybackOwnershipKeys.sameSession(null, EPISODE)); - assertTrue(PlaybackOwnershipKeys.sameSession("", HISTORY)); - assertTrue(PlaybackOwnershipKeys.sameSession("", "")); - assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, null)); - assertFalse(PlaybackOwnershipKeys.sameSession(EPISODE, null)); - } - - @Test - public void recordKeyContainingSeparatorIsStillReversible() { - // 记录键(SiteKey@@@vodId@@@cid)本身不含 '|';线路名里出现 '|' 是这里的边界情况: - // 末两段被当作「线路名 + 集序号」,左侧整体即记录键。 - String historyKey = "php_x@@@1@@@2"; - String episodeKey = "VE:" + historyKey + "|a|b|7"; - assertEquals(historyKey + "|a", PlaybackOwnershipKeys.sessionKeyOf(episodeKey)); - } - - @Test - public void malformedEpisodeKeyStaysItsOwnKey() { - assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, "VE:" + HISTORY)); - assertNull(PlaybackOwnershipKeys.historyKeyOfEpisode("VE:" + HISTORY)); - // 结构不完整时退回原字符串,绝不能因为无法解析就把两个不同会话判成同一会话。 - assertEquals("VE:" + HISTORY, PlaybackOwnershipKeys.sessionKeyOf("VE:" + HISTORY)); - assertEquals("VE:|x|1", PlaybackOwnershipKeys.sessionKeyOf("VE:|x|1")); - } - - @Test - public void blankAndNonEpisodeKeysAreTheirOwnSession() { - assertNull(PlaybackOwnershipKeys.sessionKeyOf(null)); - assertNull(PlaybackOwnershipKeys.sessionKeyOf("")); - assertEquals("VP:php@@@1@@@2", PlaybackOwnershipKeys.sessionKeyOf("VP:php@@@1@@@2")); - assertNull(PlaybackOwnershipKeys.historyKeyOfEpisode("VP:php@@@1@@@2")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlaybackResourceClassifierTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlaybackResourceClassifierTest.java index 8b1cbbc0f5..c83b1f1129 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/PlaybackResourceClassifierTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/PlaybackResourceClassifierTest.java @@ -62,14 +62,6 @@ public class PlaybackResourceClassifierTest { PlaybackResourceClassifier.classifyRequest("https://host/media", "hls", "dash").protocol()); } - @Test - public void hlsUrlCompatibilityRecognizesLiveAliases() { - assertTrue(PlaybackResourceClassifier.isHlsUrl("https://example.test/play?type=hls&id=1")); - assertTrue(PlaybackResourceClassifier.isHlsUrl("https://example.test/live/stream?id=1")); - assertTrue(PlaybackResourceClassifier.isHlsUrl("https://example.test/tv/live.php?id=1")); - assertFalse(PlaybackResourceClassifier.isHlsUrl("https://example.test/video.mp4")); - } - @Test public void hlsVodRecordsEndListAndSegmentEvidence() { String playlist = "#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\npart-1.ts\n#EXT-X-ENDLIST\n"; diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlaybackStartupPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlaybackStartupPolicyTest.java index ca6c208030..cc5b5c4370 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/PlaybackStartupPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/PlaybackStartupPolicyTest.java @@ -12,8 +12,7 @@ public class PlaybackStartupPolicyTest { } @Test - public void mpvVideoRequiresFrameEvidenceInAdditionToReady() { - assertEquals(PlaybackStartupPolicy.Completion.NONE, PlaybackStartupPolicy.resolve(true, false, true, true)); + public void mpvReadyMapsPlaybackRestartToFirstFrame() { assertEquals(PlaybackStartupPolicy.Completion.FIRST_FRAME, PlaybackStartupPolicy.resolve(true, true, true, true)); } diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerLifecycleSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerLifecycleSourceTest.java index 04e89160a4..9091e77e44 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerLifecycleSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerLifecycleSourceTest.java @@ -29,6 +29,25 @@ public class PlayerManagerLifecycleSourceTest { source.contains("public boolean isVod() {\n return engine != null && engine.isVod();")); } + @Test + public void bufferingStallMustNotHijackAManualKernelSwitch() throws Exception { + String source = readPlayerManager(); + String body = methodBody(source, "private void onBufferingStall("); + // Assert the telemetry reason rather than just the flag name: the reason string only + // exists inside the correct branch, so inverting the condition cannot keep it. + assertTrue("onBufferingStall must report a manual switch instead of auto-falling back", + body.contains("manual-switch-stall")); + assertTrue("the manual-switch branch must be gated on the pending flag", + body.contains("manualPlayerSwitchPending")); + } + + @Test + public void newMediaItemCancelsTheStallWatchdog() throws Exception { + String body = methodBody(readPlayerManager(), "private void setMediaItemNow("); + assertTrue("a new media item must invalidate the previous episode baseline", + body.contains("cancelBufferingStallWatchdog()")); + } + /** * Slices one method body. Relies on the body containing no closing brace at four-space * indentation; every method asserted here satisfies that today. If a nested block ever @@ -43,6 +62,31 @@ public class PlayerManagerLifecycleSourceTest { return source.substring(start, end); } + @Test + public void bufferingBranchKeepsTheAlreadyArmedGuard() throws Exception { + String source = readPlayerManager(); + // Deliberately asserts the whole line. Dropping this guard makes the BUFFERING branch + // re-arm on every state callback, which re-anchors the baseline and clock each time, so + // a genuine stall would never be reported. It is the kind of line a later cleanup + // removes as redundant, which is exactly why it is pinned verbatim here. + assertTrue("BUFFERING branch must only arm when not already armed; if you changed this" + + " line intentionally, re-align this assertion rather than deleting it", + source.contains("if (!bufferingStallWatchdog.isArmed()) armBufferingStallWatchdog();")); + } + + @Test + public void stallWatchdogStaysKernelAgnostic() throws Exception { + String arm = methodBody(readPlayerManager(), "private void armBufferingStallWatchdog()"); + // E-SP3 wires this watchdog into the decode/kernel fallback chain, and that chain spans + // every kernel (KERNEL_ORDER = EXO -> IJK -> MPV -> SYSTEM). Gating the arming on isExo() + // therefore reintroduces, for MPV and Ijk, exactly the "spinner never clears and no + // fallback fires" gap the watchdog exists to close. Paused-session false positives are + // the playWhenReady guard's job in checkBufferingStall(), not a kernel exclusion. + assertFalse("armBufferingStallWatchdog() must not exclude non-Exo kernels;" + + " see docs/E-SP3-exo-buffering-stall-watchdog.md", + arm.contains("isExo()")); + } + @Test public void seekInducedBufferingIsExcludedFromTheRebufferCount() throws Exception { String source = readPlayerManager(); @@ -64,73 +108,6 @@ public class PlayerManagerLifecycleSourceTest { body.contains("!playbackBufferingTracker.isBuffering()")); } - @Test - public void bufferingStateArmsTheStallWatchdogForEveryEngine() throws Exception { - String source = readPlayerManager(); - int buffering = source.indexOf("} else if (state == Player.STATE_BUFFERING) {"); - int nextBranch = source.indexOf("\n } else {", buffering); - assertTrue("state listener must contain the buffering branch", buffering >= 0); - assertTrue("state listener buffering branch must have a closing boundary", nextBranch > buffering); - int arm = source.indexOf("armBufferingStallWatchdog();", buffering); - assertTrue("entering BUFFERING must start the stall watchdog", arm > buffering && arm < nextBranch); - // The watchdog is engine-independent: MPV reaches the same frozen-BUFFERING state and - // MpvPlayer.timeOutSeekBuffering() already defers to it. Restricting it to Exo left MPV - // with no bound at all, because the 15 s startup timer is cancelled by BUFFERING itself. - assertFalse("the stall watchdog must not be gated on a single engine", - source.contains("if (isExo()) armBufferingStallWatchdog();")); - assertTrue("watchdog arming must only require a live player and spec", - source.contains("private void armBufferingStallWatchdog() {\n if (player == null || spec == null) return;")); - assertTrue("watchdog polling must only require a live player and spec", - source.contains("private void checkBufferingStall() {\n if (player == null || spec == null) {")); - assertTrue("seeking must re-arm the stall watchdog for every engine", - source.contains("player.seekTo(time);\n cancelBufferingStallWatchdog();\n armBufferingStallWatchdog();")); - int polling = source.indexOf("private void checkBufferingStall()"); - int observe = source.indexOf("bufferingStallWatchdog.observe(", polling); - int timeout = source.indexOf("bufferingStallWatchdog.shouldTimeout(", polling); - assertTrue("buffering polling method must exist", polling >= 0); - assertTrue("a discontinuity must be observed before timeout is evaluated", - observe > polling && timeout > observe); - } - - /** - * 超时日志里的实测停滞时长必须在停掉看门狗之前读出。 - * - *

{@code cancelBufferingStallWatchdog()} 会把看门狗置为未布防,而 - * {@code stalledDurationMs()} 在未布防时返回 {@code -1}。先取消再读,日志就永远写 - * {@code stalledMs=-1},实测时长这个字段等于不存在——排查时无法分辨「刚过 20s 就超时」 - * 与「卡了 90s 才超时」。 - */ - @Test - public void stallDurationIsMeasuredBeforeTheWatchdogIsDisarmed() throws Exception { - String source = stripComments(readPlayerManager()); - String body = methodBody(source, "private void onBufferingStall("); - int measured = body.indexOf("stalledDurationMs("); - int disarmed = body.indexOf("cancelBufferingStallWatchdog();"); - assertTrue("onBufferingStall must report the measured stall", measured >= 0); - assertTrue("onBufferingStall must disarm the watchdog", disarmed >= 0); - assertTrue("reading stalledDurationMs() after reset() always yields -1", measured < disarmed); - } - - /** - * Drops whole-line comments and trailing {@code //} comments. - * - *

Without this, an assertion that looks for a call by name can be satisfied by the same - * name inside the explanatory comment that documents it — the guard then passes no matter - * what the code does. Verified by mutation: restoring the buggy order while the comment - * still names {@code stalledDurationMs()} kept the assertion green until comments were - * stripped. - */ - private static String stripComments(String source) { - StringBuilder out = new StringBuilder(source.length()); - for (String line : source.split("\n", -1)) { - String trimmed = line.trim(); - if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue; - int marker = line.indexOf("//"); - out.append(marker >= 0 ? line.substring(0, marker) : line).append('\n'); - } - return out.toString(); - } - private static String readPlayerManager() throws IOException { Path root = Path.of("").toAbsolutePath(); Path source = root.resolve(Path.of( diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerSwitchCauseSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerSwitchCauseSourceTest.java deleted file mode 100644 index d0ac460656..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerSwitchCauseSourceTest.java +++ /dev/null @@ -1,216 +0,0 @@ -package com.fongmi.android.tv.player; - -import org.junit.Test; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 本类断言 {@code PlayerManager} 的**实现文本**(仓库既有的 source-text 约定),锁定 - * “每次内核/软硬解切换都必须能回答为什么换”的不变量。 - * - *

这些断言无法从行为上便宜地观测:切换发生在多个异步路径里,而这里要保证的恰恰是 - * “所有路径都经过同一个归因入口”。若日后重构掉这些方法,断言会变红 —— 那说明约定被破坏 - * 需要同步更新断言,**不是**测试坏了。确认行为未变后请重新对齐断言。 - */ -public class PlayerManagerSwitchCauseSourceTest { - - @Test - public void everyKernelSwitchFlowsThroughTheSingleCauseLogger() throws Exception { - String source = readPlayerManager(); - String engineSwitch = methodBody(source, - "private void switchEngine(int type, boolean chosen, boolean notifyPrepare, int decode, long position, float speed, boolean repeat, boolean wasPlayWhenReady, String cause)", - "private void rebuildPlayer()"); - - assertTrue("the kernel switch must log its cause", engineSwitch.contains("logSwitchCause(cause,")); - // Capture the previous identity before mutating it, or the log would report from==to. - int captureType = engineSwitch.indexOf("int previousType = playerType;"); - int assign = engineSwitch.indexOf("playerType = type;"); - assertTrue("the previous kernel must be captured before it is overwritten", - captureType >= 0 && assign > captureType); - int captureDecode = engineSwitch.indexOf("int previousDecode = engine == null ? -1 : engine.getDecode();"); - assertTrue("the previous decode must be captured before the engine is released", - captureDecode >= 0 && captureDecode < engineSwitch.indexOf("engine.release();")); - } - - @Test - public void switchKindIsDerivedFromWhatActuallyChanged() throws Exception { - String source = readPlayerManager(); - String derived = methodBody(source, - "private void logSwitchCause(String cause, int fromType, int toType,", - "private static String decodeText(int decode)"); - - // A decode-only switch (same kernel, hard->soft) must not be reported as a kernel switch. - assertTrue("kind must be derived from the actual change", - derived.contains("fromType != toType ? \"kernel\" : fromDecode != toDecode ? \"decode\" : \"none\"")); - } - - @Test - public void fallbackCausesCarryTheTriggeringErrorCode() throws Exception { - String source = readPlayerManager(); - String decodeFallback = methodBody(source, - "private boolean fallbackDecode(PlaybackException e)", - "private boolean fallbackPlayer(PlaybackException e)"); - String playerFallback = methodBody(source, - "private boolean fallbackPlayer(PlaybackException e)", - "private static String fallbackTrigger(PlaybackException e)"); - String trigger = methodBody(source, - "private static String fallbackTrigger(PlaybackException e)", - "private boolean ensurePlayerAvailableForPlayback()"); - - assertTrue("a decode fallback must state why it happened", - decodeFallback.contains("\"fallback-decode:\" + fallbackTrigger(e)")); - assertTrue("a kernel fallback must state why it happened", - playerFallback.contains("\"fallback-kernel:\" + fallbackTrigger(e)")); - // The mapping must not collapse every failure into one label, or the log cannot - // distinguish a timeout from a decoder failure. - assertTrue(trigger.contains("ERROR_CODE_TIMEOUT -> \"timeout\"")); - assertTrue(trigger.contains("ERROR_CODE_DECODER_INIT_FAILED -> \"decoder-init\"")); - assertTrue(trigger.contains("default -> \"code-\" + e.errorCode")); - } - - @Test - public void kernelUnavailableIsDistinguishedFromFailureFallback() throws Exception { - String source = readPlayerManager(); - String ensure = methodBody(source, - "private boolean ensurePlayerAvailableForPlayback()", - "private int nextFallbackPlayer()"); - - assertTrue("an unavailable kernel is not a playback failure and must say so", - ensure.contains("\"kernel-unavailable\"")); - } - - @Test - public void historyKernelAndPendingKernelAreDistinguishable() throws Exception { - String source = readPlayerManager(); - // force=true is only ever passed by the onError retry, so it doubles as the cause signal - // and no public signature had to change to record it. - assertTrue(source.contains("force ? \"history-kernel-retry-after-error\" : \"history-kernel-before-play\"")); - // preparePlayer is the only entry point that restores a remembered kernel before the - // play url is resolved, so its cause is unambiguous without a parameter. - String prepare = methodBody(source, "public void preparePlayer(int type, boolean force)", "public void switchPlayer(int type, PlaySpec freshSpec"); - assertTrue("restoring a remembered kernel must be logged", prepare.contains("logSwitchCause(")); - assertFalse("preparePlayer must not take a cause parameter; the call sites are all the same reason", - prepare.contains("String cause")); - } - - @Test - public void userDrivenSwitchesAreLabelledAsUserChoice() throws Exception { - String source = readPlayerManager(); - // Every switchPlayer/switchDecode caller is a UI entry point, so the label is derived - // inside PlayerManager instead of being threaded through five Activity files. - assertTrue(source.contains("switchEngine(type, true, true, true, engine.getDecode(), \"user-choice\")")); - assertTrue(source.contains("logSwitchCause(\"user-choice\", previousType, type, decode, decode, true, position)")); - assertTrue(source.contains("logSwitchCause(\"decode\", \"user-choice\", playerType, playerType, engine.getDecode(), next, false, position)")); - } - - @Test - public void chosenFlagSeparatesUserChoiceFromAutomaticFallback() throws Exception { - String source = readPlayerManager(); - String engineSwitch = methodBody(source, - "private void switchEngine(int type, boolean chosen, boolean notifyPrepare, int decode, long position, float speed, boolean repeat, boolean wasPlayWhenReady, String cause)", - "private void rebuildPlayer()"); - - assertTrue("the log must expose whether the user chose this kernel", - engineSwitch.contains("chosen,")); - // Automatic fallbacks pass chosen=false so they never look like a user selection. - assertTrue(source.contains("switchEngine(playerType, false, true, true, PlayerEngine.SOFT, \"fallback-decode:\"")); - assertTrue(source.contains("switchEngine(next, false, true, true, fallbackDecode(")); - } - - @Test - public void manualDecodeButtonLogsItsCauseToo() throws Exception { - String source = readPlayerManager(); - // toggleDecode() is the control bar's decode button - the most common manual - // soft/hard switch on both flavors. switchDecode(...) only covers the source-refresh - // path, so without a log here the everyday manual switch had no switch-cause row. - String toggle = methodBody(source, "public void toggleDecode()", "private void maybeFallbackToSoftDecode("); - assertTrue("the manual decode button must log its cause", - toggle.contains("logSwitchCause(\"decode\", \"user-choice\",")); - // The previous decode has to be read before engine.setDecode(next) overwrites it. - int capture = toggle.indexOf("int previousDecode = engine.getDecode();"); - int mutate = toggle.indexOf("engine.setDecode(next);"); - assertTrue("the previous decode must be captured before it is overwritten", - capture >= 0 && mutate > capture); - } - - @Test - public void switchCauseLogIsNotGatedOnSpiderDebug() throws Exception { - String source = readPlayerManager(); - String logger = methodBody(source, - "private void logSwitchCause(String kind, String cause, int fromType, int toType,", - "private void logSwitchCause(String cause, int fromType, int toType,"); - - // PlaybackTrace.log already gates on SpiderDebug.isEnabled(); a second gate here would - // make the cause invisible in exactly the exports users send for triage. - assertTrue(logger.contains("PlaybackTrace.log(\"switch-cause\"")); - assertFalse(logger.contains("SpiderDebug.isEnabled()")); - } - - /** - * 日志里的位置必须在引擎释放之前读出。 - * - *

{@code preparePlayer()} 的切换原因日志位于 {@code engine.release()} 之后;若那里 - * 直接写 {@code getPosition()},记录的就是已释放播放器的状态,而不是内核被换掉时播放 - * 真正所在的位置——排查「按剧集记住的内核」时这个字段正是关键。 - */ - @Test - public void switchCausePositionIsReadBeforeTheEngineIsReleased() throws Exception { - String source = stripComments(readPlayerManager()); - String prepare = methodBody(source, - "public void preparePlayer(int type, boolean force)", - "public void switchPlayer(int type, PlaySpec freshSpec"); - - int capture = prepare.indexOf("long positionBeforeRelease = Math.max(0, getPosition());"); - int release = prepare.indexOf("engine.release();"); - int log = prepare.indexOf("logSwitchCause("); - assertTrue("preparePlayer must read the position while the old engine is alive", capture >= 0); - assertTrue("preparePlayer must release the old engine", release >= 0); - assertTrue("preparePlayer must log the switch cause", log >= 0); - assertTrue("the read must precede the release", capture < release); - assertTrue("the log must use the captured value, not a post-release read", - prepare.indexOf("true, positionBeforeRelease)") > release && log > release); - } - - /** - * Drops whole-line comments and trailing {@code //} comments, so an assertion looking for a - * call by name cannot be satisfied by that name appearing in the comment documenting it. - * Verified by mutation: leaving the comment in place kept a broken ordering assertion green. - */ - private static String stripComments(String source) { - StringBuilder out = new StringBuilder(source.length()); - for (String line : source.split("\n", -1)) { - String trimmed = line.trim(); - if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue; - int marker = line.indexOf("//"); - out.append(marker >= 0 ? line.substring(0, marker) : line).append('\n'); - } - return out.toString(); - } - - private static String readPlayerManager() throws IOException { - return read("app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java", - "src/main/java/com/fongmi/android/tv/player/PlayerManager.java"); - } - - private static String read(String moduleRelative, String rootRelative) throws IOException { - Path root = Path.of("").toAbsolutePath(); - Path source = root.resolve(moduleRelative); - if (!Files.exists(source)) source = root.resolve(rootRelative); - assertTrue("source must exist: " + source, Files.exists(source)); - return Files.readString(source, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } - - private static String methodBody(String source, String startToken, String endToken) { - int start = source.indexOf(startToken); - assertTrue("Missing source token: " + startToken, start >= 0); - int end = source.indexOf(endToken, start); - assertTrue("Missing source token after " + startToken + ": " + endToken, end > start); - return source.substring(start, end); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerTest.java index 21ed812134..d4114fa309 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerTest.java @@ -245,4 +245,23 @@ public class PlayerManagerTest { assertEquals(0, PlayerManager.httpStatus(new IOException("Socket closed"))); } + @Test + public void nextUntriedFfmpegMode_walksOrderThenExhausts() { + int[] order = PlayerSetting.FFMPEG_AUTO_ORDER; + boolean[] tried = new boolean[order.length]; + + int first = PlayerManager.nextUntriedFfmpegMode(order, tried); + assertEquals(order[0], first); + tried[0] = true; + + int second = PlayerManager.nextUntriedFfmpegMode(order, tried); + assertEquals(order[1], second); + tried[1] = true; + + int third = PlayerManager.nextUntriedFfmpegMode(order, tried); + assertEquals(order[2], third); + tried[2] = true; + + assertEquals(PlayerSetting.NONE, PlayerManager.nextUntriedFfmpegMode(order, tried)); + } } diff --git a/app/src/test/java/com/fongmi/android/tv/player/SurfaceRebindDiagnosticTest.java b/app/src/test/java/com/fongmi/android/tv/player/SurfaceRebindDiagnosticTest.java deleted file mode 100644 index 0735f2ba68..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/SurfaceRebindDiagnosticTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.fongmi.android.tv.player; - -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; - -import org.junit.Test; - -/** - * {@code surface.rebind} 的事件名与字段名必须登记在 catvod 的诊断白名单里。 - * - *

{@code PlaybackDiagnosticCollector.emit} 把白名单外名字抛出的 {@link IllegalArgumentException} - * 吞成 {@code collectorFailure}:现场既拿不到这条证据,采集器还会被记成故障。这里直接构造生产代码 - * 写入的同一批事实,把白名单契约钉住。 - */ -public class SurfaceRebindDiagnosticTest { - - @Test - public void surfaceRebindFactsAreRegisteredInTheSchema() { - DiagnosticEvent event = SurfaceDiagnosticCollector.rebindFacts( - new DiagnosticEvent(SurfaceDiagnosticCollector.EVENT_REBIND, "trace", "instance", 1, 1), - "state=2", false, null); - - String json = event.json(); - assertTrue("the event name must survive the schema allowlist", json.contains("\"event\":\"surface.rebind\"")); - assertTrue(json.contains("\"detachedFirst\"")); - assertTrue(json.contains("\"surfaceViewPresent\"")); - assertTrue(json.contains("\"reason\"")); - assertTrue(json.contains("\"physicalDisplay\"")); - assertTrue(json.contains("\"surfaceId\"")); - assertTrue(json.contains("\"valid\"")); - } - - @Test - public void theAllowlistGuardIsNotVacuous() { - try { - new DiagnosticEvent(SurfaceDiagnosticCollector.EVENT_REBIND + ".typo", "t", "i", 1, 1); - fail("unknown diagnostic events must be rejected"); - } catch (IllegalArgumentException expected) { - // 白名单外的名字确实会被拒,上面的用例才有意义。 - } - try { - SurfaceDiagnosticCollector.rebindFacts( - new DiagnosticEvent(SurfaceDiagnosticCollector.EVENT_REBIND, "t", "i", 1, 1), - "x", false, null) - .observed("unregisteredField", true); - fail("unknown diagnostic fields must be rejected"); - } catch (IllegalArgumentException expected) { - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/audio/PlaybackMediaSignalHubTest.java b/app/src/test/java/com/fongmi/android/tv/player/audio/PlaybackMediaSignalHubTest.java index cef5992be2..1b8675345b 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/audio/PlaybackMediaSignalHubTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/audio/PlaybackMediaSignalHubTest.java @@ -89,14 +89,14 @@ public class PlaybackMediaSignalHubTest { public void captureLeaseIsReferenceCounted() { PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); PlaybackMediaSignalHub.CaptureLease first = hub.requestCapture( - PlaybackMediaSignalHub.ConsumerKind.TEST); + PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO); PlaybackMediaSignalHub.CaptureLease second = hub.requestCapture( - PlaybackMediaSignalHub.ConsumerKind.TEST); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.TEST)); + PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO); + assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); first.close(); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.TEST)); + assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); second.close(); - assertTrue(!hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.TEST)); + assertTrue(!hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); } @Test diff --git a/app/src/test/java/com/fongmi/android/tv/player/engine/ExoPlayerEngineSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/engine/ExoPlayerEngineSourceTest.java new file mode 100644 index 0000000000..5b8fdc9fdb --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/engine/ExoPlayerEngineSourceTest.java @@ -0,0 +1,92 @@ +package com.fongmi.android.tv.player.engine; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ExoPlayerEngineSourceTest { + + @Test + public void everyPrepareUsesTheGenerationAwareLifecycleBridge() throws Exception { + String source = readMainJava("com", "fongmi", "android", "tv", "player", "engine", "ExoPlayerEngine.java"); + String startInternal = methodBody(source, "private void startInternal(long position, boolean playWhenReady)"); + String seekToDefault = methodBody(source, "private ErrorAction seekToDefaultPosition()"); + String preparePlayer = methodBody(source, "private void preparePlayer()"); + + assertEquals("ExoPlayerEngine must keep the raw prepare call behind one lifecycle bridge", + 1, count(source, "player.prepare();")); + assertTrue("normal starts and format retries must use the lifecycle bridge", + startInternal.contains("preparePlayer();")); + assertTrue("behind-live-window recovery must use the lifecycle bridge", + seekToDefault.contains("preparePlayer();")); + assertTrue("the bridge must announce the generation before preparing", + preparePlayer.indexOf("int generation = beginPrepare();") >= 0 + && preparePlayer.indexOf("prepareListener.onPrepareStarted(generation);") > preparePlayer.indexOf("int generation = beginPrepare();") + && preparePlayer.indexOf("player.prepare();") > preparePlayer.indexOf("prepareListener.onPrepareStarted(generation);")); + } + + @Test + public void readyAndCancellationCallbacksCarryTheCapturedGeneration() throws Exception { + String source = readMainJava("com", "fongmi", "android", "tv", "player", "engine", "ExoPlayerEngine.java"); + String beginPrepare = methodBody(source, "private int beginPrepare()"); + String cancelPrepare = methodBody(source, "public void cancelPendingPrepare()"); + String release = methodBody(source, "public void release()"); + String rebuild = methodBody(source, "public Player rebuild(Player.Listener listener)"); + String restart = methodBody(source, "public void restart(PlaySpec spec, long position, boolean playWhenReady)"); + String stop = methodBody(source, "public void stop()"); + + assertTrue("prepare generations must remain unique across engine replacements", + source.contains("static final AtomicInteger PREPARE_GENERATION") + && beginPrepare.contains("PREPARE_GENERATION.incrementAndGet()")); + assertTrue("READY must complete only the listener generation that is still active", + beginPrepare.contains("generation != pendingPrepareGeneration") + && beginPrepare.contains("prepareListener.onPrepareReady(generation);")); + assertTrue("cancellation must invalidate and report the exact active generation", + cancelPrepare.contains("int generation = pendingPrepareGeneration;") + && cancelPrepare.contains("prepareListener.onPrepareCanceled(generation);")); + assertTrue("replacement, stop, rebuild, and release paths must cancel stale prepare listeners", + beginPrepare.contains("cancelPendingPrepare();") + && restart.contains("cancelPendingPrepare();") + && stop.contains("cancelPendingPrepare();") + && rebuild.contains("cancelPendingPrepare();") + && release.contains("cancelPendingPrepare();")); + } + + private static int count(String source, String value) { + int count = 0; + int index = 0; + while ((index = source.indexOf(value, index)) >= 0) { + count++; + index += value.length(); + } + return count; + } + + private static String methodBody(String source, String signature) { + int method = source.indexOf(signature); + assertTrue("Missing method: " + signature, method >= 0); + int start = source.indexOf('{', method); + int depth = 0; + for (int i = start; i < source.length(); i++) { + char value = source.charAt(i); + if (value == '{') depth++; + if (value == '}' && --depth == 0) return source.substring(start + 1, i); + } + throw new AssertionError("Unclosed method: " + signature); + } + + private static String readMainJava(String... parts) throws Exception { + Path path = Path.of("src", "main", "java"); + for (String part : parts) path = path.resolve(part); + if (!Files.exists(path)) { + path = Path.of("app", "src", "main", "java"); + for (String part : parts) path = path.resolve(part); + } + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/engine/IjkStreamScenePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/engine/IjkStreamScenePolicyTest.java index afcea71201..ab6cbe1478 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/engine/IjkStreamScenePolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/engine/IjkStreamScenePolicyTest.java @@ -126,8 +126,6 @@ public class IjkStreamScenePolicyTest { assertFalse(decision.dynamic()); assertEquals(IjkStreamScenePolicy.Reason.MANIFEST_NOT_MEDIA, decision.reason()); - assertFalse(IjkStreamScenePolicy.isLive(decision, 120_000)); - assertTrue(IjkStreamScenePolicy.isVod(decision, 120_000)); } @Test @@ -163,8 +161,6 @@ public class IjkStreamScenePolicyTest { assertFalse(stale.live()); assertFalse(stale.vod()); assertEquals(IjkStreamScenePolicy.Reason.MANIFEST_STALE, stale.reason()); - assertFalse(IjkStreamScenePolicy.isLive(stale, 120_000)); - assertTrue(IjkStreamScenePolicy.isVod(stale, 120_000)); } @Test @@ -182,52 +178,16 @@ public class IjkStreamScenePolicyTest { IjkStreamScenePolicy.Decision longDuration = IjkStreamScenePolicy.resolve(progressive, 120_000, 100); - assertFalse(unknownDuration.live()); - assertTrue(unknownDuration.vod()); + assertTrue(unknownDuration.live()); assertTrue(unknownDuration.dynamic()); - assertEquals(IjkStreamScenePolicy.Reason.LEGACY_DURATION_VOD, unknownDuration.reason()); assertTrue(shortDuration.live()); assertFalse(shortDuration.dynamic()); assertFalse(exactBoundary.live()); - assertTrue(exactBoundary.vod()); + assertFalse(exactBoundary.vod()); assertTrue(longDuration.vod()); assertFalse(longDuration.live()); } - @Test - public void authoritativeManifestBeatsTheDurationHeuristic() { - IjkStreamScenePolicy.Decision vod = IjkStreamScenePolicy.resolve( - hls("#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\na.ts\n#EXT-X-ENDLIST\n", 100), - 30_000, - Long.MAX_VALUE); - - assertFalse(IjkStreamScenePolicy.isLive(vod, 30_000)); - assertTrue(IjkStreamScenePolicy.isVod(vod, 30_000)); - } - - @Test - public void unknownDurationIsVodOnceTheManifestGivesNoLiveEvidence() { - PlaybackResourceClassifier.Classification progressive = - PlaybackResourceClassifier.classifyRequest( - "https://origin.invalid/movie.mp4", "video/mp4", null); - IjkStreamScenePolicy.Decision decision = - IjkStreamScenePolicy.resolve(progressive, C.TIME_UNSET, 100); - - assertFalse(IjkStreamScenePolicy.isLive(decision, C.TIME_UNSET)); - assertTrue(IjkStreamScenePolicy.isVod(decision, C.TIME_UNSET)); - } - - @Test - public void liveManifestStillWinsWhileTheDurationIsUnknown() { - IjkStreamScenePolicy.Decision decision = IjkStreamScenePolicy.resolve( - hls("#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\na.ts\n", 100), - C.TIME_UNSET, - 200); - - assertTrue(IjkStreamScenePolicy.isLive(decision, C.TIME_UNSET)); - assertFalse(IjkStreamScenePolicy.isVod(decision, C.TIME_UNSET)); - } - @Test public void timelineSnapshotContainsOnlySafeFactsAndRefreshIdentity() { IjkStreamScenePolicy.Decision first = IjkStreamScenePolicy.resolve( @@ -258,34 +218,6 @@ public class IjkStreamScenePolicyTest { assertNull(decision.timelineSnapshot()); } - @Test - public void thresholdDurationIsVodOnceItIsNotShorterThanTheLegacyBoundary() { - long threshold = TimelineStreamKindPolicy.LEGACY_LIVE_DURATION_THRESHOLD_MS; - IjkStreamScenePolicy.Decision decision = IjkStreamScenePolicy.resolve( - PlaybackResourceClassifier.classifyRequest( - "https://origin.invalid/movie.mp4", "video/mp4", null), - threshold, - 100); - - assertFalse(decision.live()); - assertTrue(decision.vod()); - assertFalse(IjkStreamScenePolicy.isLive(decision, threshold)); - assertTrue(IjkStreamScenePolicy.isVod(decision, threshold)); - } - - @Test - public void liveProtocolStaysLiveWithoutManifestOrDuration() { - PlaybackResourceClassifier.Classification rtmp = - PlaybackResourceClassifier.classifyRequest("rtmp://origin.invalid/live/stream", null, null); - IjkStreamScenePolicy.Decision decision = IjkStreamScenePolicy.resolve(rtmp, C.TIME_UNSET); - - assertTrue(decision.live()); - assertFalse(decision.vod()); - assertEquals(IjkStreamScenePolicy.Reason.PROTOCOL_LIVE, decision.reason()); - assertTrue(IjkStreamScenePolicy.isLive(decision, C.TIME_UNSET)); - assertFalse(IjkStreamScenePolicy.isVod(decision, C.TIME_UNSET)); - } - private static PlaybackResourceClassifier.Classification hls( String text, long observedAtMs) { diff --git a/app/src/test/java/com/fongmi/android/tv/player/engine/MpvAudioCapabilitiesTest.java b/app/src/test/java/com/fongmi/android/tv/player/engine/MpvAudioCapabilitiesTest.java index d03ff56978..2baac7badd 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/engine/MpvAudioCapabilitiesTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/engine/MpvAudioCapabilitiesTest.java @@ -2,16 +2,11 @@ package com.fongmi.android.tv.player.engine; import androidx.media3.common.C; -import android.media.AudioFormat; -import android.os.Build; - import org.junit.Test; -import java.util.LinkedHashSet; import java.util.Set; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; public class MpvAudioCapabilitiesTest { @@ -41,22 +36,6 @@ public class MpvAudioCapabilitiesTest { assertEquals("", MpvAudioCapabilities.getAudioSpdifCodecs(encoding -> false)); } - @Test - public void doesNotAdvertiseCompressedDirectWithoutPassthroughRoute() { - Set target = new LinkedHashSet<>(); - MpvAudioCapabilities.addCompressedCodecsIfRouted( - target, Set.of("aac", "mp3"), false); - assertTrue(target.isEmpty()); - } - - @Test - public void advertisesCompressedDirectWhenPassthroughRouteExists() { - Set target = new LinkedHashSet<>(); - MpvAudioCapabilities.addCompressedCodecsIfRouted( - target, Set.of("aac", "mp3"), true); - assertEquals(Set.of("aac", "mp3"), target); - } - @Test public void filtersAdvertisedCodecsByActualMpvCarrierSupport() { Set advertised = Set.of("ac3", "eac3", "truehd"); @@ -71,43 +50,4 @@ public class MpvAudioCapabilitiesTest { assertEquals("ac3,eac3,dts,dts-hd,truehd", MpvAudioCapabilities.getAudioSpdifCodecs(advertised, codec -> true)); } - - @Test - public void keepsTrueHdOnSevenPointOneForOlderAndroidTvCompatibility() { - assertEquals( - java.util.List.of(new MpvAudioCapabilities.CarrierFormat( - 192000, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)), - MpvAudioCapabilities.getCarrierFormats("truehd", Build.VERSION_CODES.R)); - } - - @Test - public void probesBothDtsHdCarrierLayoutsOnAndroidTwelveAndNewer() { - assertEquals( - java.util.List.of( - new MpvAudioCapabilities.CarrierFormat( - 192000, AudioFormat.CHANNEL_OUT_STEREO), - new MpvAudioCapabilities.CarrierFormat( - 192000, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)), - MpvAudioCapabilities.getCarrierFormats("dts-hd", Build.VERSION_CODES.S)); - } - - @Test - public void keepsDtsHdStereoProbeBeforeAndroidTwelve() { - assertEquals( - java.util.List.of(new MpvAudioCapabilities.CarrierFormat( - 192000, AudioFormat.CHANNEL_OUT_STEREO)), - MpvAudioCapabilities.getCarrierFormats("dts-hd", Build.VERSION_CODES.R)); - } - - @Test - public void preservesExistingCoreAndEnhancedCarrierRates() { - assertEquals( - java.util.List.of(new MpvAudioCapabilities.CarrierFormat( - 48000, AudioFormat.CHANNEL_OUT_STEREO)), - MpvAudioCapabilities.getCarrierFormats("ac3", Build.VERSION_CODES.VANILLA_ICE_CREAM)); - assertEquals( - java.util.List.of(new MpvAudioCapabilities.CarrierFormat( - 192000, AudioFormat.CHANNEL_OUT_STEREO)), - MpvAudioCapabilities.getCarrierFormats("eac3", Build.VERSION_CODES.VANILLA_ICE_CREAM)); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/player/engine/MpvDolbyVisionFallbackPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/engine/MpvDolbyVisionFallbackPolicyTest.java index a26d7e4bd7..b37689629d 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/engine/MpvDolbyVisionFallbackPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/engine/MpvDolbyVisionFallbackPolicyTest.java @@ -2,12 +2,8 @@ package com.fongmi.android.tv.player.engine; import androidx.media3.mpvplayer.MpvPlayer; -import com.fongmi.android.tv.player.mpv.MpvAutoOutputPolicy; -import com.fongmi.android.tv.setting.PlaybackPerformanceSetting; - import org.junit.Test; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -45,77 +41,6 @@ public class MpvDolbyVisionFallbackPolicyTest { details(7), true, false)); } - @Test - public void nativeDv7AlwaysWinsOverFallbackPreference() { - assertEquals(MpvPlayerEngine.DV7_PRESERVE, - MpvPlayerEngine.selectDv7Handling( - MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED, - MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED, - PlaybackPerformanceSetting.DV7_HANDLING_HDR10)); - } - - @Test - public void supportedP81UsesP81Preference() { - assertEquals(MpvPlayerEngine.DV7_P81, - MpvPlayerEngine.selectDv7Handling( - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED, - PlaybackPerformanceSetting.DV7_HANDLING_P81)); - } - - @Test - public void unsupportedP81FallsBackToHdr10() { - assertEquals(MpvPlayerEngine.DV7_HDR10, - MpvPlayerEngine.selectDv7Handling( - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - PlaybackPerformanceSetting.DV7_HANDLING_P81)); - } - - @Test - public void explicitFelWinsEvenWhenNativeDv7IsSupported() { - for (MpvAutoOutputPolicy.DolbyVisionSupport nativeSupport - : MpvAutoOutputPolicy.DolbyVisionSupport.values()) { - for (MpvAutoOutputPolicy.DolbyVisionSupport p81Support - : MpvAutoOutputPolicy.DolbyVisionSupport.values()) { - assertEquals(MpvPlayerEngine.DV7_FEL, - MpvPlayerEngine.selectDv7Handling(nativeSupport, p81Support, - PlaybackPerformanceSetting.DV7_HANDLING_FEL)); - } - } - } - - @Test - public void felPreservesOriginalLayersAndRpu() { - assertEquals("preserve", MpvPlayerEngine.dv7DemuxerOption(MpvPlayerEngine.DV7_FEL)); - assertEquals("p81", MpvPlayerEngine.dv7DemuxerOption(MpvPlayerEngine.DV7_P81)); - assertEquals("hdr10", MpvPlayerEngine.dv7DemuxerOption(MpvPlayerEngine.DV7_HDR10)); - assertEquals("preserve", MpvPlayerEngine.dv7DemuxerOption(MpvPlayerEngine.DV7_PRESERVE)); - } - - @Test - public void profile8SelectsHdr10OnlyWithRegularHevcSupport() { - assertEquals(MpvPlayerEngine.DV8_HDR10, - MpvPlayerEngine.selectDv8Handling( - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED)); - assertEquals(MpvPlayerEngine.DV8_PRESERVE, - MpvPlayerEngine.selectDv8Handling( - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN)); - assertEquals(MpvPlayerEngine.DV8_PRESERVE, - MpvPlayerEngine.selectDv8Handling( - MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED, - MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED)); - } - - @Test - public void configuredProfile8FallbackIsReportedSeparatelyFromDv7() { - MpvPlayer.VideoTrackDiagnostics details = details(8); - assertTrue(MpvPlayerEngine.isConfiguredDv8Hdr10Fallback(details, true, true)); - assertFalse(MpvPlayerEngine.isConfiguredDv7Hdr10Fallback(details, true, true)); - } - private static MpvPlayer.VideoTrackDiagnostics details(int profile) { return new MpvPlayer.VideoTrackDiagnostics( "dvhe.0" + profile + ".06", profile, 6, diff --git a/app/src/test/java/com/fongmi/android/tv/player/engine/MpvHardwareDecodePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/engine/MpvHardwareDecodePolicyTest.java deleted file mode 100644 index 10c70407a5..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/engine/MpvHardwareDecodePolicyTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.fongmi.android.tv.player.engine; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -public final class MpvHardwareDecodePolicyTest { - - @Test - public void hardwareModeDisablesAutomaticSoftwareFallback() { - assertEquals("hwdec-software-fallback", - MpvPlayerEngine.HWDEC_SOFTWARE_FALLBACK_OPTION); - assertEquals("no", MpvPlayerEngine.hardwareDecodeSoftwareFallbackOption(PlayerEngine.HARD)); - } - - @Test - public void explicitSoftwareModeAllowsSoftwareDecoder() { - assertEquals("yes", MpvPlayerEngine.hardwareDecodeSoftwareFallbackOption(PlayerEngine.SOFT)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/engine/MpvTrackSelectionTest.java b/app/src/test/java/com/fongmi/android/tv/player/engine/MpvTrackSelectionTest.java index cadb793d7b..cfed0ea737 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/engine/MpvTrackSelectionTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/engine/MpvTrackSelectionTest.java @@ -1,15 +1,10 @@ package com.fongmi.android.tv.player.engine; -import androidx.media3.common.C; - -import com.fongmi.android.tv.bean.Track; - import org.junit.Test; import java.util.List; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; public class MpvTrackSelectionTest { @Test @@ -30,16 +25,4 @@ public class MpvTrackSelectionTest { assertEquals(1, matched); } - - @Test - public void extractsPersistedSubtitleIdForPreselection() { - Track track = new Track(C.TRACK_TYPE_TEXT, "简体中文", - "3:2,简体中文,subrip,zh,application/x-subrip,-1"); - track.setSelected(true); - - assertEquals("2", MpvPlayerEngine.persistedSubtitleTrackId(track)); - - track.setSelected(false); - assertNull(MpvPlayerEngine.persistedSubtitleTrackId(track)); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/player/engine/PlaySpecTest.java b/app/src/test/java/com/fongmi/android/tv/player/engine/PlaySpecTest.java index d67c229157..67b77a53e1 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/engine/PlaySpecTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/engine/PlaySpecTest.java @@ -1,14 +1,11 @@ package com.fongmi.android.tv.player.engine; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; import androidx.media3.common.C; import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.Danmaku; import com.fongmi.android.tv.bean.Sub; import org.junit.Test; @@ -65,20 +62,6 @@ public class PlaySpecTest { assertEquals(C.SELECTION_FLAG_FORCED, forced.getRawFlag()); } - @Test - public void addDanmaku_selectsAutoSearchedLineWhenSiteDanmakuIsNotLoaded() { - Danmaku site = Danmaku.from("https://example.test/site.xml"); - site.setSelected(true); - Danmaku auto = Danmaku.from("https://example.test/auto.xml"); - PlaySpec spec = specWithDanmakus(site); - - spec.addDanmaku(auto); - - assertEquals(2, spec.getDanmakus().size()); - assertTrue(auto.isSelected()); - assertFalse(site.isSelected()); - } - private PlaySpec specWithSubs(Sub... subs) { Result result = new Result(); result.setPlayUrl("https://play/"); @@ -86,10 +69,4 @@ public class PlaySpecTest { result.setSubs(new ArrayList<>(List.of(subs))); return PlaySpec.from(result, "playback-1", null); } - - private PlaySpec specWithDanmakus(Danmaku... danmakus) { - PlaySpec spec = PlaySpec.from("playback-1", "movie.m3u8", null, null); - for (Danmaku danmaku : danmakus) spec.setDanmaku(danmaku); - return spec; - } } diff --git a/app/src/test/java/com/fongmi/android/tv/player/engine/TimelineStreamKindPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/engine/TimelineStreamKindPolicyTest.java deleted file mode 100644 index 37a1660fc1..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/engine/TimelineStreamKindPolicyTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.fongmi.android.tv.player.engine; - -import androidx.media3.common.C; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TimelineStreamKindPolicyTest { - - @Test - public void unknownDurationIsVodWhenTheManifestIsNotLive() { - assertFalse(TimelineStreamKindPolicy.isLive(C.TIME_UNSET, false)); - assertTrue(TimelineStreamKindPolicy.isVod(C.TIME_UNSET, false)); - } - - @Test - public void unknownDurationStillFollowsALiveManifest() { - assertTrue(TimelineStreamKindPolicy.isLive(C.TIME_UNSET, true)); - assertFalse(TimelineStreamKindPolicy.isVod(C.TIME_UNSET, true)); - } - - @Test - public void zeroDurationIsTreatedAsUnknown() { - assertFalse(TimelineStreamKindPolicy.isLive(0, false)); - assertTrue(TimelineStreamKindPolicy.isVod(0, false)); - } - - @Test - public void shortKnownDurationIsStillLive() { - assertTrue(TimelineStreamKindPolicy.isLive(30_000, false)); - assertFalse(TimelineStreamKindPolicy.isVod(30_000, false)); - } - - @Test - public void thresholdItselfIsVodAlthoughItIsNotLive() { - long threshold = TimelineStreamKindPolicy.LEGACY_LIVE_DURATION_THRESHOLD_MS; - - assertFalse(TimelineStreamKindPolicy.isLive(threshold, false)); - assertTrue(TimelineStreamKindPolicy.isVod(threshold, false)); - } - - @Test - public void longKnownDurationIsVod() { - assertFalse(TimelineStreamKindPolicy.isLive(900_000, false)); - assertTrue(TimelineStreamKindPolicy.isVod(900_000, false)); - } - - @Test - public void liveManifestOverridesALongDuration() { - assertTrue(TimelineStreamKindPolicy.isLive(900_000, true)); - assertFalse(TimelineStreamKindPolicy.isVod(900_000, true)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ApeExtractorTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ApeExtractorTest.java deleted file mode 100644 index e443cb0506..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ApeExtractorTest.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import androidx.media3.common.C; -import androidx.media3.common.DataReader; -import androidx.media3.common.Format; -import androidx.media3.common.util.ParsableByteArray; -import androidx.media3.extractor.DefaultExtractorInput; -import androidx.media3.extractor.Extractor; -import androidx.media3.extractor.ExtractorInput; -import androidx.media3.extractor.ExtractorOutput; -import androidx.media3.extractor.PositionHolder; -import androidx.media3.extractor.SeekMap; -import androidx.media3.extractor.TrackOutput; - -import org.junit.Test; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.EOFException; -import java.io.IOException; -import java.util.Arrays; - -public final class ApeExtractorTest { - - @Test - public void sniffRecognizesCurrentApeHeader() throws Exception { - byte[] source = apeFile(3990, 1, 4); - ApeExtractor extractor = new ApeExtractor(); - - assertTrue(extractor.sniff(input(source))); - assertFalse(extractor.sniff(input(new byte[] {'B', 'A', 'D', ' ', 0, 0}))); - byte[] invalidVersion = Arrays.copyOf(source, source.length); - invalidVersion[4] = (byte) 0xAF; - invalidVersion[5] = 0x0E; - assertFalse(extractor.sniff(input(invalidVersion))); - } - - @Test - public void readEmitsFfmpegCompatiblePacketsAndSeekMap() throws Exception { - byte[] source = apeFile(3970, 3, 4); - ApeExtractor extractor = new ApeExtractor(); - CaptureOutput output = new CaptureOutput(); - extractor.init(output); - ExtractorInput input = input(source); - PositionHolder seekPosition = new PositionHolder(); - - assertEquals(Extractor.RESULT_CONTINUE, extractor.read(input, seekPosition)); - assertEquals(Extractor.RESULT_CONTINUE, extractor.read(input, seekPosition)); - assertEquals(Extractor.RESULT_CONTINUE, extractor.read(input, seekPosition)); - assertEquals(Extractor.RESULT_END_OF_INPUT, extractor.read(input, seekPosition)); - - assertEquals(3, output.track.samples.size()); - assertEquals("audio/ape", output.track.format.sampleMimeType); - assertEquals(44100, output.track.format.sampleRate); - assertEquals(2, output.track.format.channelCount); - assertEquals(6, output.track.format.initializationData.get(0).length); - assertArrayEquals(new byte[] {(byte) 0x82, 0x0F, (byte) 0x88, 0x13, 0, 0}, - output.track.format.initializationData.get(0)); - assertEquals(73728 * 4, littleEndianInt(output.track.samples.get(0), 0)); - assertEquals(0, littleEndianInt(output.track.samples.get(0), 4)); - assertEquals(4, output.track.samples.get(0).length - 8); - long expectedBlocks = 73728L * 4 * 2 + 4; - assertEquals(expectedBlocks * C.MICROS_PER_SECOND / 44100, - output.seekMap.getDurationUs()); - assertEquals(44, output.seekMap.getSeekPoints(0).first.position); - assertNotNull(output.seekMap.getSeekPoints(1_000_000).first); - } - - @Test - public void truncatedHeaderFailsWithoutEmittingSamples() throws Exception { - ApeExtractor extractor = new ApeExtractor(); - CaptureOutput output = new CaptureOutput(); - extractor.init(output); - - assertThrows(EOFException.class, - () -> extractor.read(input(new byte[] {'M', 'A', 'C', ' ', (byte) 0x82, 0x0F}), - new PositionHolder())); - assertTrue(output.track.samples.isEmpty()); - } - - private static byte[] apeFile(int version, int frameCount, int frameSize) throws IOException { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - writeInt(output, 0x2043414d); - writeShort(output, version); - writeShort(output, 5000); - writeShort(output, 0); - writeShort(output, 2); - writeInt(output, 44100); - writeInt(output, 0); - writeInt(output, 0); - writeInt(output, frameCount); - writeInt(output, 4); - int firstFrame = 32 + frameCount * 4; - for (int index = 0; index < frameCount; index++) { - writeInt(output, index == 0 ? 0 : firstFrame + index * frameSize); - } - for (int index = 0; index < frameCount * frameSize; index++) { - output.write(index + 1); - } - return output.toByteArray(); - } - - private static ExtractorInput input(byte[] source) { - return new DefaultExtractorInput(new ByteArrayDataReader(source), 0, source.length); - } - - private static int littleEndianInt(byte[] data, int offset) { - return (data[offset] & 0xff) - | ((data[offset + 1] & 0xff) << 8) - | ((data[offset + 2] & 0xff) << 16) - | ((data[offset + 3] & 0xff) << 24); - } - - private static void writeShort(ByteArrayOutputStream output, int value) { - output.write(value & 0xff); - output.write((value >>> 8) & 0xff); - } - - private static void writeInt(ByteArrayOutputStream output, int value) { - output.write(value & 0xff); - output.write((value >>> 8) & 0xff); - output.write((value >>> 16) & 0xff); - output.write((value >>> 24) & 0xff); - } - - private static final class ByteArrayDataReader implements DataReader { - private final ByteArrayInputStream input; - - ByteArrayDataReader(byte[] source) { - input = new ByteArrayInputStream(source); - } - - @Override - public int read(byte[] buffer, int offset, int length) { - return input.read(buffer, offset, length); - } - } - - private static final class CaptureOutput implements ExtractorOutput { - final CaptureTrackOutput track = new CaptureTrackOutput(); - SeekMap seekMap; - - @Override - public TrackOutput track(int id, int type) { - assertEquals(0, id); - assertEquals(C.TRACK_TYPE_AUDIO, type); - return track; - } - - @Override - public void endTracks() { - } - - @Override - public void seekMap(SeekMap seekMap) { - this.seekMap = seekMap; - } - } - - private static final class CaptureTrackOutput implements TrackOutput { - Format format; - final java.util.List samples = new java.util.ArrayList<>(); - - @Override - public void format(Format format) { - this.format = format; - } - - @Override - public int sampleData(DataReader input, int length, boolean allowEndOfInput, int sampleDataPart) - throws IOException { - byte[] data = new byte[length]; - int read = input.read(data, 0, length); - if (read == -1 && allowEndOfInput) return 0; - if (read != length) throw new EOFException(); - return read; - } - - @Override - public void sampleData(ParsableByteArray data, int length, int sampleDataPart) { - byte[] sample = new byte[length]; - data.readBytes(sample, 0, length); - samples.add(sample); - } - - @Override - public void sampleMetadata(long timeUs, int flags, int size, int offset, CryptoData cryptoData) { - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/AutoLoadControlTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/AutoLoadControlTest.java index 49a4bb9cdc..d9a93bc583 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/AutoLoadControlTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/AutoLoadControlTest.java @@ -10,59 +10,6 @@ import static org.junit.Assert.assertTrue; public class AutoLoadControlTest { - @Test - public void seekRecoveryUsesMedia3UserActionThresholdWithoutWeakeningRebuffer() { - assertEquals(ExoPlaybackThresholdCoordinator.Episode.REBUFFER, - AutoLoadControl.playbackEpisode(true, true)); - assertEquals(ExoPlaybackThresholdCoordinator.Episode.SEEK, - AutoLoadControl.playbackEpisode(false, true)); - assertEquals(ExoPlaybackThresholdCoordinator.Episode.STARTUP, - AutoLoadControl.playbackEpisode(false, false)); - - ExoPlaybackThresholdPolicy.Decision policy = ExoPlaybackThresholdPolicy.resolve( - ExoPlaybackThresholdPolicy.Inputs.unknown()); - ExoPlaybackThresholdCoordinator.Selection criticalSeek = - new ExoPlaybackThresholdCoordinator.Selection( - com.fongmi.android.tv.player.PlaybackAutoContext.SessionToken.none(), - ExoPlaybackThresholdCoordinator.Episode.SEEK, - 8_000, - 15_000, - policy, - true, - ExoPlaybackThresholdCoordinator.Action.LOCK); - ExoPlaybackThresholdCoordinator.Selection conservativeRebuffer = - new ExoPlaybackThresholdCoordinator.Selection( - com.fongmi.android.tv.player.PlaybackAutoContext.SessionToken.none(), - ExoPlaybackThresholdCoordinator.Episode.REBUFFER, - 8_000, - 15_000, - policy, - true, - ExoPlaybackThresholdCoordinator.Action.LOCK); - - assertEquals(1_000, criticalSeek.thresholdMs()); - assertEquals(15_000, conservativeRebuffer.thresholdMs()); - } - - @Test - public void seekMarkerIsSessionBoundAndExpires() { - ExoPlaybackThresholdCoordinator coordinator = - new ExoPlaybackThresholdCoordinator(); - com.fongmi.android.tv.player.PlaybackAutoContext.SessionToken session = - new com.fongmi.android.tv.player.PlaybackAutoContext.SessionToken( - "p-seek-1", 1); - - coordinator.markSeek(session, 10_000); - - assertTrue(coordinator.isSeekPending(session, 10_001)); - assertFalse(coordinator.isSeekPending( - com.fongmi.android.tv.player.PlaybackAutoContext.SessionToken.none(), - 10_001)); - assertFalse(coordinator.isSeekPending( - session, - 10_000 + ExoPlaybackThresholdCoordinator.SEEK_RECOVERY_TIMEOUT_MS + 1)); - } - @Test public void usesAdaptiveVodRebufferThreshold() { assertFalse(AutoLoadControl.reachedAdaptiveThreshold(2_999_000, 1f, C.TIME_UNSET, 3_000)); diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/AutoPreloadPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/AutoPreloadPolicyTest.java index 96739c5095..a9016a72dd 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/AutoPreloadPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/AutoPreloadPolicyTest.java @@ -39,8 +39,7 @@ public class AutoPreloadPolicyTest { input = new AutoPreloadPolicy.Inputs( input.session(), false, input.route(), input.bufferedMs(), input.mediaBitrateBitsPerSecond(), input.rebufferCount(), input.loading(), - input.trend(), input.throughput(), input.system(), false, false, - Long.MAX_VALUE, 0); + input.trend(), input.throughput(), input.system(), false, false, 0); AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate(input); @@ -207,8 +206,7 @@ public class AutoPreloadPolicyTest { AutoPreloadPolicy.Inputs pressured = new AutoPreloadPolicy.Inputs( safe.session(), safe.sessionMatches(), safe.route(), safe.bufferedMs(), safe.mediaBitrateBitsPerSecond(), safe.rebufferCount(), safe.loading(), - safe.trend(), safe.throughput(), safe.system(), true, false, - Long.MAX_VALUE, 0); + safe.trend(), safe.throughput(), safe.system(), true, false, 0); AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate(pressured); @@ -322,8 +320,7 @@ public class AutoPreloadPolicyTest { AutoPreloadPolicy.Inputs rebuffered = new AutoPreloadPolicy.Inputs( safe.session(), safe.sessionMatches(), safe.route(), safe.bufferedMs(), safe.mediaBitrateBitsPerSecond(), 1, safe.loading(), safe.trend(), - safe.throughput(), safe.system(), false, false, - Long.MAX_VALUE, 0); + safe.throughput(), safe.system(), false, false, 0); AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate(rebuffered); @@ -347,7 +344,6 @@ public class AutoPreloadPolicyTest { safeSystem(), false, false, - Long.MAX_VALUE, 0); AutoPreloadPolicy.Inputs recovered = new AutoPreloadPolicy.Inputs( SESSION, @@ -362,7 +358,6 @@ public class AutoPreloadPolicyTest { safeSystem(), false, false, - Long.MAX_VALUE, 15_000); AutoPreloadPolicy.Decision paused = policy.evaluate(low); @@ -520,20 +515,6 @@ public class AutoPreloadPolicyTest { ForwardBufferTrend.Snapshot trend, boolean memoryPaused, boolean preloadActive) { - return inputs(nowMs, route, bufferedMs, throughput, system, trend, memoryPaused, - preloadActive, Long.MAX_VALUE); - } - - private static AutoPreloadPolicy.Inputs inputs( - long nowMs, - PlaybackRoute route, - long bufferedMs, - AutoPreloadPolicy.ThroughputEvidence throughput, - AutoPreloadPolicy.SystemEvidence system, - ForwardBufferTrend.Snapshot trend, - boolean memoryPaused, - boolean preloadActive, - long foregroundEofRecoveryAgeMs) { return new AutoPreloadPolicy.Inputs( SESSION, true, @@ -547,78 +528,9 @@ public class AutoPreloadPolicyTest { system, memoryPaused, preloadActive, - foregroundEofRecoveryAgeMs, nowMs); } - /** - * The external-loopback route is the one whose throughput estimate is BLOCKED, so the ordinary - * throughput judgments are skipped and only buffer level protects the foreground. These cases - * pin the new yield-on-EOF behavior on exactly that route. - */ - @Test - public void foregroundEofRecoveryYieldsOnBlockedThroughputRoute() { - AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate( - inputs(0, PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, 30_000, - AutoPreloadPolicy.ThroughputEvidence.unknown(), - safeSystem(), stableTrend(0, 30_000), false, false, 0)); - - assertFalse(decision.enabled()); - assertEquals("foreground-eof-recovery", decision.reason()); - } - - @Test - public void yieldOutlastsTheObservedRecoveryBurst() { - AutoPreloadPolicy policy = new AutoPreloadPolicy(); - - // The device burst ran 2-6 s; the window must still be closed well after that. - assertFalse(policy.evaluate( - inputs(0, PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, 30_000, - AutoPreloadPolicy.ThroughputEvidence.unknown(), - safeSystem(), stableTrend(0, 30_000), false, false, 6_000)).enabled()); - assertFalse(policy.evaluate( - inputs(0, PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, 30_000, - AutoPreloadPolicy.ThroughputEvidence.unknown(), - safeSystem(), stableTrend(0, 30_000), false, false, 14_999)).enabled()); - } - - @Test - public void preloadResumesOnceTheYieldWindowExpires() { - // A stale recovery must not suspend preload forever: the window is a delay, not a circuit. - AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate( - inputs(0, PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, 30_000, - AutoPreloadPolicy.ThroughputEvidence.unknown(), - safeSystem(), stableTrend(0, 30_000), false, false, 15_001)); - - assertTrue(decision.enabled()); - assertTrue(decision.threads() >= 1); - } - - @Test - public void neverObservedRecoveryNeverYields() { - // Long.MAX_VALUE is the "no recovery in this process" sentinel. A healthy session must not - // be mistaken for a recovering one, or preload would never start at all. - AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate( - inputs(0, PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, 30_000, - AutoPreloadPolicy.ThroughputEvidence.unknown(), - safeSystem(), stableTrend(0, 30_000), false, false, Long.MAX_VALUE)); - - assertTrue(decision.enabled()); - assertTrue(decision.threads() >= 1); - } - - @Test - public void healthyRouteWithTrustedThroughputIsUnaffectedByStaleRecovery() { - // The yield must not disturb the ordinary trusted path once its window has passed. - AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate( - inputs(0, PlaybackRoute.DIRECT_REMOTE_HTTP, 20_000, - trustedThroughput(0, 30, 30, 30, 200, false), - safeSystem(), stableTrend(0, 20_000), false, false, 60_000)); - - assertTrue(decision.enabled()); - assertTrue(decision.threads() >= 1); - } - private static AutoPreloadPolicy.ThroughputEvidence trustedThroughput( long observedAtMs, long effectiveMbps, diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/DiagnosticEventFieldAllowlistTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/DiagnosticEventFieldAllowlistTest.java deleted file mode 100644 index f5293e457e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/DiagnosticEventFieldAllowlistTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashSet; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Locks the join between a diagnostic producer and the schema allowlist. - * - *

{@link DiagnosticEvent#observed(String, Object)} throws {@code IllegalArgumentException} for - * any name missing from {@code DiagnosticEvent.FIELDS}, and the collector's {@code emit} catches - * that as a collector failure. A missing name therefore does not fail loudly: the whole event - * (including the fields that were valid) is dropped and the export silently loses the line. The - * E-SP11 input-side drop counters were added without the matching allowlist entries, which is - * exactly this failure — the very {@code video.output.summary} line the change was meant to enrich - * disappeared from every diagnostics-on session. - * - *

This test is a source+behaviour hybrid on purpose: the source scan is what notices a new - * {@code observed} literal, and constructing the real event is what notices the allowlist entry - * being absent. Neither half alone catches the regression. - */ -public class DiagnosticEventFieldAllowlistTest { - - private static final Pattern OBSERVED_CALL = Pattern.compile("\\.observed\\(\"([^\"]+)\""); - - @Test - public void everyFieldTheExoCollectorRecordsIsAcceptedByTheSchema() throws Exception { - String source = read(sourcePath( - "main", "java", "com", "fongmi", "android", "tv", "player", "exo", - "ExoDiagnosticCollector.java")); - Set names = new LinkedHashSet<>(); - Matcher matcher = OBSERVED_CALL.matcher(source); - while (matcher.find()) names.add(matcher.group(1)); - assertTrue("No observed() literals found in ExoDiagnosticCollector", names.size() > 5); - // Guard against a vacuous pass if the counters are removed from the collector later. - assertTrue(names.contains("droppedInputBuffers")); - assertTrue(names.contains("skippedInputBuffers")); - assertTrue(names.contains("droppedToKeyframe")); - for (String name : names) { - try { - new DiagnosticEvent("video.output.summary", "trace", "instance", 1, 1) - .observed(name, 0); - } catch (IllegalArgumentException error) { - fail("ExoDiagnosticCollector records \"" + name - + "\" but DiagnosticEvent rejects it, so the whole event is dropped: " - + error.getMessage()); - } - } - } - - @Test - public void theAllowlistCheckIsNotVacuous() { - // Self-proof that the fixture really reaches the validator. - assertThrows(IllegalArgumentException.class, - () -> new DiagnosticEvent("video.output.summary", "trace", "instance", 1, 1) - .observed("notARegisteredDiagnosticField", 0)); - } - - private static String read(Path path) throws Exception { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - private static Path sourcePath(String... parts) { - Path path = Path.of("src", parts[0], parts[1]); - for (int i = 2; i < parts.length; i++) path = path.resolve(parts[i]); - if (Files.exists(path)) return path; - path = Path.of("app", "src", parts[0], parts[1]); - for (int i = 2; i < parts.length; i++) path = path.resolve(parts[i]); - return path; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/DroppedFrameAccountingTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/DroppedFrameAccountingTest.java deleted file mode 100644 index e603f83e1e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/DroppedFrameAccountingTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -public class DroppedFrameAccountingTest { - - private static final long T0 = 1_000L; - - private final DroppedFrameAccounting accounting = new DroppedFrameAccounting(); - - @Test - public void pauseSettledReportKeepsTheTotalButNotTheRate() { - accounting.onPlayingStateChanged(true, T0); - // media3 settles the renderer accumulator from onStopped(), i.e. after playback - // stopped; the frames themselves were dropped during the 10s that just played. - accounting.onPlayingStateChanged(false, T0 + 10_000); - long total = accounting.onDroppedFramesReported(4, true); - - assertEquals(4, total); - assertEquals(4, accounting.pauseSettledDroppedFrames()); - assertEquals(1, accounting.pauseSettledReports()); - assertEquals(10_000, accounting.playTimeMs(T0 + 10_000)); - assertEquals(0.4d, accounting.droppedFramesPerSecond(T0 + 10_000), 1e-9); - } - - @Test - public void playedTimeAccumulatesAcrossPauses() { - accounting.onPlayingStateChanged(true, T0); - accounting.onPlayingStateChanged(false, T0 + 10_000); - accounting.onPlayingStateChanged(true, T0 + 30_000); - accounting.onPlayingStateChanged(false, T0 + 35_000); - - assertEquals(15_000, accounting.playTimeMs(T0 + 35_000)); - } - - @Test - public void openPlayingWindowIsCountedUpToNow() { - accounting.onPlayingStateChanged(true, T0); - - assertEquals(0, accounting.playTimeMs(T0 - 1)); - assertEquals(2_500, accounting.playTimeMs(T0 + 2_500)); - } - - @Test - public void redundantPlayingStateNeverDoubleBillsTheInterval() { - accounting.onPlayingStateChanged(true, T0); - accounting.onPlayingStateChanged(true, T0 + 5_000); - accounting.onPlayingStateChanged(false, T0 + 10_000); - - assertEquals(10_000, accounting.playTimeMs(T0 + 10_000)); - } - - @Test - public void rateIsZeroWithoutMeasuredPlayTime() { - // A single frame with no measured played time must not read as an infinite rate. - accounting.onDroppedFramesReported(1, false); - - assertEquals(0d, accounting.droppedFramesPerSecond(T0), 1e-9); - assertEquals(0d, accounting.droppedFramesPerSecond(T0 + 60_000), 1e-9); - } - - @Test - public void pauseSettledSplitSeparatesTheReportThatArrivedWhileStopped() { - accounting.onPlayingStateChanged(true, T0); - accounting.onDroppedFramesReported(3, false); - accounting.onPlayingStateChanged(false, T0 + 4_000); - accounting.onDroppedFramesReported(5, true); - accounting.onDroppedFramesReported(2, true); - - assertEquals(10, accounting.droppedFrames()); - assertEquals(7, accounting.pauseSettledDroppedFrames()); - assertEquals(2, accounting.pauseSettledReports()); - assertEquals(2.5d, accounting.droppedFramesPerSecond(T0 + 4_000), 1e-9); - } - - @Test - public void negativeReportNeverSubtracts() { - accounting.onPlayingStateChanged(true, T0); - accounting.onPlayingStateChanged(false, T0 + 1_000); - accounting.onDroppedFramesReported(-4, true); - - assertEquals(0, accounting.droppedFrames()); - assertEquals(0, accounting.pauseSettledDroppedFrames()); - assertEquals(1, accounting.pauseSettledReports()); - } - - @Test - public void resetClearsEveryAccumulator() { - accounting.onPlayingStateChanged(true, T0); - accounting.onDroppedFramesReported(9, false); - accounting.onPlayingStateChanged(false, T0 + 2_000); - - accounting.reset(); - - assertEquals(0, accounting.droppedFrames()); - assertEquals(0, accounting.pauseSettledDroppedFrames()); - assertEquals(0, accounting.pauseSettledReports()); - assertEquals(0, accounting.playTimeMs(T0 + 60_000)); - assertEquals(0d, accounting.droppedFramesPerSecond(T0 + 60_000), 1e-9); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAudioCodecSelectorTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAudioCodecSelectorTest.java deleted file mode 100644 index 9507d3658a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAudioCodecSelectorTest.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertSame; - -import androidx.media3.exoplayer.mediacodec.MediaCodecInfo; -import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; - -import org.junit.Test; - -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -public class ExoAudioCodecSelectorTest { - - private static final String AUDIO_MIME = "audio/mp4a-latm"; - private static final String ALAC_MIME = "audio/alac"; - - @Test - public void audioDecoders_areStableAndHardwareFirst() throws Exception { - MediaCodecInfo softwareA = codec("c2.android.aac.decoder", false, true); - MediaCodecInfo hardwareA = codec("c2.vendor.aac.decoder", true, false); - MediaCodecInfo unknown = codec("omx.vendor.unknown.decoder", false, false); - MediaCodecInfo hardwareB = codec("omx.vendor.aac.decoder", true, false); - MediaCodecInfo softwareB = codec("omx.google.aac.decoder", false, true); - ExoAudioCodecSelector selector = new ExoAudioCodecSelector( - fixed(List.of(softwareA, hardwareA, unknown, hardwareB, softwareB))); - - List actual = selector.getDecoderInfos( - AUDIO_MIME, false, false); - - assertEquals(List.of(hardwareA, hardwareB, unknown, softwareA, softwareB), - actual); - } - - @Test - public void audioQuery_isCachedPerSecurityAndTunnelingKey() throws Exception { - AtomicInteger queryCount = new AtomicInteger(); - List infos = List.of( - codec("c2.vendor.aac.decoder", true, false)); - MediaCodecSelector delegate = (mimeType, secure, tunneling) -> { - queryCount.incrementAndGet(); - return infos; - }; - ExoAudioCodecSelector selector = new ExoAudioCodecSelector(delegate); - - List first = selector.getDecoderInfos( - AUDIO_MIME, false, false); - List second = selector.getDecoderInfos( - AUDIO_MIME, false, false); - selector.getDecoderInfos(AUDIO_MIME, true, false); - selector.getDecoderInfos(AUDIO_MIME, false, true); - - assertSame(first, second); - assertEquals(3, queryCount.get()); - } - - @Test - public void nonAudioQuery_isDelegatedWithoutSortingOrCaching() throws Exception { - AtomicInteger queryCount = new AtomicInteger(); - List infos = List.of( - codec("c2.android.avc.decoder", false, true), - codec("c2.vendor.avc.decoder", true, false)); - MediaCodecSelector delegate = (mimeType, secure, tunneling) -> { - queryCount.incrementAndGet(); - return infos; - }; - ExoAudioCodecSelector selector = new ExoAudioCodecSelector(delegate); - - List first = selector.getDecoderInfos( - "video/avc", false, false); - List second = selector.getDecoderInfos( - "video/avc", false, false); - - assertSame(infos, first); - assertSame(infos, second); - assertEquals(2, queryCount.get()); - } - - @Test - public void alacDecoders_areRoutedToFfmpeg() throws Exception { - AtomicInteger queryCount = new AtomicInteger(); - MediaCodecSelector delegate = (mimeType, secure, tunneling) -> { - queryCount.incrementAndGet(); - return List.of(codec("c2.vivo.alac.decoder", true, false)); - }; - ExoAudioCodecSelector selector = new ExoAudioCodecSelector(delegate); - - assertEquals(List.of(), selector.getDecoderInfos(ALAC_MIME, false, false)); - assertEquals(List.of(), selector.getDecoderInfos(ALAC_MIME, false, false)); - assertEquals(0, queryCount.get()); - } - - private static MediaCodecSelector fixed(List infos) { - return (mimeType, secure, tunneling) -> infos; - } - - private static MediaCodecInfo codec( - String name, boolean hardwareAccelerated, boolean softwareOnly) { - return MediaCodecInfo.newInstance( - name, - name.contains("avc") ? "video/avc" : AUDIO_MIME, - name.contains("avc") ? "video/avc" : AUDIO_MIME, - null, - hardwareAccelerated, - softwareOnly, - !softwareOnly, - false, - false); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAudioDirectFailureMemoryTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAudioDirectFailureMemoryTest.java deleted file mode 100644 index e11cc6537f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAudioDirectFailureMemoryTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -import androidx.media3.common.AudioAttributes; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; - -import org.junit.Test; - -import java.util.List; - -public class ExoAudioDirectFailureMemoryTest { - - @Test - public void confirmedEntry_expiresWithoutRefreshingOnLookup() { - ExoAudioDirectFailureMemory memory = new ExoAudioDirectFailureMemory(); - ExoAudioDirectFailureMemory.Key key = key("one", 1); - assertTrue(memory.confirm(key, memory.generation(), 1_000)); - assertTrue(memory.contains(key, 1_000 + ExoAudioDirectFailureMemory.TTL_MS - 1)); - assertFalse(memory.contains(key, 1_000 + ExoAudioDirectFailureMemory.TTL_MS)); - assertFalse(memory.hasMedia(key.media(), 1_000 + ExoAudioDirectFailureMemory.TTL_MS)); - } - - @Test - public void capacity_evictsOldestConfirmation() { - ExoAudioDirectFailureMemory memory = new ExoAudioDirectFailureMemory(); - for (int i = 0; i <= ExoAudioDirectFailureMemory.MAX_ENTRIES; i++) { - assertTrue(memory.confirm(key("media-" + i, 1), memory.generation(), i)); - } - assertFalse(memory.contains(key("media-0", 1), 100)); - assertTrue(memory.contains(key("media-1", 1), 100)); - assertTrue(memory.contains(key("media-32", 1), 100)); - } - - @Test - public void capabilityChange_rejectsLateConfirmation() { - ExoAudioDirectFailureMemory memory = new ExoAudioDirectFailureMemory(); - long oldGeneration = memory.generation(); - assertTrue(memory.confirm(key("one", 1), oldGeneration, 0)); - memory.invalidate(); - assertFalse(memory.contains(key("one", 1), 1)); - assertFalse(memory.confirm(key("one", 1), oldGeneration, 2)); - assertTrue(memory.confirm(key("one", 1), memory.generation(), 2)); - } - - @Test - public void unknownRouteAndMedia_doNotBecomeSharedEvidence() { - ExoAudioDirectFailureMemory memory = new ExoAudioDirectFailureMemory(); - ExoAudioDirectFailureMemory.Key unknownRoute = new ExoAudioDirectFailureMemory.Key( - "media", "format", AudioAttributes.DEFAULT, null); - ExoAudioDirectFailureMemory.Key unknownMedia = new ExoAudioDirectFailureMemory.Key( - null, "format", AudioAttributes.DEFAULT, key("one", 1).route()); - assertFalse(memory.confirm(unknownRoute, memory.generation(), 0)); - assertFalse(memory.confirm(unknownMedia, memory.generation(), 0)); - } - - @Test - public void exactMediaAndRoute_areIsolated() { - ExoAudioDirectFailureMemory memory = new ExoAudioDirectFailureMemory(); - assertTrue(memory.confirm(key("one", 1), memory.generation(), 0)); - assertFalse(memory.contains(key("two", 1), 1)); - assertFalse(memory.contains(key("one", 2), 1)); - assertFalse(memory.hasMedia(key("two", 1).media(), 1)); - } - - @Test - public void fingerprints_keepQueryTrackAndCodecInitializationIdentity() { - String url = "https://private.example/video?token=secret"; - String id = ExoAudioDirectFailureMemory.mediaId(url); - assertEquals(64, id.length()); - assertFalse(id.contains("private")); - assertFalse(id.contains("secret")); - assertNotEquals(id, ExoAudioDirectFailureMemory.mediaId(url + "2")); - Format format = new Format.Builder().setId("audio-1") - .setSampleMimeType(MimeTypes.AUDIO_AAC).setCodecs("mp4a.40.2") - .setSampleRate(44_100).setChannelCount(2) - .setInitializationData(List.of(new byte[]{0x12, 0x10})).build(); - String formatId = ExoAudioDirectFailureMemory.formatId(format); - assertEquals(formatId, ExoAudioDirectFailureMemory.formatId(format.buildUpon().build())); - assertNotEquals(formatId, ExoAudioDirectFailureMemory.formatId( - format.buildUpon().setId("audio-2").build())); - assertNotEquals(formatId, ExoAudioDirectFailureMemory.formatId( - format.buildUpon().setInitializationData(List.of(new byte[]{0x12, 0x11})).build())); - assertNotEquals(formatId, ExoAudioDirectFailureMemory.formatId( - format.buildUpon().setSampleRate(48_000).build())); - } - - private static ExoAudioDirectFailureMemory.Key key(String media, int routeId) { - return new ExoAudioDirectFailureMemory.Key(ExoAudioDirectFailureMemory.mediaId(media), - "format", AudioAttributes.DEFAULT, - new ExoAudioDirectFailureMemory.Route(routeId, 2, 3, 4, 5)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAudioOutputStateTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAudioOutputStateTest.java deleted file mode 100644 index cf30b9f3f5..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAudioOutputStateTest.java +++ /dev/null @@ -1,146 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import androidx.media3.common.C; -import androidx.media3.exoplayer.audio.AudioOutput; -import androidx.media3.exoplayer.audio.AudioOutputProvider; - -import org.junit.Test; - -import java.lang.reflect.Proxy; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -public class ExoAudioOutputStateTest { - - @Test - public void lateReleaseOfIdenticalConfiguration_keepsNewOutput() throws Exception { - ExoAudioOutputState state = new ExoAudioOutputState(); - AtomicInteger oldReleases = new AtomicInteger(); - AudioOutput old = state.track(output(oldReleases::incrementAndGet), config(false, false)); - ExoAudioOutputState.Snapshot previous = state.snapshot(); - state.track(output(() -> { }), config(false, false)); - ExoAudioOutputState.Snapshot current = state.snapshot(); - assertEquals(previous, current); - assertNotSame(previous, current); - - var executor = Executors.newSingleThreadExecutor(); - try { - executor.submit(old::release).get(2, TimeUnit.SECONDS); - } finally { - executor.shutdownNow(); - } - - assertSame(current, state.snapshot()); - assertTrue(state.snapshot().initialized()); - assertEquals(1, oldReleases.get()); - } - - @Test - public void currentRelease_clearsStateBeforeBackendCompletes() { - ExoAudioOutputState state = new ExoAudioOutputState(); - AtomicInteger releases = new AtomicInteger(); - AudioOutput current = state.track(output(() -> { - assertFalse(state.snapshot().initialized()); - releases.incrementAndGet(); - }), config(false, false)); - - current.release(); - - assertSame(ExoAudioOutputState.Snapshot.empty(), state.snapshot()); - assertEquals(1, releases.get()); - } - - @Test - public void newestRelease_doesNotRestoreOlderOutput() { - ExoAudioOutputState state = new ExoAudioOutputState(); - AudioOutput old = state.track(output(() -> { }), config(false, false)); - AudioOutput current = state.track(output(() -> { }), config(true, false)); - - current.release(); - assertFalse(state.snapshot().initialized()); - old.release(); - assertFalse(state.snapshot().initialized()); - } - - @Test - public void oldReleaseAfterAnotherSeek_cannotClearThirdOutput() { - ExoAudioOutputState state = new ExoAudioOutputState(); - AudioOutput first = state.track(output(() -> { }), config(false, false)); - AudioOutput second = state.track(output(() -> { }), config(false, false)); - state.track(output(() -> { }), config(false, true)); - ExoAudioOutputState.Snapshot third = state.snapshot(); - - second.release(); - first.release(); - - assertSame(third, state.snapshot()); - assertTrue(third.offload()); - } - - @Test - public void separatePlayers_doNotShareOutputState() { - ExoAudioOutputState first = new ExoAudioOutputState(); - ExoAudioOutputState second = new ExoAudioOutputState(); - AudioOutput firstOutput = first.track(output(() -> { }), config(false, false)); - second.track(output(() -> { }), config(true, false)); - ExoAudioOutputState.Snapshot secondSnapshot = second.snapshot(); - - firstOutput.release(); - - assertFalse(first.snapshot().initialized()); - assertSame(secondSnapshot, second.snapshot()); - assertTrue(secondSnapshot.tunneling()); - } - - @Test - public void releaseFailure_doesNotLeavePublishedOutput() { - ExoAudioOutputState state = new ExoAudioOutputState(); - AudioOutput output = state.track(output(() -> { - throw new IllegalStateException("release failed"); - }), config(false, false)); - - assertThrows(IllegalStateException.class, output::release); - assertFalse(state.snapshot().initialized()); - } - - @Test - public void snapshot_usesActualOutputConfiguration() { - ExoAudioOutputState state = new ExoAudioOutputState(); - assertFalse(state.snapshot().initialized()); - state.track(output(() -> { }), config(true, false)); - - ExoAudioOutputState.Snapshot snapshot = state.snapshot(); - assertEquals(C.ENCODING_PCM_16BIT, snapshot.encoding()); - assertEquals(48_000, snapshot.sampleRate()); - assertEquals(2, snapshot.channels()); - assertTrue(snapshot.tunneling()); - assertFalse(snapshot.offload()); - } - - private static AudioOutput output(Runnable release) { - return (AudioOutput) Proxy.newProxyInstance(AudioOutput.class.getClassLoader(), - new Class[]{AudioOutput.class}, (proxy, method, args) -> { - if (method.getName().equals("release")) release.run(); - return null; - }); - } - - private static AudioOutputProvider.OutputConfig config(boolean tunneling, boolean offload) { - return new AudioOutputProvider.OutputConfig.Builder() - .setEncoding(C.ENCODING_PCM_16BIT) - .setSampleRate(48_000) - .setChannelMask(12) - .setBufferSize(4096) - .setIsTunneling(tunneling) - .setIsOffload(offload) - .build(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAutomaticVideoConstraintPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAutomaticVideoConstraintPolicyTest.java index 62e4cf9ef5..1e46f74162 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAutomaticVideoConstraintPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoAutomaticVideoConstraintPolicyTest.java @@ -351,6 +351,68 @@ public class ExoAutomaticVideoConstraintPolicyTest { assertEquals(BASELINE, recovered.state().effective()); } + @Test + public void throughputFaultStepsDownTheLadderAndRecoversLikeOtherFaults() { + // 起播固定选最高画质后原生 ABR 不再兜底吞吐,带宽不足/重缓冲要靠 THROUGHPUT 证据降档。 + ExoAutomaticVideoConstraintPolicy.SelectedTrack selected = + new ExoAutomaticVideoConstraintPolicy.SelectedTrack(3840, 2160, 60, 18_000_000); + ExoAutomaticVideoConstraintPolicy.Input input = input( + ExoAutomaticVideoConstraintPolicy.Environment.unknown(), true, + selected, ExoAutomaticVideoConstraintPolicy.ResourceMode.ADAPTIVE_VARIANTS, 1_000); + + ExoAutomaticVideoConstraintPolicy.Decision first = + ExoAutomaticVideoConstraintPolicy.onFault( + ExoAutomaticVideoConstraintPolicy.initial(BASELINE), input, + ExoAutomaticVideoConstraintPolicy.Fault.THROUGHPUT); + // 带宽回调很频繁,冷却期内的重复证据不能继续往下踩。 + ExoAutomaticVideoConstraintPolicy.Decision repeated = + ExoAutomaticVideoConstraintPolicy.onFault( + first.state(), input(first.state(), selected, 5_000), + ExoAutomaticVideoConstraintPolicy.Fault.THROUGHPUT); + // 冷却结束后仍然吞吐不足则继续降一档。 + ExoAutomaticVideoConstraintPolicy.Decision stepped = + ExoAutomaticVideoConstraintPolicy.onFault( + repeated.state(), + input(repeated.state(), + new ExoAutomaticVideoConstraintPolicy.SelectedTrack(2560, 1440, 60, 11_000_000), + 17_000), + ExoAutomaticVideoConstraintPolicy.Fault.THROUGHPUT); + + assertEquals(TIERS.get(1), first.state().effective()); + assertTrue(first.faultStepped()); + assertTrue(first.faultActive()); + assertEquals(TIERS.get(1), repeated.state().effective()); + assertFalse(repeated.faultStepped()); + assertEquals(ExoAutomaticVideoConstraintPolicy.Action.FAULT_COOLDOWN, repeated.action()); + assertEquals(TIERS.get(2), stepped.state().effective()); + assertTrue(stepped.faultStepped()); + } + + @Test + public void throughputFaultNeverFallsBelowTheLowestTier() { + // 反复吞吐不足时必须停在最低档,不能越降越低把画面降没了。 + ExoAutomaticVideoConstraintPolicy.State state = + ExoAutomaticVideoConstraintPolicy.initial(BASELINE); + ExoAutomaticVideoConstraintPolicy.Limit lowest = TIERS.get(TIERS.size() - 1); + long now = 1_000; + for (int i = 0; i < TIERS.size() + 3; i++) { + ExoAutomaticVideoConstraintPolicy.Limit effective = state.effective(); + ExoAutomaticVideoConstraintPolicy.Decision decision = + ExoAutomaticVideoConstraintPolicy.onFault( + state, + input(state, + new ExoAutomaticVideoConstraintPolicy.SelectedTrack( + effective.width(), effective.height(), + effective.frameRate(), effective.maxVideoBitrate()), + now), + ExoAutomaticVideoConstraintPolicy.Fault.THROUGHPUT); + state = decision.state(); + now += ExoAutomaticVideoConstraintPolicy.FAULT_STEP_COOLDOWN_MS + 1; + } + + assertEquals(lowest, state.effective()); + } + @Test public void progressiveModeNeverReportsABandwidthDowngradeAction() { ExoAutomaticVideoConstraintPolicy.Decision decision = evaluate( diff --git a/app/src/test/java/com/fongmi/android/tv/player/BufferingStallWatchdogTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoBufferingStallWatchdogTest.java similarity index 71% rename from app/src/test/java/com/fongmi/android/tv/player/BufferingStallWatchdogTest.java rename to app/src/test/java/com/fongmi/android/tv/player/exo/ExoBufferingStallWatchdogTest.java index f6b76c3825..6fd395934b 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/BufferingStallWatchdogTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoBufferingStallWatchdogTest.java @@ -1,29 +1,28 @@ -package com.fongmi.android.tv.player; +package com.fongmi.android.tv.player.exo; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import org.junit.Test; -public class BufferingStallWatchdogTest { +public class ExoBufferingStallWatchdogTest { - private static final long TIMEOUT = BufferingStallWatchdog.STALL_TIMEOUT_MS; - private static final long STALL = BufferingStallWatchdog.STALL_TIMEOUT_MS; + private static final long TIMEOUT = ExoBufferingStallWatchdog.STALL_TIMEOUT_MS; + private static final long STALL = ExoBufferingStallWatchdog.STALL_TIMEOUT_MS; private static final long SEGMENT_PROGRESS_MARGIN_MS = - BufferingStallWatchdog.SEGMENT_PROGRESS_MARGIN_MS; - private static final long LOADING_TIMEOUT = BufferingStallWatchdog.LOADING_STALL_TIMEOUT_MS; + ExoBufferingStallWatchdog.SEGMENT_PROGRESS_MARGIN_MS; + private static final long LOADING_TIMEOUT = ExoBufferingStallWatchdog.LOADING_STALL_TIMEOUT_MS; @Test public void staysQuietUntilArmed() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); assertFalse(watchdog.isArmed()); assertFalse(watchdog.shouldTimeout(LOADING_TIMEOUT * 10, 0, 0, false)); } @Test public void timesOutOnlyWhenNeitherPositionNorBufferAdvances() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); assertFalse(watchdog.shouldTimeout(TIMEOUT - 1, 5_000, 9_000, false)); assertTrue(watchdog.shouldTimeout(TIMEOUT, 5_000, 9_000, false)); @@ -31,7 +30,7 @@ public class BufferingStallWatchdogTest { @Test public void growingBufferIsProgressEvenWhilePositionStandsStill() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // A normal rebuffer: position frozen, buffered end still climbing. watchdog.observe(TIMEOUT - 1, 5_000, 12_000); @@ -41,7 +40,7 @@ public class BufferingStallWatchdogTest { @Test public void advancingPositionIsProgressEvenWhileBufferStandsStill() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); watchdog.observe(TIMEOUT - 1, 7_000, 9_000); assertFalse(watchdog.shouldTimeout(TIMEOUT, 7_000, 9_000, false)); @@ -49,7 +48,7 @@ public class BufferingStallWatchdogTest { @Test public void loadingSourceGetsTheLongerCeiling() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // A remote Matroska Cues fetch produces no samples yet is not stalled. assertFalse(watchdog.shouldTimeout(TIMEOUT, 5_000, 9_000, true)); @@ -66,28 +65,16 @@ public class BufferingStallWatchdogTest { @Test public void resetDisarms() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); watchdog.reset(); assertFalse(watchdog.isArmed()); assertFalse(watchdog.shouldTimeout(LOADING_TIMEOUT * 10, 5_000, 9_000, false)); } - @Test - public void stalledDurationMeasuresTimeSinceTheLastProgress() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); - assertTrue("a disarmed watchdog has no measured stall", watchdog.stalledDurationMs(50_000) < 0); - watchdog.arm(1_000, 5_000, 9_000); - assertEquals(0, watchdog.stalledDurationMs(1_000)); - // Progress moves the clock forward, so the reported stall restarts from there. - watchdog.observe(4_000, 5_000, 12_000); - assertEquals(0, watchdog.stalledDurationMs(4_000)); - assertEquals(TIMEOUT, watchdog.stalledDurationMs(4_000 + TIMEOUT)); - } - @Test public void observeArmsWhenNotYetArmed() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.observe(1_000, 5_000, 9_000); assertTrue(watchdog.isArmed()); assertFalse(watchdog.shouldTimeout(1_000 + TIMEOUT - 1, 5_000, 9_000, false)); @@ -96,17 +83,14 @@ public class BufferingStallWatchdogTest { @Test public void timeoutMustOutlastMaxRebufferThreshold() { - // The engine-specific thresholds are asserted where they are visible: - // Exo's MAX_STREAMING_REBUFFER_MS in ExoPlaybackThresholdPolicyTest, MPV's 8 s ceiling - // here. This test only pins the shared watchdog's own floor. - assertTrue(BufferingStallWatchdog.STALL_TIMEOUT_MS > 8_000L); - assertTrue(BufferingStallWatchdog.LOADING_STALL_TIMEOUT_MS - > BufferingStallWatchdog.STALL_TIMEOUT_MS); + // A LoadControl still filling its rebuffer threshold must never be killed. + assertTrue(ExoBufferingStallWatchdog.STALL_TIMEOUT_MS + > ExoPlaybackThresholdPolicy.MAX_STREAMING_REBUFFER_MS); } @Test public void regressedSampleRearmsBecauseItIsADiscontinuity() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 300_000, 320_000); // A backward seek drops position and buffered end well below the baseline. Treating // that as "no progress" would make every later sample satisfy the criterion and time @@ -117,21 +101,9 @@ public class BufferingStallWatchdogTest { assertTrue(watchdog.shouldTimeout(TIMEOUT / 2 + TIMEOUT, 60_000, 62_000, false)); } - @Test - public void aLateBackwardSeekIsRebasedBeforeTheTimeoutCheck() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); - watchdog.arm(0, 300_000, 320_000); - long now = TIMEOUT + 1_000; - // This is the order used by PlayerManager's polling loop: observe the sample first, - // then evaluate it. A seek that arrives after the ordinary stall window must not be - // mistaken for a frozen session using the pre-seek baseline. - watchdog.observe(now, 60_000, 62_000); - assertFalse(watchdog.shouldTimeout(now, 60_000, 62_000, false)); - } - @Test public void repeatingLargeRegressionCannotDeferTheTimeoutForever() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // Alternating growth and a >tolerance regression rebaselines the clock every tick. // Without an episode ceiling this defers the timeout indefinitely while the position @@ -148,18 +120,18 @@ public class BufferingStallWatchdogTest { watchdog.observe(now, 5_000, buffered); } assertTrue("episode ceiling must bound a repeating regression cycle", fired); - assertTrue(now <= BufferingStallWatchdog.EPISODE_CEILING_MS + 2_000); + assertTrue(now <= ExoBufferingStallWatchdog.EPISODE_CEILING_MS + 2_000); } @Test public void ceilingSparesAGenuinelyProgressingEpisode() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // A large file on a slow link can spend well past the ceiling steadily filling its // rebuffer threshold. The ceiling must not kill it just because time elapsed. long now = 0; long buffered = 9_000; - while (now < BufferingStallWatchdog.EPISODE_CEILING_MS + 2_000) { + while (now < ExoBufferingStallWatchdog.EPISODE_CEILING_MS + 2_000) { now += 1_000; buffered += 200; assertFalse("fired at " + now + "ms despite progress", @@ -170,7 +142,7 @@ public class BufferingStallWatchdogTest { @Test public void ceilingSparesProgressAfterABackwardSeek() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); // Start high, then seek far back. If the progress watermarks stayed at the pre-seek // values, every later sample would read as zero progress (clamped) and the ceiling // would kill a session that is actually advancing from the new position. @@ -178,7 +150,7 @@ public class BufferingStallWatchdogTest { watchdog.observe(1_000, 30_000, 32_000); long position = 30_000; long buffered = 32_000; - for (long now = 2_000; now < BufferingStallWatchdog.EPISODE_CEILING_MS + 5_000; + for (long now = 2_000; now < ExoBufferingStallWatchdog.EPISODE_CEILING_MS + 5_000; now += 1_000) { position += 1_000; buffered += 1_000; @@ -190,7 +162,7 @@ public class BufferingStallWatchdogTest { @Test public void stallAfterABackwardSeekStillFires() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 600_000, 620_000); watchdog.observe(1_000, 30_000, 32_000); // Frozen from the new position onward, so the normal criterion must still report it. @@ -199,17 +171,17 @@ public class BufferingStallWatchdogTest { @Test public void ceilingStillFiresWhenNetProgressIsBelowTheMargin() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // Movement exists but is far below the margin, so this is not a working session. - long now = BufferingStallWatchdog.EPISODE_CEILING_MS; - long buffered = 9_000 + BufferingStallWatchdog.SEGMENT_PROGRESS_MARGIN_MS - 1_000; + long now = ExoBufferingStallWatchdog.EPISODE_CEILING_MS; + long buffered = 9_000 + ExoBufferingStallWatchdog.SEGMENT_PROGRESS_MARGIN_MS - 1_000; assertTrue(watchdog.shouldTimeout(now, 5_000, buffered, true)); } @Test public void oscillationWhollyAboveTheArmWatermarkStillTerminates() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // The severity case: a mid-playback rebuffer arms with buffered already high, and the // oscillation sits entirely above arm + margin. If the progress watermarks stayed pinned @@ -233,7 +205,7 @@ public class BufferingStallWatchdogTest { @Test public void episodeCeilingDoesNotCountPausedTime() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); // A paused session re-arms every tick, so the ceiling never accumulates. long now = 0; for (int i = 0; i < 300; i++) { @@ -248,7 +220,7 @@ public class BufferingStallWatchdogTest { @Test public void toleranceDipWithGrowthKeepsTheHigherBaseline() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_300); // Position grows while buffered dips within tolerance: the growth branch runs, and it // must not lower the buffered baseline, or the later rebound would count as progress @@ -261,7 +233,7 @@ public class BufferingStallWatchdogTest { @Test public void oscillatingBufferedEndStillTimesOut() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // Buffered end dithers within the jitter tolerance while position stays frozen. // Re-arming on each dip would reset the clock forever and never report the stall. @@ -275,16 +247,16 @@ public class BufferingStallWatchdogTest { @Test public void jitterBelowToleranceDoesNotRearm() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); - watchdog.observe(1_000, 5_000, 9_000 - (BufferingStallWatchdog.DISCONTINUITY_TOLERANCE_MS - 1)); + watchdog.observe(1_000, 5_000, 9_000 - (ExoBufferingStallWatchdog.DISCONTINUITY_TOLERANCE_MS - 1)); // Clock kept running from the original arm, so the original deadline still applies. assertTrue(watchdog.shouldTimeout(TIMEOUT, 5_000, 9_000, false)); } @Test public void aBackwardSeekDoesNotInheritTheOldBaseline() { - BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); watchdog.arm(0, 600_000, 620_000); // Seek from 10:00 back to 00:30 while buffering. watchdog.observe(1_000, 30_000, 32_000); diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicyTest.java deleted file mode 100644 index 4fe59f55e7..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicyTest.java +++ /dev/null @@ -1,1668 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import android.media.AudioManager; - -import androidx.media3.common.AudioAttributes; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.PlaybackException; -import androidx.media3.common.util.Clock; -import androidx.media3.common.util.StuckPlayerException; -import androidx.media3.common.util.Util; -import androidx.media3.exoplayer.audio.AudioOffloadSupport; -import androidx.media3.exoplayer.audio.AudioOutput; -import androidx.media3.exoplayer.audio.AudioOutputProvider; -import androidx.media3.exoplayer.ExoPlaybackException; -import androidx.media3.exoplayer.Renderer; -import androidx.media3.exoplayer.analytics.PlayerId; - -import org.junit.Test; -import org.junit.Ignore; - -import com.github.catvod.crawler.diagnostics.DiagnosticEvent; - -import java.lang.reflect.Field; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; -import java.lang.reflect.Proxy; -import java.nio.ByteBuffer; - -public class ExoCompressedAudioDirectPolicyTest { - - @Test - public void firstPlayback_fullStartupBufferRecoversAt800MsOnlyOnce() throws Exception { - StartupFixture f = new StartupFixture(4096); - f.write(4096, 10); - assertNull(f.poll(0, true)); - assertNull(f.poll(799, true)); - assertSame(f.memory.format, f.poll(1, true)); - assertNull(f.poll(10_000, true)); - assertSame(AudioOutputProvider.FormatSupport.UNSUPPORTED, - f.memory.provider.getFormatSupport(formatConfig(f.memory.format))); - assertTrue(f.memory.policy.consumePcmFallbackRequest()); - assertFalse(f.memory.policy.consumePcmFallbackRequest()); - } - - @Test - public void startupWaitBeginsAtSufficientData_notFirstWrite() throws Exception { - StartupFixture f = new StartupFixture(4096); - f.write(4095, 1); - assertNull(f.poll(10_000, true)); - f.write(1, 1); - assertNull(f.poll(0, true)); - assertNull(f.poll(799, true)); - assertSame(f.memory.format, f.poll(1, true)); - } - - @Test - public void completeAccessUnitsLowerStartupThresholdWithoutShrinkingCapacity() throws Exception { - StartupFixture f = new StartupFixture(256 * 1024); - f.memory.environment.adjustThreshold = true; - f.write(1024, 8); // 185 ms at 44.1 kHz: not enough yet. - assertEquals(0, f.memory.environment.requestedThreshold); - f.write(128, 1); // Nine complete AAC LC AUs cover 209 ms. - assertEquals(1152, f.memory.environment.requestedThreshold); - assertEquals(256 * 1024, f.memory.environment.buffer.capacityBytes()); - assertEquals(256 * 1024, f.memory.environment.buffer.sizeBytes()); - assertNull(f.poll(0, true)); - assertSame(f.memory.format, f.poll(800, true)); - } - - @Test - public void clampedStartupThresholdNeedsMoreDataAndReadbackFailureKeepsOriginal() throws Exception { - StartupFixture f = new StartupFixture(8192); - f.memory.environment.adjustThreshold = true; - f.memory.environment.minimumThreshold = 4096; - f.write(1024, 10); - assertNull(f.poll(2000, true)); - f.write(3072, 10); - assertNull(f.poll(0, true)); - assertSame(f.memory.format, f.poll(800, true)); - - StartupFixture unsupported = new StartupFixture(8192); - unsupported.write(1024, 10); - assertNull(unsupported.poll(10_000, true)); - assertFalse(unsupported.memory.policy.consumePcmFallbackRequest()); - } - - @Test - public void rejectedAndPartialWritesDoNotCountWholeAccessUnits() throws Exception { - StartupFixture f = new StartupFixture(4096); - f.memory.environment.adjustThreshold = true; - f.memory.directRaw.acceptWrites = false; - f.write(4096, 10); - assertNull(f.poll(10_000, true)); - assertEquals(0, f.memory.environment.requestedThreshold); - f.memory.directRaw.acceptWrites = true; - f.memory.directRaw.maxWriteBytes = 512; - ByteBuffer buffer = ByteBuffer.allocateDirect(1024); - assertFalse(f.output.write(buffer, 10, 0)); - assertEquals(0, f.memory.environment.requestedThreshold); - assertTrue(f.output.write(buffer, 10, 0)); - assertEquals(1024, f.memory.environment.requestedThreshold); - } - - @Test - public void unreadableStartupMetricsRecoverFromAcceptedPayloadAndPinnedHead() throws Exception { - // Vendor tracks can report capacity/size/threshold as zero, which leaves no threshold to - // compare against; accepted payload plus a head that never leaves zero must still recover. - StartupFixture f = new StartupFixture(0); - f.write(256 * 1024, 32); - assertNull(f.poll(0, true)); - assertNull(f.poll(ExoCompressedAudioDirectPolicy.ZERO_METRIC_STARTUP_STALL_MS - 1, true)); - assertSame(f.memory.format, f.poll(1, true)); - assertNull(f.poll(10_000, true)); - assertEquals(Long.MAX_VALUE, f.memory.policy.startupProgressIntervalUs()); - assertTrue(f.memory.policy.consumePcmFallbackRequest()); - assertFalse(f.memory.policy.consumePcmFallbackRequest()); - - // Without accepted payload there is no evidence at all, so nothing may be declared. - StartupFixture silent = new StartupFixture(0); - assertNull(silent.poll(10_000, true)); - } - - @Test - public void unknownRawHeadNeverDeclaresStartupFailure() throws Exception { - StartupFixture unknownHead = new StartupFixture(4096); - unknownHead.memory.environment.rawHead = C.LENGTH_UNSET; - unknownHead.write(4096, 10); - assertNull(unknownHead.poll(0, true)); - assertNull(unknownHead.poll(800, true)); - assertEquals(Long.MAX_VALUE, unknownHead.memory.policy.startupProgressIntervalUs()); - - StartupFixture unreadableAndUnknownHead = new StartupFixture(0); - unreadableAndUnknownHead.memory.environment.rawHead = C.LENGTH_UNSET; - unreadableAndUnknownHead.write(256 * 1024, 32); - assertNull(unreadableAndUnknownHead.poll(0, true)); - assertNull(unreadableAndUnknownHead.poll(10_000, true)); - } - - @Test - public void validPositionOrRawHeadRetiresStartupProbe() throws Exception { - StartupFixture f = new StartupFixture(4096); - f.write(4096, 10); - assertNull(f.poll(0, true)); - f.memory.directRaw.positionUs = 1; - assertNull(f.poll(800, true)); - assertEquals(Long.MAX_VALUE, f.memory.policy.startupProgressIntervalUs()); - f.memory.directRaw.positionUs = 0; - assertNull(f.poll(10_000, true)); - - StartupFixture timestampLag = new StartupFixture(4096); - timestampLag.write(4096, 10); - assertNull(timestampLag.poll(0, true)); - timestampLag.memory.environment.rawHead = 128; - assertNull(timestampLag.poll(800, true)); - assertEquals(Long.MAX_VALUE, timestampLag.memory.policy.startupProgressIntervalUs()); - } - - @Test - public void pauseAndBufferingRestartObservationWithoutCountingSuspendedTime() throws Exception { - StartupFixture f = new StartupFixture(4096); - f.write(4096, 10); - assertNull(f.poll(0, true)); - f.output.pause(); - assertNull(f.poll(5000, false)); - f.output.play(); - assertNull(f.poll(0, true)); - assertNull(f.poll(799, true)); - assertNull(f.poll(1, false)); - assertNull(f.poll(0, true)); - assertSame(f.memory.format, f.poll(800, true)); - } - - @Test - public void flushStopReleaseAndNewAttemptCannotUseOldStartupEvidence() throws Exception { - for (String operation : new String[]{"flush", "stop", "release", "prepare"}) { - StartupFixture f = new StartupFixture(4096); - f.write(4096, 10); - assertNull(f.poll(0, true)); - switch (operation) { - case "flush" -> f.output.flush(); - case "stop" -> f.output.stop(); - case "release" -> f.output.release(); - case "prepare" -> f.memory.policy.prepareForPlayback("new", false); - } - f.memory.nowMs.addAndGet(800); - assertNull(operation, f.memory.policy.maybeRequestStartupPcmFallback(true)); - } - } - - @Test - public void routeOrCapabilityChangeInvalidatesStartupEvidence() throws Exception { - StartupFixture routeChange = new StartupFixture(4096); - routeChange.write(4096, 10); - assertNull(routeChange.poll(0, true)); - routeChange.memory.environment.actual = route(2); - assertNull(routeChange.poll(800, true)); - StartupFixture capabilities = new StartupFixture(4096); - capabilities.write(4096, 10); - assertNull(capabilities.poll(0, true)); - capabilities.memory.memory.invalidate(); - assertNull(capabilities.poll(800, true)); - } - - @Test - public void growingRealThresholdIsRecheckedBeforeRecovery() throws Exception { - StartupFixture f = new StartupFixture(4096); - f.write(4096, 10); - assertNull(f.poll(0, true)); - f.memory.environment.buffer = new ExoCompressedAudioDirectPolicy.StartupBuffer(8192, 8192, 8192); - assertNull(f.poll(800, true)); - f.write(4096, 10); - assertNull(f.poll(0, true)); - assertSame(f.memory.format, f.poll(800, true)); - } - - @Test - public void unreadableThresholdAfterReadableStartAbandonsEvidenceAndRenewsWindow() throws Exception { - StartupFixture f = new StartupFixture(4096); - f.write(4096, 10); - // Arm the startup window; nothing can be decided at the first visit. - assertNull(f.poll(0, true)); - f.memory.environment.buffer = ExoCompressedAudioDirectPolicy.StartupBuffer.UNKNOWN; - // Metrics turned unreadable: the readable-metric budget is already spent, so the stale - // readable-threshold evidence is abandoned here instead of being judged as a stall. - assertNull(f.poll(800, true)); - // Abandoning that evidence cleared the window; this visit re-arms it for zero metrics. - assertNull(f.poll(0, true)); - // Probe inside the zero-metric budget. A window that was never cleared would still be armed - // at 0 ms and would already decide here, so only this poll pins the renewal itself. - assertNull(f.poll(400, true)); - // The renewed window expires after the full 1200 ms zero-metric budget (800 -> 2000), so the - // accepted payload plus a head pinned at zero is finally reported as a startup stall. - assertSame(f.memory.format, f.poll(800, true)); - } - - @Test - public void internalPcmRecoveryConfirmsWithoutNewMediaPreparation() throws Exception { - StartupFixture f = new StartupFixture(4096); - f.memory.policy.setSelectedAudioFormat(f.memory.format); - f.write(4096, 10); - assertNull(f.poll(0, true)); - assertSame(f.memory.format, f.poll(800, true)); - f.output.release(); - f.memory.createPcm(); // No prepareForPlayback, as in Media3's internal recovery. - f.memory.advancePcm(); - assertFalse(f.memory.policy.consumePcmFallbackRequest()); - assertFalse(f.memory.freshEngineUsesDirect(f.memory.url, f.memory.format, AudioAttributes.DEFAULT)); - } - - @Test - public void earlyFailureIsNotSharedBeforePcmProgressAndRetiredAttemptCannotDisableNewOutput() throws Exception { - StartupFixture f = new StartupFixture(4096); - f.write(4096, 10); - assertNull(f.poll(0, true)); - f.memory.environment.onExpectedRoute = () -> f.memory.policy.prepareForPlayback("new", false); - assertNull(f.poll(800, true)); - f.memory.environment.onExpectedRoute = null; - assertFalse(f.memory.policy.consumePcmFallbackRequest()); - assertTrue(f.memory.freshEngineUsesDirect(f.memory.url, f.memory.format, AudioAttributes.DEFAULT)); - } - - @Test - @Ignore("ExoPlaybackException.createForRenderer reads SystemClock; recovery is covered by ExoStartupRecoveryIntegrationTest") - public void rendererEmitsTypedRecoverableErrorAndPreservesOriginalRenderCall() throws Exception { - StartupFixture f = new StartupFixture(4096); - AtomicInteger renders = new AtomicInteger(); - Renderer delegate = renderer(Renderer.STATE_STARTED, renders); - Renderer wrapped = new ExoStartupAudioRenderer(delegate, f.memory.policy); - wrapped.init(3, PlayerId.UNSET, Clock.DEFAULT); - f.write(4096, 10); - f.output.getPositionUs(); - wrapped.render(0, 0); - assertEquals(50_000, wrapped.getDurationToProgressUs(0, 0)); - f.memory.nowMs.set(800); - f.output.getPositionUs(); - ExoPlaybackException error = assertThrows(ExoPlaybackException.class, () -> wrapped.render(0, 800_000)); - assertTrue(isRecoverable(error)); - assertEquals(ExoPlaybackException.TYPE_RENDERER, error.type); - assertEquals(PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, error.errorCode); - assertTrue(error.getCause() instanceof ExoStartupAudioRenderer.StartupStallException); - assertEquals(3, error.rendererIndex); - assertSame(f.memory.format, error.rendererFormat); - wrapped.render(0, 900_000); - assertEquals(3, renders.get()); - assertEquals(Long.MAX_VALUE, wrapped.getDurationToProgressUs(0, 0)); - } - - @Test - public void rendererDoesNotEmitStartupErrorWhileEnabledButNotPlaying() throws Exception { - StartupFixture f = new StartupFixture(4096); - Renderer wrapped = new ExoStartupAudioRenderer(renderer(Renderer.STATE_ENABLED, new AtomicInteger()), - f.memory.policy); - f.write(4096, 10); - f.output.getPositionUs(); - wrapped.render(0, 0); - f.memory.nowMs.set(10_000); - f.output.getPositionUs(); - wrapped.render(0, 10_000_000); - assertFalse(f.memory.policy.consumePcmFallbackRequest()); - } - - private static Renderer renderer(int state, AtomicInteger renders) { - return (Renderer) Proxy.newProxyInstance(Renderer.class.getClassLoader(), new Class[]{Renderer.class}, - (proxy, method, args) -> switch (method.getName()) { - case "render" -> { renders.incrementAndGet(); yield null; } - case "getState" -> state; - case "isReady" -> true; - case "isEnded" -> false; - case "getName" -> "original-audio-renderer"; - case "getDurationToProgressUs" -> Long.MAX_VALUE; - default -> null; - }); - } - - @Test - public void passthroughDisabled_doesNotProbeOrCreateVendorOutput() { - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> { throw new AssertionError("Offload query while disabled"); }, - (format, attributes) -> { throw new AssertionError("Direct query while disabled"); }, - Clock.DEFAULT, - config -> { throw new AssertionError("Vendor output while disabled"); }); - policy.setAudioPassthroughEnabled(false); - - assertSame(AudioOffloadSupport.DEFAULT_UNSUPPORTED, - policy.getAudioOffloadSupport(aacStereo(), AudioAttributes.DEFAULT)); - AudioOutputProvider provider = wrapped(policy); - assertSame(AudioOutputProvider.FormatSupport.UNSUPPORTED, - provider.getFormatSupport(formatConfig(aacStereo()))); - assertThrows(AudioOutputProvider.ConfigurationException.class, - () -> provider.getOutputConfig(formatConfig(aacStereo()))); - assertThrows(AudioOutputProvider.InitializationException.class, - () -> provider.getAudioOutput(encodedOutput(false, false))); - assertFalse(policy.getAudioOutputSnapshot().initialized()); - assertFalse(policy.consumePcmFallbackRequest()); - } - - @Test - public void passthroughDisabled_overridesStandardEncodedSupportIncludingOffload() { - AudioOutputProvider.FormatSupport standard = new AudioOutputProvider.FormatSupport.Builder() - .setFormatSupportLevel(AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY) - .setIsFormatSupportedForOffload(true).build(); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> supported(true, true), (format, attributes) -> true); - policy.setAudioPassthroughEnabled(false); - AudioOutputProvider provider = policy.wrapOutputProvider( - new FixedFormatSupportAudioOutputProvider(standard)); - - for (String mime : new String[]{MimeTypes.AUDIO_AAC, MimeTypes.AUDIO_MPEG, - MimeTypes.AUDIO_AC3, MimeTypes.AUDIO_E_AC3, MimeTypes.AUDIO_DTS, - MimeTypes.AUDIO_TRUEHD}) { - Format format = aacStereo().buildUpon().setSampleMimeType(mime).build(); - assertSame(AudioOutputProvider.FormatSupport.UNSUPPORTED, - provider.getFormatSupport(formatConfig(format))); - } - assertSame(AudioOutputProvider.FormatSupport.UNSUPPORTED, - provider.getFormatSupport(tunnelingConfig())); - assertFalse(policy.getAudioOffloadSupport(aacStereo(), AudioAttributes.DEFAULT).isFormatSupported); - } - - @Test - public void passthroughDisabled_rejectsPreviouslySelectedEncodedConfigs() throws Exception { - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> true); - AudioOutputProvider vendor = wrapped(policy); - vendor.getFormatSupport(formatConfig(aacStereo())); - AudioOutputProvider.OutputConfig cached = vendor.getOutputConfig(formatConfig(aacStereo())); - policy.setAudioPassthroughEnabled(false); - - assertFalse(policy.usesVendorDirect(C.ENCODING_AAC_LC, cached.sampleRate, cached.channelMask)); - assertThrows(AudioOutputProvider.ConfigurationException.class, - () -> vendor.getOutputConfig(formatConfig(aacStereo()))); - for (AudioOutputProvider.OutputConfig config : new AudioOutputProvider.OutputConfig[]{ - cached, encodedOutput(false, true), encodedOutput(true, false)}) { - StandardAudioOutputProvider delegate = new StandardAudioOutputProvider(config); - assertThrows(AudioOutputProvider.InitializationException.class, - () -> policy.wrapOutputProvider(delegate).getAudioOutput(config)); - assertEquals(0, delegate.creations); - } - } - - @Test - public void passthroughDisabled_preservesPcmAndTunneledPcmOutputOwnership() throws Exception { - for (boolean tunneling : new boolean[]{false, true}) { - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> { throw new AssertionError("PCM direct probe"); }); - policy.setAudioPassthroughEnabled(false); - Format pcm = aacStereo().buildUpon().setSampleMimeType(MimeTypes.AUDIO_RAW) - .setPcmEncoding(C.ENCODING_PCM_16BIT).build(); - AudioOutputProvider.FormatConfig format = new AudioOutputProvider.FormatConfig.Builder(pcm) - .setEnableTunneling(tunneling).build(); - AudioOutputProvider.OutputConfig config = new AudioOutputProvider.OutputConfig.Builder() - .setEncoding(C.ENCODING_PCM_16BIT).setSampleRate(48_000) - .setChannelMask(Util.getAudioTrackChannelConfig(pcm)) - .setBufferSize(4096).setIsTunneling(tunneling).build(); - StandardAudioOutputProvider delegate = new StandardAudioOutputProvider(config); - AudioOutputProvider provider = policy.wrapOutputProvider(delegate); - - assertSame(delegate.support, provider.getFormatSupport(format)); - assertSame(config, provider.getOutputConfig(format)); - AudioOutput output = provider.getAudioOutput(config); - assertEquals(1, delegate.creations); - assertTrue(policy.getAudioOutputSnapshot().initialized()); - assertEquals(C.ENCODING_PCM_16BIT, policy.getAudioOutputSnapshot().encoding()); - assertEquals(tunneling, policy.getAudioOutputSnapshot().tunneling()); - assertFalse(policy.getAudioOutputSnapshot().offload()); - output.release(); - assertFalse(policy.getAudioOutputSnapshot().initialized()); - } - } - - @Test - public void passthroughReenabled_restoresDirectAndStandardOffloadCandidates() { - AudioOffloadSupport offload = supported(true, true); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> offload, (format, attributes) -> true); - policy.setAudioPassthroughEnabled(false); - assertSame(AudioOutputProvider.FormatSupport.UNSUPPORTED, - wrapped(policy).getFormatSupport(formatConfig(aacStereo()))); - - policy.setAudioPassthroughEnabled(true); - assertEquals(AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY, - wrapped(policy).getFormatSupport(formatConfig(aacStereo())).supportLevel); - assertSame(offload, policy.getAudioOffloadSupport(aacStereo(), AudioAttributes.DEFAULT)); - } - - @Test - public void offloadOnlyFlag_doesNotAuthorizeNonOffloadedBitstream() { - assertFalse(ExoCompressedAudioDirectPolicy.supportsBitstream(0)); - assertFalse(ExoCompressedAudioDirectPolicy.supportsBitstream( - AudioManager.DIRECT_PLAYBACK_OFFLOAD_SUPPORTED)); - assertFalse(ExoCompressedAudioDirectPolicy.supportsBitstream( - AudioManager.DIRECT_PLAYBACK_OFFLOAD_GAPLESS_SUPPORTED)); - assertTrue(ExoCompressedAudioDirectPolicy.supportsBitstream( - AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED)); - assertTrue(ExoCompressedAudioDirectPolicy.supportsBitstream( - AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED | AudioManager.DIRECT_PLAYBACK_OFFLOAD_SUPPORTED)); - } - - @Test - public void capabilityQueryAndOutput_useTheSameEffectiveAttributes() throws Exception { - AtomicReference queried = new AtomicReference<>(); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> { queried.set(attributes); return true; }); - AudioAttributes original = AudioAttributes.DEFAULT.buildUpon() - .setHapticChannelsMuted(false).setIsContentSpatialized(true).build(); - AudioOutputProvider.FormatConfig config = new AudioOutputProvider.FormatConfig.Builder(aacStereo()) - .setAudioAttributes(original).build(); - AudioOutputProvider provider = wrapped(policy); - provider.getFormatSupport(config); - AudioOutputProvider.OutputConfig output = provider.getOutputConfig(config); - assertEquals(C.AUDIO_CONTENT_TYPE_MUSIC, queried.get().contentType); - assertEquals(queried.get(), output.audioAttributes); - assertEquals(original.usage, output.audioAttributes.usage); - assertEquals(original.flags, output.audioAttributes.flags); - assertEquals(original.hapticChannelsMuted, output.audioAttributes.hapticChannelsMuted); - assertEquals(original.isContentSpatialized, output.audioAttributes.isContentSpatialized); - assertEquals(C.AUDIO_CONTENT_TYPE_UNKNOWN, original.contentType); - } - - @Test - public void cachedDirectCapability_doesNotCrossAudioAttributes() { - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> true); - AudioOutputProvider provider = wrapped(policy); - provider.getFormatSupport(formatConfig(aacStereo())); - AudioOutputProvider.FormatConfig speech = new AudioOutputProvider.FormatConfig.Builder(aacStereo()) - .setAudioAttributes(AudioAttributes.DEFAULT.buildUpon() - .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build()).build(); - assertThrows(AudioOutputProvider.ConfigurationException.class, - () -> provider.getOutputConfig(speech)); - } - - @Test - public void startupThreshold_preservesCompressedByteUnitsAndUnknownState() { - assertEquals(256 * 1024, new ExoCompressedAudioDirectPolicy.StartupBuffer( - 512 * 1024, 256 * 1024, 512 * 1024).effectiveThresholdBytes()); - assertEquals(4096, new ExoCompressedAudioDirectPolicy.StartupBuffer( - 256 * 1024, 256 * 1024, 4096).effectiveThresholdBytes()); - assertEquals(0, ExoCompressedAudioDirectPolicy.StartupBuffer.UNKNOWN.effectiveThresholdBytes()); - assertEquals(0, new ExoCompressedAudioDirectPolicy.StartupBuffer(4096, 4096, 8192) - .effectiveThresholdBytes()); - } - - @Test - public void standardOffload_isPreservedWithoutDirectProbe() { - AtomicInteger directQueries = new AtomicInteger(); - AudioOffloadSupport standard = supported(true, true); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> standard, - (format, attributes) -> { - directQueries.incrementAndGet(); - return true; - }); - - AudioOffloadSupport actual = policy.getAudioOffloadSupport( - aacStereo(), AudioAttributes.DEFAULT); - - assertSame(standard, actual); - assertTrue(actual.isFormatSupported); - assertTrue(actual.isGaplessSupported); - assertTrue(actual.isSpeedChangeSupported); - assertTrue(directQueries.get() == 0); - } - - @Test - public void directBitstream_enablesEncodedBypassWithoutFakeOffload() { - Format format = aacStereo(); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (ignoredFormat, ignoredAttributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (ignoredFormat, ignoredAttributes) -> true); - - AudioOutputProvider.FormatSupport actual = wrapped(policy) - .getFormatSupport(formatConfig(format)); - - assertTrue(actual.supportLevel - == AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY); - assertFalse(actual.isFormatSupportedForOffload); - assertTrue(policy.usesVendorDirect(C.ENCODING_AAC_LC, 48_000, - Util.getAudioTrackChannelConfig(format))); - } - - @Test - public void passthroughWithoutOffload_isOverriddenByVendorDirect() { - Format format = aacStereo(); - AudioOutputProvider.FormatSupport passthrough = - new AudioOutputProvider.FormatSupport.Builder() - .setFormatSupportLevel( - AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY) - .setIsFormatSupportedForOffload(false) - .build(); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (ignoredFormat, ignoredAttributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (ignoredFormat, ignoredAttributes) -> true); - - AudioOutputProvider.FormatSupport actual = policy.wrapOutputProvider( - new FixedFormatSupportAudioOutputProvider(passthrough)) - .getFormatSupport(formatConfig(format)); - - assertTrue(actual.supportLevel - == AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY); - assertFalse(actual.isFormatSupportedForOffload); - assertTrue(policy.usesVendorDirect(C.ENCODING_AAC_LC, 48_000, - Util.getAudioTrackChannelConfig(format))); - } - - @Test - public void directBitstream_buildsNonOffloadEncodedOutput() throws Exception { - Format format = aacStereo(); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (ignoredFormat, ignoredAttributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (ignoredFormat, ignoredAttributes) -> true); - AudioOutputProvider provider = wrapped(policy); - AudioOutputProvider.FormatConfig config = formatConfig(format); - provider.getFormatSupport(config); - - AudioOutputProvider.OutputConfig output = provider.getOutputConfig(config); - - assertTrue(output.encoding == C.ENCODING_AAC_LC); - assertTrue(output.sampleRate == 48_000); - assertTrue(output.channelMask == Util.getAudioTrackChannelConfig(format)); - assertTrue(output.bufferSize == 256 * 1024); - assertFalse(output.isOffload); - assertFalse(output.isTunneling); - assertTrue(output.audioSessionId == 0); - assertTrue(output.virtualDeviceId == C.INDEX_UNSET); - } - - @Test - public void tunneling_doesNotAdvertiseVendorOnlyBypass() { - AtomicInteger queries = new AtomicInteger(); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> { queries.incrementAndGet(); return true; }); - - assertSame(AudioOutputProvider.FormatSupport.UNSUPPORTED, - wrapped(policy).getFormatSupport(tunnelingConfig())); - assertEquals(0, queries.get()); - assertFalse(policy.usesVendorDirect(C.ENCODING_AAC_LC, 48_000, - Util.getAudioTrackChannelConfig(aacStereo()))); - } - - @Test - public void tunneling_afterCachedDirectProbe_preservesStandardConfiguration() throws Exception { - Format format = aacStereo(); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (ignoredFormat, ignoredAttributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (ignoredFormat, ignoredAttributes) -> true); - wrapped(policy).getFormatSupport(formatConfig(format)); - AudioOutputProvider.OutputConfig standard = encodedOutput(true, false); - StandardAudioOutputProvider delegate = new StandardAudioOutputProvider(standard); - AudioOutputProvider provider = policy.wrapOutputProvider(delegate); - - // Even without a new capability query, the final request must not use the stale direct key. - assertSame(standard, provider.getOutputConfig(tunnelingConfig())); - assertTrue(standard.isTunneling); - assertEquals(1234, standard.audioSessionId); - assertSame(delegate.support, provider.getFormatSupport(tunnelingConfig())); - } - - @Test - public void finalStandardMode_ignoresDirectCacheAndPublishesItsActualOutput() throws Exception { - for (boolean offload : new boolean[]{false, true}) { - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> true); - wrapped(policy).getFormatSupport(formatConfig(aacStereo())); - AudioOutputProvider.OutputConfig standard = encodedOutput(!offload, offload); - StandardAudioOutputProvider delegate = new StandardAudioOutputProvider(standard); - AudioOutput output = policy.wrapOutputProvider(delegate).getAudioOutput(standard); - - assertEquals(1, delegate.creations); - assertEquals(!offload, policy.getAudioOutputSnapshot().tunneling()); - assertEquals(offload, policy.getAudioOutputSnapshot().offload()); - // A standard mode must not apply any vendor builder overrides, even with a cached key. - policy.modifyAudioTrackBuilder(null, standard); - output.release(); - assertFalse(policy.getAudioOutputSnapshot().initialized()); - } - } - - @Test - public void failedInitialization_doesNotPublishOutputState() { - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> false); - assertThrows(AudioOutputProvider.InitializationException.class, - () -> wrapped(policy).getAudioOutput(encodedOutput(false, false))); - assertFalse(policy.getAudioOutputSnapshot().initialized()); - } - - @Test - public void vendorDirectInitializationFailure_notifiesForImmediatePcmFallback() throws Exception { - AtomicInteger notifications = new AtomicInteger(); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> true, - Clock.DEFAULT, - config -> { - throw new AudioOutputProvider.InitializationException(); - }); - policy.setInitializationFailureListener(notifications::incrementAndGet); - AudioOutputProvider provider = wrapped(policy); - AudioOutputProvider.FormatConfig formatConfig = formatConfig(aacStereo()); - provider.getFormatSupport(formatConfig); - AudioOutputProvider.OutputConfig outputConfig = provider.getOutputConfig(formatConfig); - - assertThrows(AudioOutputProvider.InitializationException.class, - () -> provider.getAudioOutput(outputConfig)); - - assertEquals(1, notifications.get()); - assertTrue(policy.consumePcmFallbackRequest()); - assertFalse(policy.getAudioOutputSnapshot().initialized()); - } - - @Test - public void missingDirectSupport_keepsPcmFallback() { - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> false); - - AudioOutputProvider.FormatSupport actual = wrapped(policy) - .getFormatSupport(formatConfig(aacStereo())); - - assertTrue(actual.supportLevel - == AudioOutputProvider.FORMAT_UNSUPPORTED); - } - - @Test - public void failedDirectConfig_isNotRetriedInSamePlayerSession() { - AtomicInteger directQueries = new AtomicInteger(); - Format format = aacStereo(); - int channelMask = Util.getAudioTrackChannelConfig(format); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (ignoredFormat, ignoredAttributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (ignoredFormat, ignoredAttributes) -> { - directQueries.incrementAndGet(); - return true; - }); - assertTrue(wrapped(policy).getFormatSupport(formatConfig(format)) - .supportLevel == AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY); - - policy.disableVendorDirect(C.ENCODING_AAC_LC, 48_000, channelMask); - AudioOutputProvider.FormatSupport fallback = wrapped(policy) - .getFormatSupport(formatConfig(format)); - - assertTrue(fallback.supportLevel - == AudioOutputProvider.FORMAT_UNSUPPORTED); - assertFalse(policy.usesVendorDirect( - C.ENCODING_AAC_LC, 48_000, channelMask)); - assertTrue(directQueries.get() == 1); - } - - @Test - public void failedDirectConfig_masksDelegatePassthroughForPcmFallback() { - Format format = aacStereo(); - int channelMask = Util.getAudioTrackChannelConfig(format); - AudioOutputProvider.FormatSupport passthrough = - new AudioOutputProvider.FormatSupport.Builder() - .setFormatSupportLevel( - AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY) - .setIsFormatSupportedForOffload(false) - .build(); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (ignoredFormat, ignoredAttributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (ignoredFormat, ignoredAttributes) -> true); - AudioOutputProvider provider = policy.wrapOutputProvider( - new FixedFormatSupportAudioOutputProvider(passthrough)); - assertTrue(provider.getFormatSupport(formatConfig(format)).supportLevel - == AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY); - - policy.disableVendorDirect(C.ENCODING_AAC_LC, 48_000, channelMask); - - AudioOutputProvider.FormatSupport fallback = - provider.getFormatSupport(formatConfig(format)); - assertTrue(fallback.supportLevel == AudioOutputProvider.FORMAT_UNSUPPORTED); - assertTrue(policy.consumePcmFallbackRequest()); - assertFalse(policy.consumePcmFallbackRequest()); - } - - @Test - public void unsupportedEncodedFrameType_doesNotProbeDirectPlayback() { - AtomicInteger directQueries = new AtomicInteger(); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> { - directQueries.incrementAndGet(); - return true; - }); - Format flac = new Format.Builder() - .setSampleMimeType(MimeTypes.AUDIO_FLAC) - .setSampleRate(48_000) - .setChannelCount(2) - .build(); - - AudioOutputProvider.FormatSupport actual = wrapped(policy) - .getFormatSupport(formatConfig(flac)); - - assertTrue(actual.supportLevel - == AudioOutputProvider.FORMAT_UNSUPPORTED); - assertTrue(directQueries.get() == 0); - } - - @Test - public void incompleteFormat_doesNotProbeDirectPlayback() { - AtomicInteger directQueries = new AtomicInteger(); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> { - directQueries.incrementAndGet(); - return true; - }); - Format incomplete = new Format.Builder() - .setSampleMimeType(MimeTypes.AUDIO_AAC) - .setCodecs("mp4a.40.2") - .build(); - - AudioOutputProvider.FormatSupport actual = wrapped(policy) - .getFormatSupport(formatConfig(incomplete)); - - assertTrue(actual.supportLevel - == AudioOutputProvider.FORMAT_UNSUPPORTED); - assertTrue(directQueries.get() == 0); - } - - @Test - public void acceptedDirectAudio_stuckPlaying_requestsOnePcmRetry() throws Exception { - for (int sampleRate : new int[]{44_100, 48_000}) { - DirectOutputFixture fixture = new DirectOutputFixture(sampleRate); - fixture.stall(); - - assertTrue(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - assertTrue(fixture.policy.consumePcmFallbackRequest()); - assertFalse(fixture.policy.consumePcmFallbackRequest()); - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - // Even a delegate advertising generic passthrough must now force decoder + PCM. - assertEquals(AudioOutputProvider.FORMAT_UNSUPPORTED, - fixture.provider.getFormatSupport(formatConfig(fixture.format)).supportLevel); - assertFalse(fixture.policy.usesVendorDirect(C.ENCODING_AAC_LC, sampleRate, - Util.getAudioTrackChannelConfig(fixture.format))); - } - } - - @Test - public void otherTimeoutsAndErrors_doNotDisableStalledDirectOutput() throws Exception { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.stall(); - for (int type : new int[]{StuckPlayerException.STUCK_BUFFERING_NOT_LOADING, - StuckPlayerException.STUCK_BUFFERING_NO_PROGRESS, - StuckPlayerException.STUCK_PLAYING_NOT_ENDING, - StuckPlayerException.STUCK_SUPPRESSED}) { - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(stuck(type))); - } - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback( - playbackError(PlaybackException.ERROR_CODE_TIMEOUT, - new IllegalStateException()))); - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback( - playbackError(PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - new StuckPlayerException( - StuckPlayerException.STUCK_PLAYING_NO_PROGRESS, 10_000)))); - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(null)); - assertFalse(fixture.policy.consumePcmFallbackRequest()); - assertEquals(AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY, - fixture.provider.getFormatSupport(formatConfig(fixture.format)).supportLevel); - } - - @Test - public void recentAudioProgress_doesNotBlameDirectOutput() throws Exception { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.stall(); - fixture.raw.positionUs = 4_000_000; - assertEquals(4_000_000, fixture.output.getPositionUs()); - - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - assertFalse(fixture.policy.consumePcmFallbackRequest()); - } - - @Test - public void outputStallsAfterProgress_canRecoverAtNonzeroPosition() throws Exception { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.writeAndPlay(); - fixture.output.getPositionUs(); - fixture.nowMs.addAndGet(5_000); - fixture.raw.positionUs = 5_000_000; - fixture.output.getPositionUs(); - fixture.nowMs.addAndGet(10_000); - fixture.output.getPositionUs(); - - assertTrue(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - } - - @Test - public void zeroWritesOrUnknownPosition_doNotProveOutputStall() throws Exception { - DirectOutputFixture noData = new DirectOutputFixture(44_100); - noData.raw.acceptWrites = false; - noData.stall(); - assertFalse(noData.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - - DirectOutputFixture unknown = new DirectOutputFixture(44_100); - unknown.raw.positionUs = C.TIME_UNSET; - unknown.stall(); - assertFalse(unknown.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - } - - @Test - public void pauseAndResume_restartObservationWindow() throws Exception { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.writeAndPlay(); - fixture.output.getPositionUs(); - fixture.output.pause(); - fixture.nowMs.addAndGet(20_000); - fixture.output.getPositionUs(); - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - - fixture.output.play(); - fixture.output.getPositionUs(); - fixture.nowMs.addAndGet(1_000); - fixture.output.getPositionUs(); - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - fixture.nowMs.addAndGet(1_000); - fixture.output.getPositionUs(); - assertTrue(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - } - - @Test - public void flush_requiresFreshInputAndProgressEvidence() throws Exception { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.stall(); - fixture.output.flush(); - fixture.nowMs.addAndGet(10_000); - fixture.output.getPositionUs(); - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - - fixture.stall(); - assertTrue(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - } - - @Test - public void naturalStop_discardsStallEvidence() throws Exception { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.stall(); - fixture.output.stop(); - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - } - - @Test - public void pauseAndReleaseBeforeError_preserveObservedEvidence() throws Exception { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.stall(); - fixture.output.pause(); - fixture.output.release(); - - assertFalse(fixture.policy.getAudioOutputSnapshot().initialized()); - assertTrue(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - assertTrue(fixture.policy.consumePcmFallbackRequest()); - } - - @Test - public void oldRelease_doesNotContaminateNewOutput() throws Exception { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.stall(); - AudioOutput old = fixture.output; - fixture.createOutput(); - old.release(); - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - - fixture.stall(); - assertTrue(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - } - - @Test - public void newPlaybackAttempt_rejectsOldAndLateInitializationEvidence() throws Exception { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.stall(); - fixture.policy.resetOutputProgress(); - fixture.output.getPositionUs(); - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - - fixture.duringCreation = fixture.policy::resetOutputProgress; - fixture.createOutput(); - fixture.stall(); - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - - fixture.duringCreation = null; - fixture.createOutput(); - fixture.stall(); - assertTrue(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - } - - @Test - public void standardPcmOffloadAndTunneling_replaceVendorEvidence() throws Exception { - AudioOutputProvider.OutputConfig pcm = new AudioOutputProvider.OutputConfig.Builder() - .setEncoding(C.ENCODING_PCM_16BIT).setSampleRate(44_100) - .setChannelMask(12).setBufferSize(4096).build(); - for (AudioOutputProvider.OutputConfig config : new AudioOutputProvider.OutputConfig[]{ - pcm, encodedOutput(false, true), encodedOutput(true, false)}) { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.stall(); - AudioOutput standard = fixture.policy.wrapOutputProvider( - new StandardAudioOutputProvider(config)).getAudioOutput(config); - - assertFalse(fixture.policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - assertFalse(fixture.policy.consumePcmFallbackRequest()); - standard.release(); - } - } - - @Test - public void writeFailure_stillRequestsRecoverablePcmFallback() throws Exception { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.raw.writeFailure = new AudioOutput.WriteException(-6, false); - AudioOutput.WriteException error = assertThrows(AudioOutput.WriteException.class, - fixture::writeAndPlay); - - assertEquals(-6, error.errorCode); - assertTrue(error.isRecoverable); - assertTrue(fixture.policy.consumePcmFallbackRequest()); - assertEquals(AudioOutputProvider.FORMAT_UNSUPPORTED, - fixture.provider.getFormatSupport(formatConfig(fixture.format)).supportLevel); - } - - @Test - public void failedDirect_thenStableSameTrackPcm_isRememberedAcrossEngines() throws Exception { - MemoryFixture fixture = new MemoryFixture(); - fixture.failDirect(); - assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - fixture.preparePcm(true); - fixture.advancePcm(); - assertFalse(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - assertTrue(fixture.freshEngineUsesDirect(fixture.url + "&token=other", fixture.format, - AudioAttributes.DEFAULT)); - assertTrue(fixture.freshEngineUsesDirect(fixture.url, - fixture.format.buildUpon().setId("different-track").build(), AudioAttributes.DEFAULT)); - assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, - AudioAttributes.DEFAULT.buildUpon().setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build())); - fixture.environment.expected = route(2); - fixture.environment.actual = route(2); - assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - } - - @Test - public void pcmWithoutProgress_doesNotConfirmDirectFailure() throws Exception { - MemoryFixture fixture = new MemoryFixture(); - fixture.failDirect(); - fixture.preparePcm(true); - fixture.pcm.play(); - fixture.pcm.write(ByteBuffer.allocateDirect(32), 1, 0); - fixture.pcm.getPositionUs(); - fixture.nowMs.addAndGet(20_000); - fixture.pcm.getPositionUs(); - assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - } - - @Test - public void unrelatedRestartOrNewMedia_doesNotCarryPendingConfirmation() throws Exception { - for (boolean changeMedia : new boolean[]{false, true}) { - MemoryFixture fixture = new MemoryFixture(); - fixture.failDirect(); - if (changeMedia) fixture.url += "&episode=next"; - fixture.preparePcm(changeMedia); - fixture.advancePcm(); - assertTrue(fixture.freshEngineUsesDirect(MemoryFixture.URL, fixture.format, AudioAttributes.DEFAULT)); - } - } - - @Test - public void differentSelectedTrackOrActualRoute_doesNotConfirm() throws Exception { - for (boolean changeTrack : new boolean[]{false, true}) { - MemoryFixture fixture = new MemoryFixture(); - fixture.failDirect(); - fixture.preparePcm(true); - if (changeTrack) { - fixture.policy.setSelectedAudioFormat(fixture.format.buildUpon().setId("other").build()); - } else { - fixture.environment.actual = route(2); - } - fixture.advancePcm(); - assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - } - } - - @Test - public void unknownRoute_doesNotShareFailureButStillRecoversLocally() throws Exception { - MemoryFixture fixture = new MemoryFixture(); - fixture.environment.expected = null; - fixture.failDirect(); - fixture.preparePcm(true); - fixture.advancePcm(); - fixture.environment.expected = route(1); - assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - } - - @Test - public void flushStopReleaseAndNewAttempt_cancelPendingPcmConfirmation() throws Exception { - for (int action = 0; action < 4; action++) { - MemoryFixture fixture = new MemoryFixture(); - fixture.failDirect(); - fixture.preparePcm(true); - switch (action) { - case 0 -> fixture.pcm.flush(); - case 1 -> fixture.pcm.stop(); - case 2 -> fixture.pcm.release(); - case 3 -> fixture.policy.resetOutputProgress(); - } - if (action != 2) fixture.advancePcm(); - assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - } - } - - @Test - public void pausedPcm_doesNotConfirmUntilFreshContinuousProgress() throws Exception { - MemoryFixture fixture = new MemoryFixture(); - fixture.failDirect(); - fixture.preparePcm(true); - fixture.pcm.write(ByteBuffer.allocateDirect(32), 1, 0); - fixture.pcm.play(); - fixture.pcm.getPositionUs(); - fixture.pcm.pause(); - fixture.nowMs.addAndGet(20_000); - fixture.pcmRaw.positionUs = 2_000_000; - fixture.pcm.getPositionUs(); - assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - fixture.pcmRaw.positionUs = 0; - fixture.advancePcm(); - assertFalse(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - } - - @Test - public void capabilityNotification_invalidatesConfirmedAndPendingFailures() throws Exception { - MemoryFixture fixture = new MemoryFixture(); - AtomicReference observer = new AtomicReference<>(); - AudioOutputProvider watched = fixture.policy.wrapOutputProvider(new UnsupportedAudioOutputProvider() { - @Override public void addListener(Listener listener) { observer.compareAndSet(null, listener); } - }); - watched.addListener(() -> {}); - fixture.failDirect(); - fixture.preparePcm(true); - observer.get().onFormatSupportChanged(); - fixture.advancePcm(); - assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - - MemoryFixture confirmed = new MemoryFixture(); - confirmed.failDirect(); - confirmed.preparePcm(true); - confirmed.advancePcm(); - confirmed.memory.invalidate(); - assertTrue(confirmed.freshEngineUsesDirect(confirmed.url, confirmed.format, AudioAttributes.DEFAULT)); - } - - @Test - public void staleWriteFailure_cannotRequestPcmForNewPlayback() throws Exception { - DirectOutputFixture fixture = new DirectOutputFixture(44_100); - fixture.policy.resetOutputProgress(); - fixture.raw.writeFailure = new AudioOutput.WriteException(-6, false); - assertThrows(AudioOutput.WriteException.class, fixture::writeAndPlay); - assertFalse(fixture.policy.consumePcmFallbackRequest()); - assertEquals(AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY, - fixture.provider.getFormatSupport(formatConfig(fixture.format)).supportLevel); - } - - @Test - public void newAttemptDuringFinalRouteQuery_rejectsLatePcmConfirmation() throws Exception { - MemoryFixture fixture = new MemoryFixture(); - fixture.failDirect(); - fixture.preparePcm(true); - fixture.environment.onExpectedRoute = fixture.policy::resetOutputProgress; - fixture.advancePcm(); - fixture.environment.onExpectedRoute = null; - assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - } - - @Test - public void replacedPcmOutput_cannotConfirmTheRetiredOutput() throws Exception { - MemoryFixture fixture = new MemoryFixture(); - fixture.failDirect(); - fixture.preparePcm(true); - AudioOutputProvider.OutputConfig config = new AudioOutputProvider.OutputConfig.Builder() - .setEncoding(C.ENCODING_PCM_16BIT).setSampleRate(44_100) - .setChannelMask(12).setBufferSize(4096).build(); - fixture.policy.wrapOutputProvider(new StandardAudioOutputProvider(config, new FakeAudioOutput().output)) - .getAudioOutput(config); - fixture.advancePcm(); - assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); - } - - @Test - public void stalledOffloadOutputIsOnlyClaimedAfterAcceptedPayloadStopsBeingConsumed() - throws Exception { - OffloadStallFixture f = new OffloadStallFixture(true, false); - f.consumeFirstPayloadThenStall(392); - assertFalse(f.policy.hasStalledOffloadOutput()); - f.nowMs.addAndGet(1_999); - assertFalse(f.policy.hasStalledOffloadOutput()); - f.nowMs.addAndGet(1); - assertTrue(f.policy.hasStalledOffloadOutput()); - } - - @Test - public void advancingOrUnobservableOffloadHeadIsNeverAStall() throws Exception { - OffloadStallFixture advancing = new OffloadStallFixture(true, false); - advancing.consumeFirstPayloadThenStall(392); - advancing.environment.rawHead = 512; - advancing.nowMs.addAndGet(30_000); - assertFalse(advancing.policy.hasStalledOffloadOutput()); - assertFalse(advancing.policy.requestOffloadStallRecovery()); - - OffloadStallFixture unobservable = new OffloadStallFixture(true, false); - unobservable.consumeFirstPayloadThenStall(392); - unobservable.environment.rawHead = C.LENGTH_UNSET; - unobservable.nowMs.addAndGet(30_000); - assertFalse(unobservable.policy.hasStalledOffloadOutput()); - } - - @Test - public void refusedWritesWithoutAcceptedPayloadAreNotAnOffloadStall() throws Exception { - OffloadStallFixture f = new OffloadStallFixture(true, false); - f.raw.acceptWrites = false; - f.write(392); - f.write(392); - f.nowMs.addAndGet(30_000); - assertFalse(f.policy.hasStalledOffloadOutput()); - } - - @Test - public void pcmOutputsAreNotWatchedForOffloadStall() throws Exception { - OffloadStallFixture f = new OffloadStallFixture(false, false); - f.raw.acceptWrites = false; - f.write(392); - f.nowMs.addAndGet(30_000); - assertFalse(f.policy.hasStalledOffloadOutput()); - assertFalse(f.policy.requestOffloadStallRecovery()); - } - - @Test - public void offloadStallRecoveryIsOneShotAndWithdrawsOnlyThatConfiguration() throws Exception { - OffloadStallFixture f = new OffloadStallFixture(true, true); - f.consumeFirstPayloadThenStall(392); - f.nowMs.addAndGet(30_000); - assertTrue(f.policy.getAudioOffloadSupport(f.format, AudioAttributes.DEFAULT).isFormatSupported); - assertTrue(f.policy.requestOffloadStallRecovery()); - assertFalse(f.policy.requestOffloadStallRecovery()); - assertFalse(f.policy.getAudioOffloadSupport(f.format, AudioAttributes.DEFAULT).isFormatSupported); - // A different sample rate is a different output configuration and must stay untouched. - assertTrue(f.policy.getAudioOffloadSupport(aacStereo(), AudioAttributes.DEFAULT).isFormatSupported); - // Vendor-direct admission is a separate decision that this recovery must not consume. - assertEquals(AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY, - f.policy.wrapOutputProvider(new StandardAudioOutputProvider(f.config, f.raw.output)) - .getFormatSupport(formatConfig(f.format)).supportLevel); - } - - @Test - public void acceptedPayloadWithNoRefusedWriteIsNotAnOffloadStall() throws Exception { - // An output that keeps accepting everything is healthy however long it has been running. - // Only an accepted-then-refused sequence is evidence, so this guards the requirement that - // a refusal actually happened after the first acceptance. - OffloadStallFixture f = new OffloadStallFixture(true, false); - f.write(392); - f.write(392); - f.nowMs.addAndGet(30_000); - assertFalse(f.policy.hasStalledOffloadOutput()); - assertFalse(f.policy.requestOffloadStallRecovery()); - } - - @Test - public void flushDiscardsOffloadEvidenceFromBeforeTheSeek() throws Exception { - OffloadStallFixture f = new OffloadStallFixture(true, false); - f.consumeFirstPayloadThenStall(392); - f.nowMs.addAndGet(30_000); - assertTrue(f.policy.hasStalledOffloadOutput()); - // A seek flushes the output and resets the hardware head to zero, so counters from before - // the flush must not be replayed against a post-flush head of zero. - f.output.flush(); - assertFalse(f.policy.hasStalledOffloadOutput()); - assertFalse(f.policy.requestOffloadStallRecovery()); - // Fresh evidence recorded after the flush is still usable. - f.raw.acceptWrites = true; - f.consumeFirstPayloadThenStall(392); - f.nowMs.addAndGet(30_000); - assertTrue(f.policy.hasStalledOffloadOutput()); - } - - @Test - public void offloadStallDiagnosticUsesOnlyRegisteredEventAndFields() { - // The catvod diagnostics schema rejects unknown event and field names with an - // IllegalArgumentException, and PlaybackDiagnosticCollector.emit swallows that into a - // silent collectorFailure. Assert the exact event/field set the policy emits is registered, - // so this evidence can never be dropped invisibly again. - DiagnosticEvent event = new DiagnosticEvent("audio.output.write", "trace", "instance", 1, 1); - event.inferred() - .observed("stage", "offload-startup-stall") - .observed("encoding", C.ENCODING_AAC_LC) - .observed("sampleRate", 22_050) - .observed("channelMask", Util.getAudioTrackChannelConfig(aacStereo())) - .observed("acceptedBytes", 392L) - .observed("zeroWrites", 1338L) - .observed("headRaw", 0L) - .observed("windowMs", 15_000L) - .observed("reason", "offload-accepted-payload-never-consumed") - .observed("note", "restart-current-item-with-pcm") - .observed("metricScope", "AudioOutput payload consumption; retried requests included") - .unknown("audibility", DiagnosticEvent.Status.NOT_OBSERVABLE) - .pin("offload-startup-stall-media"); - String json = event.json(); - assertTrue(json.contains("offload-startup-stall")); - assertTrue(json.contains("\"evidenceClass\":\"inferred\"")); - // Self-proof that this guard is not vacuous: the validator really does reject the - // unregistered names, which is exactly why the original `audio.offload.stall` event and - // its `recovery` field were being dropped silently. - assertThrows(IllegalArgumentException.class, - () -> new DiagnosticEvent("audio.offload.stall", "trace", "instance", 1, 1)); - assertThrows(IllegalArgumentException.class, - () -> new DiagnosticEvent("audio.output.write", "trace", "instance", 1, 1) - .observed("recovery", "restart-current-item-with-pcm")); - } - - private static ExoAudioDirectFailureMemory.Route route(int id) { - return new ExoAudioDirectFailureMemory.Route(id, 2, 3, 4, 5); - } - - private static final class TestEnvironment implements ExoCompressedAudioDirectPolicy.OutputEnvironment { - ExoAudioDirectFailureMemory.Route expected = route(1); - ExoAudioDirectFailureMemory.Route actual = route(1); - Runnable onExpectedRoute; - ExoCompressedAudioDirectPolicy.StartupBuffer buffer = ExoCompressedAudioDirectPolicy.StartupBuffer.UNKNOWN; - boolean adjustThreshold; - int requestedThreshold; - int minimumThreshold; - long rawHead; - @Override public ExoAudioDirectFailureMemory.Route expectedRoute(AudioAttributes attributes) { - if (onExpectedRoute != null) onExpectedRoute.run(); - return expected; - } - @Override public ExoAudioDirectFailureMemory.Route actualRoute(AudioOutput output) { return actual; } - @Override public ExoCompressedAudioDirectPolicy.StartupBuffer startupBuffer(AudioOutput output) { return buffer; } - @Override public long rawPlaybackHead(AudioOutput output) { return rawHead; } - @Override public ExoCompressedAudioDirectPolicy.StartupBuffer setStartThresholdBytes(AudioOutput output, int bytes) { - if (!adjustThreshold) return ExoCompressedAudioDirectPolicy.StartupBuffer.UNKNOWN; - requestedThreshold = bytes; - buffer = new ExoCompressedAudioDirectPolicy.StartupBuffer(buffer.capacityBytes(), - buffer.sizeBytes(), Math.max(bytes, minimumThreshold)); - return buffer; - } - } - - private static final class StartupFixture { - final MemoryFixture memory = new MemoryFixture(); - final AudioOutput output; - StartupFixture(int threshold) throws Exception { - memory.environment.buffer = new ExoCompressedAudioDirectPolicy.StartupBuffer( - threshold, threshold, threshold); - memory.policy.prepareForPlayback(memory.url, false); - memory.provider.getFormatSupport(formatConfig(memory.format)); - output = memory.provider.getAudioOutput(memory.provider.getOutputConfig(formatConfig(memory.format))); - output.play(); - } - void write(int bytes, int units) throws Exception { output.write(ByteBuffer.allocateDirect(bytes), units, 0); } - Format poll(long elapsedMs, boolean playing) { - memory.nowMs.addAndGet(elapsedMs); - output.getPositionUs(); - return memory.policy.maybeRequestStartupPcmFallback(playing); - } - } - - private static final class MemoryFixture { - static final String URL = "https://example.invalid/video?token=private"; - final ExoAudioDirectFailureMemory memory = new ExoAudioDirectFailureMemory(); - final TestEnvironment environment = new TestEnvironment(); - final AtomicLong nowMs = new AtomicLong(); - final Format format = aacStereo().buildUpon().setSampleRate(44_100).build(); - final FakeAudioOutput directRaw = new FakeAudioOutput(); - final ExoCompressedAudioDirectPolicy policy = newPolicy(); - final AudioOutputProvider provider = wrapped(policy); - String url = URL; - AudioOutput pcm; - FakeAudioOutput pcmRaw; - - private ExoCompressedAudioDirectPolicy newPolicy() { - Clock clock = (Clock) Proxy.newProxyInstance(Clock.class.getClassLoader(), - new Class[]{Clock.class}, (proxy, method, args) -> { - if (method.getName().equals("elapsedRealtime")) return nowMs.get(); - throw new AssertionError("Unexpected Clock call: " + method.getName()); - }); - return new ExoCompressedAudioDirectPolicy( - (ignored, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (ignored, attributes) -> true, clock, config -> directRaw.output, memory, environment); - } - - void failDirect() throws Exception { - policy.prepareForPlayback(url, false); - provider.getFormatSupport(formatConfig(format)); - AudioOutput output = provider.getAudioOutput(provider.getOutputConfig(formatConfig(format))); - output.write(ByteBuffer.allocateDirect(32), 1, 0); - output.play(); - output.getPositionUs(); - nowMs.addAndGet(10_000); - output.getPositionUs(); - assertTrue(policy.requestPcmFallbackForStuckPlayback(stuckPlaying())); - assertTrue(policy.consumePcmFallbackRequest()); - output.release(); - } - - void preparePcm(boolean retry) throws Exception { - policy.prepareForPlayback(url, retry); - policy.setSelectedAudioFormat(format); - createPcm(); - } - - void createPcm() throws Exception { - pcmRaw = new FakeAudioOutput(); - AudioOutputProvider.OutputConfig config = new AudioOutputProvider.OutputConfig.Builder() - .setEncoding(C.ENCODING_PCM_16BIT).setSampleRate(44_100) - .setChannelMask(12).setBufferSize(4096).build(); - pcm = policy.wrapOutputProvider(new StandardAudioOutputProvider(config, pcmRaw.output)) - .getAudioOutput(config); - } - - void advancePcm() throws Exception { - pcm.write(ByteBuffer.allocateDirect(32), 1, 0); - pcm.play(); - pcm.getPositionUs(); - for (int i = 1; i <= 2; i++) { - nowMs.addAndGet(1_000); - pcmRaw.positionUs = i * 1_000_000L; - pcm.getPositionUs(); - } - } - - boolean freshEngineUsesDirect(String media, Format input, AudioAttributes attributes) { - ExoCompressedAudioDirectPolicy fresh = newPolicy(); - fresh.prepareForPlayback(media, false); - return wrapped(fresh).getFormatSupport(new AudioOutputProvider.FormatConfig.Builder(input) - .setAudioAttributes(attributes).build()).supportLevel - == AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY; - } - } - - private static PlaybackException stuckPlaying() { - return stuck(StuckPlayerException.STUCK_PLAYING_NO_PROGRESS); - } - - private static PlaybackException stuck(int type) { - return playbackError(PlaybackException.ERROR_CODE_TIMEOUT, - new StuckPlayerException(type, 10_000)); - } - - private static PlaybackException playbackError(int errorCode, Throwable cause) { - // Media3's public PlaybackException constructor reads android.os.SystemClock, which - // the JVM unit-test android stub rejects as an unmocked native method. The - // timestamped constructor keeps this fixture free of android.os framework calls. - return new TestPlaybackException(errorCode, cause); - } - - private static final class TestPlaybackException extends PlaybackException { - - TestPlaybackException(int errorCode, Throwable cause) { - super("stuck", cause, errorCode, null, 0); - } - } - - private static final class DirectOutputFixture { - final AtomicLong nowMs = new AtomicLong(); - final Format format; - final ExoCompressedAudioDirectPolicy policy; - final AudioOutputProvider provider; - AudioOutput output; - FakeAudioOutput raw; - Runnable duringCreation; - - DirectOutputFixture(int sampleRate) throws Exception { - format = aacStereo().buildUpon().setSampleRate(sampleRate).build(); - Clock clock = (Clock) Proxy.newProxyInstance(Clock.class.getClassLoader(), - new Class[]{Clock.class}, (proxy, method, args) -> { - if (method.getName().equals("elapsedRealtime")) return nowMs.get(); - throw new AssertionError("Unexpected Clock call: " + method.getName()); - }); - policy = new ExoCompressedAudioDirectPolicy( - (ignoredFormat, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (ignoredFormat, attributes) -> true, clock, config -> { - if (duringCreation != null) duringCreation.run(); - return raw.output; - }); - provider = policy.wrapOutputProvider(new FixedFormatSupportAudioOutputProvider( - new AudioOutputProvider.FormatSupport.Builder() - .setFormatSupportLevel(AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY) - .build())); - provider.getFormatSupport(formatConfig(format)); - createOutput(); - } - - void createOutput() throws Exception { - raw = new FakeAudioOutput(); - output = provider.getAudioOutput(provider.getOutputConfig(formatConfig(format))); - } - - void writeAndPlay() throws AudioOutput.WriteException { - ByteBuffer buffer = ByteBuffer.allocateDirect(32); - output.write(buffer, 1, 1_027_599_000L); - output.play(); - } - - void stall() throws AudioOutput.WriteException { - writeAndPlay(); - output.getPositionUs(); - nowMs.addAndGet(10_000); - output.getPositionUs(); - } - } - - /** - * A standard (non-vendor-direct) audio output whose writes can be accepted and then refused, - * which is exactly the shape reported by the failing device log. - */ - private static final class OffloadStallFixture { - final ExoAudioDirectFailureMemory memory = new ExoAudioDirectFailureMemory(); - final TestEnvironment environment = new TestEnvironment(); - final AtomicLong nowMs = new AtomicLong(); - final Format format = aacStereo().buildUpon().setSampleRate(22_050).build(); - final FakeAudioOutput raw = new FakeAudioOutput(); - final AudioOutputProvider.OutputConfig config; - final ExoCompressedAudioDirectPolicy policy; - final AudioOutput output; - - OffloadStallFixture(boolean offload, boolean vendorDirectSupported) throws Exception { - config = new AudioOutputProvider.OutputConfig.Builder() - .setEncoding(C.ENCODING_AAC_LC) - .setSampleRate(22_050) - .setChannelMask(Util.getAudioTrackChannelConfig(aacStereo())) - .setBufferSize(4096) - .setAudioAttributes(AudioAttributes.DEFAULT) - .setIsOffload(offload) - .build(); - Clock clock = (Clock) Proxy.newProxyInstance(Clock.class.getClassLoader(), - new Class[]{Clock.class}, (proxy, method, args) -> { - if (method.getName().equals("elapsedRealtime")) return nowMs.get(); - throw new AssertionError("Unexpected Clock call: " + method.getName()); - }); - policy = new ExoCompressedAudioDirectPolicy( - (ignored, attributes) -> supported(false, false), - (ignored, attributes) -> vendorDirectSupported, - clock, ignored -> raw.output, memory, environment); - policy.prepareForPlayback("https://example.invalid/music?token=private", false); - output = policy.wrapOutputProvider(new StandardAudioOutputProvider(config, raw.output)) - .getAudioOutput(config); - } - - void write(int bytes) throws Exception { - output.write(ByteBuffer.allocateDirect(bytes), 1, 0); - } - - void consumeFirstPayloadThenStall(int bytes) throws Exception { - write(bytes); - raw.acceptWrites = false; - write(bytes); - } - } - - private static final class FakeAudioOutput { - long positionUs; - boolean acceptWrites = true; - int maxWriteBytes = Integer.MAX_VALUE; - boolean released; - AudioOutput.WriteException writeFailure; - final AudioOutput output = (AudioOutput) Proxy.newProxyInstance( - AudioOutput.class.getClassLoader(), new Class[]{AudioOutput.class}, - (proxy, method, args) -> { - switch (method.getName()) { - case "getPositionUs": - if (released) throw new AssertionError("Queried released output"); - return positionUs; - case "write": - if (writeFailure != null) throw writeFailure; - ByteBuffer buffer = (ByteBuffer) args[0]; - if (acceptWrites) buffer.position(buffer.position() + Math.min(buffer.remaining(), maxWriteBytes)); - return !buffer.hasRemaining(); - case "release": - released = true; - return null; - default: - return null; - } - }); - } - - private static AudioOutputProvider wrapped( - ExoCompressedAudioDirectPolicy policy) { - return policy.wrapOutputProvider(new UnsupportedAudioOutputProvider()); - } - - private static AudioOutputProvider.FormatConfig formatConfig(Format format) { - return new AudioOutputProvider.FormatConfig.Builder(format) - .setAudioAttributes(AudioAttributes.DEFAULT) - .build(); - } - - private static AudioOutputProvider.FormatConfig tunnelingConfig() { - return new AudioOutputProvider.FormatConfig.Builder(aacStereo()) - .setAudioAttributes(AudioAttributes.DEFAULT) - .setAudioSessionId(1234) - .setVirtualDeviceId(0) - .setEnableTunneling(true) - .build(); - } - - private static AudioOutputProvider.OutputConfig encodedOutput(boolean tunneling, boolean offload) { - return new AudioOutputProvider.OutputConfig.Builder() - .setEncoding(C.ENCODING_AAC_LC) - .setSampleRate(48_000) - .setChannelMask(Util.getAudioTrackChannelConfig(aacStereo())) - .setBufferSize(4096) - .setAudioSessionId(1234) - .setIsTunneling(tunneling) - .setIsOffload(offload) - .build(); - } - - private static Format aacStereo() { - return new Format.Builder() - .setSampleMimeType(MimeTypes.AUDIO_AAC) - .setCodecs("mp4a.40.2") - .setSampleRate(48_000) - .setChannelCount(2) - .build(); - } - - private static AudioOffloadSupport supported( - boolean gapless, boolean speedChange) { - return new AudioOffloadSupport.Builder() - .setIsFormatSupported(true) - .setIsGaplessSupported(gapless) - .setIsSpeedChangeSupported(speedChange) - .build(); - } - - private static boolean isRecoverable(ExoPlaybackException error) throws ReflectiveOperationException { - Field field = ExoPlaybackException.class.getDeclaredField("isRecoverable"); - field.setAccessible(true); - return field.getBoolean(error); - } - - private static class UnsupportedAudioOutputProvider - implements AudioOutputProvider { - - @Override - public FormatSupport getFormatSupport(FormatConfig config) { - return FormatSupport.UNSUPPORTED; - } - - @Override - public OutputConfig getOutputConfig(FormatConfig config) - throws ConfigurationException { - throw new ConfigurationException("unsupported"); - } - - @Override - public AudioOutput getAudioOutput(OutputConfig config) - throws InitializationException { - throw new InitializationException(); - } - - @Override - public void addListener(Listener listener) { - } - - @Override - public void removeListener(Listener listener) { - } - - @Override - public void release() { - } - } - - private static final class FixedFormatSupportAudioOutputProvider - extends UnsupportedAudioOutputProvider { - - private final FormatSupport formatSupport; - - FixedFormatSupportAudioOutputProvider(FormatSupport formatSupport) { - this.formatSupport = formatSupport; - } - - @Override - public FormatSupport getFormatSupport(FormatConfig config) { - return formatSupport; - } - } - - private static final class StandardAudioOutputProvider extends UnsupportedAudioOutputProvider { - private final OutputConfig config; - private final AudioOutput output; - private final FormatSupport support = new FormatSupport.Builder() - .setFormatSupportLevel(AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY).build(); - private int creations; - - StandardAudioOutputProvider(OutputConfig config) { - this(config, (AudioOutput) Proxy.newProxyInstance(AudioOutput.class.getClassLoader(), - new Class[]{AudioOutput.class}, (proxy, method, args) -> null)); - } - - StandardAudioOutputProvider(OutputConfig config, AudioOutput output) { - this.config = config; - this.output = output; - } - - @Override public FormatSupport getFormatSupport(FormatConfig config) { return support; } - - @Override public OutputConfig getOutputConfig(FormatConfig config) { return this.config; } - - @Override public AudioOutput getAudioOutput(OutputConfig config) { - assertSame(this.config, config); - creations++; - return output; - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeProfilesSummaryTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeProfilesSummaryTest.java deleted file mode 100644 index bab39d44d5..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeProfilesSummaryTest.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import org.junit.Test; - -import java.util.List; -import java.util.Locale; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Guards the user-visible hint for silent hardware-decoder exclusions: playback falls back to - * software decoding after two consecutive failures for one decoder+format combination, which looks - * like unexplained frame drops on 4K content unless the exclusion is surfaced. - */ -public class ExoDecoderRuntimeProfilesSummaryTest { - - @Test - public void summaryIsEmptyWithoutExclusions() { - ExoDecoderRuntimeProfiles profiles = profiles(new MemoryBackend()); - - assertEquals("", profiles.blacklistSummary()); - assertEquals(0, profiles.blacklistedCount()); - } - - @Test - public void summaryDescribesExclusionAfterTwoConsecutiveFailures() { - ExoDecoderRuntimeProfiles profiles = profiles(new MemoryBackend()); - long now = System.currentTimeMillis(); - failTwice(profiles, key(1, "video/hevc", 3840, 2160), now); - - assertEquals(1, profiles.blacklistedCount()); - String summary = profiles.blacklistSummary(); - assertTrue(summary, summary.startsWith("硬解已排除 1 条")); - assertTrue(summary, summary.contains("HEVC")); - assertTrue(summary, summary.contains("3840×2160")); - } - - @Test - public void singleFailureDoesNotProduceASummary() { - ExoDecoderRuntimeProfiles profiles = profiles(new MemoryBackend()); - long now = System.currentTimeMillis(); - profiles.recordFailure( - key(1, "video/hevc", 3840, 2160), - ExoDecoderRuntimeProfileStore.FailureKind.INIT, - ExoDecoderRuntimeProfileStore.Metrics.EMPTY, - now); - - assertEquals(0, profiles.blacklistedCount()); - assertEquals("", profiles.blacklistSummary()); - } - - @Test - public void summaryIgnoresExclusionsRecordedUnderAnotherEnvironment() { - ExoDecoderRuntimeProfiles profiles = profiles(new MemoryBackend()); - long now = System.currentTimeMillis(); - // The environment digest covers Build.FINGERPRINT plus the app/media3 versions, so an - // upgraded install can no longer match these keys. They stay in the store for 30 days and - // must not be reported as an active hardware-decode fallback. - failTwice(profiles, key(9, 1, "video/hevc", 3840, 2160), now); - failTwice(profiles, key(1, 2, "video/avc", 1920, 1080), now); - - assertEquals(1, profiles.blacklistedCount()); - String summary = profiles.blacklistSummary(); - assertTrue(summary, summary.startsWith("硬解已排除 1 条")); - assertTrue(summary, summary.contains("H.264")); - assertFalse(summary, summary.contains("HEVC")); - } - - @Test - public void activeBlacklistOrdersNewestFirstAndSkipsHealthyEntries() { - ExoDecoderRuntimeProfileStore store = new ExoDecoderRuntimeProfileStore(new MemoryBackend()); - long now = System.currentTimeMillis(); - failTwice(store, key(1, "video/hevc", 3840, 2160), now - 10_000); - failTwice(store, key(2, "video/avc", 1920, 1080), now); - store.recordFailure( - key(3, "video/hevc", 1280, 720), - ExoDecoderRuntimeProfileStore.FailureKind.INIT, - ExoDecoderRuntimeProfileStore.Metrics.EMPTY, - now); - - List active = store.activeBlacklist(now); - - assertEquals(2, active.size()); - assertEquals("video/avc", active.get(0).key().mimeType()); - assertEquals("video/hevc", active.get(1).key().mimeType()); - assertFalse(store.activeBlacklist(now).contains(store.lookup(key(3, "video/hevc", 1280, 720), now).entry())); - } - - private static ExoDecoderRuntimeProfiles profiles(MemoryBackend backend) { - return new ExoDecoderRuntimeProfiles( - backend, new ExoDecoderRuntimeKey.Environment(digest(1))); - } - - private static void failTwice( - ExoDecoderRuntimeProfiles profiles, - ExoDecoderRuntimeKey.Key key, - long now) { - profiles.recordFailure(key, ExoDecoderRuntimeProfileStore.FailureKind.INIT, - ExoDecoderRuntimeProfileStore.Metrics.EMPTY, now); - profiles.recordFailure(key, ExoDecoderRuntimeProfileStore.FailureKind.DECODE, - ExoDecoderRuntimeProfileStore.Metrics.EMPTY, now + 1); - } - - private static void failTwice( - ExoDecoderRuntimeProfileStore store, - ExoDecoderRuntimeKey.Key key, - long now) { - store.recordFailure(key, ExoDecoderRuntimeProfileStore.FailureKind.INIT, - ExoDecoderRuntimeProfileStore.Metrics.EMPTY, now); - store.recordFailure(key, ExoDecoderRuntimeProfileStore.FailureKind.DECODE, - ExoDecoderRuntimeProfileStore.Metrics.EMPTY, now + 1); - } - - private static ExoDecoderRuntimeKey.Key key( - int decoder, - String mimeType, - int width, - int height) { - return key(1, decoder, mimeType, width, height); - } - - private static ExoDecoderRuntimeKey.Key key( - int environment, - int decoder, - String mimeType, - int width, - int height) { - return new ExoDecoderRuntimeKey.Key( - digest(environment), - digest(decoder + 1000), - mimeType, - digest(decoder + 2000), - 2, - 153, - width, - height, - 25_000, - 6, - 2, - 6, - true, - false, - ExoDecoderRuntimeSession.OutputTarget.SURFACE, - false); - } - - private static String digest(int value) { - return String.format(Locale.US, "%024x", value); - } - - private static final class MemoryBackend - implements ExoDecoderRuntimeProfileStore.Backend { - - private String value = ""; - - @Override - public String read() { - return value; - } - - @Override - public void write(String value) { - this.value = value == null ? "" : value; - } - - @Override - public void clear() { - value = ""; - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeSessionTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeSessionTest.java index be1214d1fe..c09fabc230 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeSessionTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeSessionTest.java @@ -171,8 +171,7 @@ public class ExoDecoderRuntimeSessionTest { ExoDecoderRuntimeSession session = session(profiles); ExoDecoderRuntimeSession.Evidence evidence = new ExoDecoderRuntimeSession.Evidence( - "decoder", format(3840, 2160), false, 24, 1, - ExoDecoderRuntimeSession.STABLE_PLAYBACK_WINDOW_MS); + "decoder", format(3840, 2160), false, 24, 1); session.recordStable( evidence, @@ -184,66 +183,6 @@ public class ExoDecoderRuntimeSessionTest { assertEquals(0, profiles.failureKeys.size()); } - @Test - public void slowDropsOverALongWindowStayHealthy() { - // 40 drops is well past the old fixed count of 23, but spread over two minutes it is - // 0.33/s, below the 0.77/s the 23-frames-per-30s budget has always meant. Reading the - // rate instead of the raw total is what keeps a long playback from being condemned for - // the tail media3 settles at every pause. - FakeProfiles profiles = new FakeProfiles(); - ExoDecoderRuntimeSession session = session(profiles); - ExoDecoderRuntimeSession.Evidence evidence = - new ExoDecoderRuntimeSession.Evidence( - "decoder", format(3840, 2160), false, 40, 0, 120_000L); - - session.recordStable( - evidence, - ELAPSED + ExoDecoderRuntimeSession.STABLE_PLAYBACK_WINDOW_MS, - EPOCH); - - assertEquals(1, profiles.successKeys.size()); - assertEquals(0, profiles.observationKeys.size()); - } - - @Test - public void shortWindowWithTheSameTotalIsUnhealthy() { - // Identical dropped-frame total in only 10 played seconds is 4/s: the rate holds the - // line the fixed count drew, instead of the window length deciding the verdict. - FakeProfiles profiles = new FakeProfiles(); - ExoDecoderRuntimeSession session = session(profiles); - ExoDecoderRuntimeSession.Evidence evidence = - new ExoDecoderRuntimeSession.Evidence( - "decoder", format(3840, 2160), false, 40, 0, 10_000L); - - session.recordStable( - evidence, - ELAPSED + ExoDecoderRuntimeSession.STABLE_PLAYBACK_WINDOW_MS, - EPOCH); - - assertEquals(0, profiles.successKeys.size()); - assertEquals(1, profiles.observationKeys.size()); - } - - @Test - public void unmeasuredPlayTimeFallsBackToTheFixedCount() { - // Played time is only measured while the analysis listener sees isPlaying; if that - // never happened the old fixed count still applies, so an unmeasured session cannot - // become healthier than it was before. - FakeProfiles profiles = new FakeProfiles(); - ExoDecoderRuntimeSession session = session(profiles); - ExoDecoderRuntimeSession.Evidence evidence = - new ExoDecoderRuntimeSession.Evidence( - "decoder", format(3840, 2160), false, 40, 0); - - session.recordStable( - evidence, - ELAPSED + ExoDecoderRuntimeSession.STABLE_PLAYBACK_WINDOW_MS, - EPOCH); - - assertEquals(0, profiles.successKeys.size()); - assertEquals(1, profiles.observationKeys.size()); - } - @Test public void healthyFallbackRecordsSuccessForFailedKey() { FakeProfiles profiles = new FakeProfiles(); diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCodecSnapshotCacheTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCodecSnapshotCacheTest.java deleted file mode 100644 index 2bcb3c8db3..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDiagnosticCodecSnapshotCacheTest.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.player.PlaybackDiagnosticCollector.Context; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; - -public class ExoDiagnosticCodecSnapshotCacheTest { - private static final Context MEDIA = new Context("test", 1, 1, "media-a", "foreground"); - private static final ExoDiagnosticCodecSnapshotCache.Query QUERY = query("video/dolby-vision", false, false, "selector"); - private static final ExoDiagnosticCodecSnapshotCache.Candidate DV = candidate("dv5", 32, 256); - - @Test public void repeatedQueriesReferenceTheOriginalSnapshotAndKeepCounts() { - ExoDiagnosticCodecSnapshotCache cache = new ExoDiagnosticCodecSnapshotCache(); - var first = cache.observe(MEDIA, 1, QUERY, List.of(DV)); - assertTrue(first.fullSnapshot()); - for (int i = 2; i <= 100; i++) { - var next = cache.observe(MEDIA, 1, QUERY, List.of(candidate("dv5", 32, 256))); - assertFalse(next.fullSnapshot()); - assertEquals(first.snapshotId(), next.snapshotId()); - assertEquals(i, next.queryCount()); - } - } - - @Test public void orderCapabilitiesAndAttributesArePartOfTheSnapshot() { - ExoDiagnosticCodecSnapshotCache cache = new ExoDiagnosticCodecSnapshotCache(); - var hevc = candidate("hevc", 2, 256); - cache.observe(MEDIA, 1, QUERY, List.of(DV, hevc)); - assertTrue(cache.observe(MEDIA, 1, QUERY, List.of(hevc, DV)).fullSnapshot()); - assertTrue(cache.observe(MEDIA, 1, QUERY, List.of(hevc, candidate("dv5", 32, 512))).fullSnapshot()); - assertTrue(cache.observe(MEDIA, 1, QUERY, List.of(hevc, - new ExoDiagnosticCodecSnapshotCache.Candidate("dv5", false, true, false, - 1, List.of(new ExoDiagnosticCodecSnapshotCache.Profile(32, 512)), true))).fullSnapshot()); - } - - @Test public void queriesAreSeparatedByMimeSecurityTunnelingAndPolicy() { - ExoDiagnosticCodecSnapshotCache cache = new ExoDiagnosticCodecSnapshotCache(); - cache.observe(MEDIA, 1, QUERY, List.of(DV)); - for (var key : List.of(query("video/hevc", false, false, "selector"), - query("video/dolby-vision", true, false, "selector"), - query("video/dolby-vision", false, true, "selector"), - query("video/dolby-vision", false, false, "renderer-final-order"))) { - assertTrue(cache.observe(MEDIA, 1, key, List.of(DV)).fullSnapshot()); - } - assertFalse(cache.observe(MEDIA, 1, QUERY, List.of(DV)).fullSnapshot()); - } - - @Test public void clearCaptureRestartAndReprepareRequireCompleteSnapshots() { - ExoDiagnosticCodecSnapshotCache cache = new ExoDiagnosticCodecSnapshotCache(); - long original = cache.observe(MEDIA, 1, QUERY, List.of(DV)).snapshotId(); - assertTrue(cache.observe(MEDIA, 2, QUERY, List.of(DV)).fullSnapshot()); - Context nextMedia = new Context("test", 1, 2, "media-b", "foreground"); - assertTrue(cache.observe(nextMedia, 2, QUERY, List.of(DV)).fullSnapshot()); - cache.clear(); - var restarted = cache.observe(nextMedia, 2, QUERY, List.of(DV)); - assertTrue(restarted.fullSnapshot()); - assertNotEquals(original, restarted.snapshotId()); - } - - @Test public void failedReadsAreNeverReusedAndEmptyResultsCanChange() { - ExoDiagnosticCodecSnapshotCache cache = new ExoDiagnosticCodecSnapshotCache(); - var failed = new ExoDiagnosticCodecSnapshotCache.Candidate("dv5", true, false, true, 0, List.of(), false); - cache.observe(MEDIA, 1, QUERY, List.of(failed)); - assertTrue(cache.observe(MEDIA, 1, QUERY, List.of(failed)).fullSnapshot()); - assertTrue(cache.observe(MEDIA, 1, QUERY, List.of()).fullSnapshot()); - assertFalse(cache.observe(MEDIA, 1, QUERY, List.of()).fullSnapshot()); - assertTrue(cache.observe(MEDIA, 1, QUERY, List.of(DV)).fullSnapshot()); - } - - @Test public void cacheIsBoundedAndEvictionProducesACompleteSnapshot() { - ExoDiagnosticCodecSnapshotCache cache = new ExoDiagnosticCodecSnapshotCache(); - cache.observe(MEDIA, 1, QUERY, List.of(DV)); - for (int i = 0; i < ExoDiagnosticCodecSnapshotCache.MAX_QUERIES; i++) { - cache.observe(MEDIA, 1, query("video/test-" + i, false, false, "selector"), List.of(DV)); - } - assertTrue(cache.observe(MEDIA, 1, QUERY, List.of(DV)).fullSnapshot()); - } - - @Test public void mutableInputCannotChangeAnAlreadyRecordedSnapshot() { - ExoDiagnosticCodecSnapshotCache cache = new ExoDiagnosticCodecSnapshotCache(); - List candidates = new ArrayList<>(List.of(DV)); - cache.observe(MEDIA, 1, QUERY, candidates); - candidates.clear(); - assertFalse(cache.observe(MEDIA, 1, QUERY, List.of(DV)).fullSnapshot()); - } - - @Test public void concurrentQueriesKeepOneSnapshotAndAnExactTotal() throws Exception { - ExoDiagnosticCodecSnapshotCache cache = new ExoDiagnosticCodecSnapshotCache(); - long id = cache.observe(MEDIA, 1, QUERY, List.of(DV)).snapshotId(); - ExecutorService executor = Executors.newFixedThreadPool(4); - try { - List> calls = new ArrayList<>(); - for (int worker = 0; worker < 4; worker++) calls.add(() -> { - for (int i = 0; i < 100; i++) { - var result = cache.observe(MEDIA, 1, QUERY, List.of(DV)); - if (result.fullSnapshot() || result.snapshotId() != id) return false; - } - return true; - }); - for (Future result : executor.invokeAll(calls)) assertTrue(result.get()); - } finally { - executor.shutdownNow(); - } - assertEquals(402, cache.observe(MEDIA, 1, QUERY, List.of(DV)).queryCount()); - } - - private static ExoDiagnosticCodecSnapshotCache.Candidate candidate(String name, int profile, int level) { - return new ExoDiagnosticCodecSnapshotCache.Candidate(name, true, false, true, 1, - List.of(new ExoDiagnosticCodecSnapshotCache.Profile(profile, level)), true); - } - - private static ExoDiagnosticCodecSnapshotCache.Query query(String mime, boolean secure, boolean tunneling, String policy) { - return new ExoDiagnosticCodecSnapshotCache.Query(mime, secure, tunneling, policy); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDv5GpuMappingPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDv5GpuMappingPolicyTest.java deleted file mode 100644 index 3de7a2bbab..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDv5GpuMappingPolicyTest.java +++ /dev/null @@ -1,264 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class ExoDv5GpuMappingPolicyTest { - - @Test - public void detectsOnlyDolbyVisionProfile5() { - assertTrue(ExoDv5GpuMappingPolicy.isProfile5( - "video/dolby-vision", "dvhe.05.06")); - assertTrue(ExoDv5GpuMappingPolicy.isProfile5( - "video/dolby-vision", " DVH1.05.09 , hev1.2.4.L153.B0")); - assertFalse(ExoDv5GpuMappingPolicy.isProfile5( - "video/dolby-vision", "dvhe.07.06")); - assertFalse(ExoDv5GpuMappingPolicy.isProfile5( - "video/dolby-vision", "dvh1.08.06")); - assertFalse(ExoDv5GpuMappingPolicy.isProfile5( - "video/hevc", "dvhe.05.06")); - } - - @Test - public void nativeDolbyVisionWinsWhenDisplayAndDecoderAreAvailable() { - ExoDv5GpuMappingPolicy.Decision decision = decide(input() - .nativeDisplay(true) - .nativeDecoder(true) - .codecToneMapAccepted(true) - .rendererAvailable(true) - .probePassed(true) - .experimentEnabled(true)); - - assertRoute( - decision, - ExoDv5GpuMappingPolicy.Route.NATIVE_DOLBY_VISION, - ExoDv5GpuMappingPolicy.Reason.NATIVE_DOLBY_VISION_AVAILABLE); - } - - @Test - public void nativeDolbyVisionRequiresBothDisplayAndDecoder() { - ExoDv5GpuMappingPolicy.Decision decision = decide(input() - .nativeDisplay(true) - .rendererAvailable(true) - .probePassed(true) - .experimentEnabled(true)); - - assertRoute( - decision, - ExoDv5GpuMappingPolicy.Route.GPU_MAPPING, - ExoDv5GpuMappingPolicy.Reason.GPU_MAPPING_ELIGIBLE); - } - - @Test - public void acceptedCodecToneMapRequiresApi31AndPrecedesGpuMapping() { - ExoDv5GpuMappingPolicy.Decision accepted = decide(input() - .apiLevel(31) - .codecToneMapAccepted(true) - .rendererAvailable(true) - .probePassed(true) - .experimentEnabled(true)); - ExoDv5GpuMappingPolicy.Decision tooOld = decide(input() - .apiLevel(30) - .codecToneMapAccepted(true) - .rendererAvailable(true) - .probePassed(true) - .experimentEnabled(true)); - - assertRoute( - accepted, - ExoDv5GpuMappingPolicy.Route.CODEC_TONE_MAP, - ExoDv5GpuMappingPolicy.Reason.CODEC_TONE_MAP_ACCEPTED); - assertEquals(ExoDv5GpuMappingPolicy.Route.GPU_MAPPING, tooOld.route()); - } - - @Test - public void gpuMappingRequiresEveryRuntimeGate() { - ExoDv5GpuMappingPolicy.Decision decision = decide(input() - .rendererAvailable(true) - .probePassed(true) - .experimentEnabled(true)); - - assertRoute( - decision, - ExoDv5GpuMappingPolicy.Route.GPU_MAPPING, - ExoDv5GpuMappingPolicy.Reason.GPU_MAPPING_ELIGIBLE); - assertTrue(decision.usesGpuMapping()); - assertEquals(ExoDv5GpuMappingPolicy.Reason.NONE, - decision.gpuBlockReason()); - } - - @Test - public void drmNeverEntersGpuMapping() { - ExoDv5GpuMappingPolicy.Decision decision = decide(input() - .drmProtected(true) - .rendererAvailable(true) - .probePassed(true) - .experimentEnabled(true) - .legacyFallbackAllowed(true)); - - assertBlocked( - decision, ExoDv5GpuMappingPolicy.Reason.DRM_REQUIRES_SECURE_OUTPUT); - } - - @Test - public void gpuMappingReportsEachMissingCapability() { - assertBlocked(decide(input().apiLevel(25) - .rendererAvailable(true).probePassed(true).experimentEnabled(true)), - ExoDv5GpuMappingPolicy.Reason.API_BELOW_26); - assertBlocked(decide(input().rendererAvailable(true).probePassed(true)), - ExoDv5GpuMappingPolicy.Reason.EXPERIMENT_DISABLED); - assertBlocked(decide(input().tunneling(true) - .rendererAvailable(true).probePassed(true).experimentEnabled(true)), - ExoDv5GpuMappingPolicy.Reason.TUNNELING_UNSUPPORTED); - assertBlocked(decide(input().hevcDecoder(false) - .rendererAvailable(true).probePassed(true).experimentEnabled(true)), - ExoDv5GpuMappingPolicy.Reason.HEVC_DECODER_UNAVAILABLE); - assertBlocked(decide(input().probePassed(true).experimentEnabled(true)), - ExoDv5GpuMappingPolicy.Reason.RENDERER_UNAVAILABLE); - assertBlocked(decide(input().rendererAvailable(true).experimentEnabled(true)), - ExoDv5GpuMappingPolicy.Reason.VULKAN_AHB_PROBE_FAILED); - } - - @Test - public void legacyFallbackIsExplicitAndRetainsGpuBlockReason() { - ExoDv5GpuMappingPolicy.Decision allowed = decide(input() - .legacyFallbackAllowed(true)); - ExoDv5GpuMappingPolicy.Decision rejected = decide(input()); - - assertRoute( - allowed, - ExoDv5GpuMappingPolicy.Route.LEGACY_HDR10_FALLBACK, - ExoDv5GpuMappingPolicy.Reason.LEGACY_HDR10_FALLBACK_ALLOWED); - assertEquals( - ExoDv5GpuMappingPolicy.Reason.EXPERIMENT_DISABLED, - allowed.gpuBlockReason()); - assertBlocked( - rejected, ExoDv5GpuMappingPolicy.Reason.EXPERIMENT_DISABLED); - } - - @Test - public void nonProfile5NeverSelectsAnyFallback() { - ExoDv5GpuMappingPolicy.Decision decision = decide(input().profile5(false)); - - assertBlocked(decision, ExoDv5GpuMappingPolicy.Reason.NOT_PROFILE_5); - } - - private static void assertRoute( - ExoDv5GpuMappingPolicy.Decision decision, - ExoDv5GpuMappingPolicy.Route route, - ExoDv5GpuMappingPolicy.Reason reason) { - assertEquals(route, decision.route()); - assertEquals(reason, decision.reason()); - } - - private static void assertBlocked( - ExoDv5GpuMappingPolicy.Decision decision, - ExoDv5GpuMappingPolicy.Reason reason) { - assertRoute(decision, ExoDv5GpuMappingPolicy.Route.UNSUPPORTED, reason); - assertEquals(reason, decision.gpuBlockReason()); - assertFalse(decision.usesGpuMapping()); - } - - private static ExoDv5GpuMappingPolicy.Decision decide(InputBuilder input) { - return ExoDv5GpuMappingPolicy.decide(input.build()); - } - - private static InputBuilder input() { - return new InputBuilder(); - } - - private static final class InputBuilder { - - private boolean profile5 = true; - private boolean drmProtected; - private boolean nativeDisplay; - private boolean nativeDecoder; - private int apiLevel = 34; - private boolean codecToneMapAccepted; - private boolean hevcDecoder = true; - private boolean rendererAvailable; - private boolean probePassed; - private boolean experimentEnabled; - private boolean tunneling; - private boolean legacyFallbackAllowed; - - InputBuilder profile5(boolean value) { - profile5 = value; - return this; - } - - InputBuilder drmProtected(boolean value) { - drmProtected = value; - return this; - } - - InputBuilder nativeDisplay(boolean value) { - nativeDisplay = value; - return this; - } - - InputBuilder nativeDecoder(boolean value) { - nativeDecoder = value; - return this; - } - - InputBuilder apiLevel(int value) { - apiLevel = value; - return this; - } - - InputBuilder codecToneMapAccepted(boolean value) { - codecToneMapAccepted = value; - return this; - } - - InputBuilder hevcDecoder(boolean value) { - hevcDecoder = value; - return this; - } - - InputBuilder rendererAvailable(boolean value) { - rendererAvailable = value; - return this; - } - - InputBuilder probePassed(boolean value) { - probePassed = value; - return this; - } - - InputBuilder experimentEnabled(boolean value) { - experimentEnabled = value; - return this; - } - - InputBuilder tunneling(boolean value) { - tunneling = value; - return this; - } - - InputBuilder legacyFallbackAllowed(boolean value) { - legacyFallbackAllowed = value; - return this; - } - - ExoDv5GpuMappingPolicy.Input build() { - return new ExoDv5GpuMappingPolicy.Input( - profile5, - drmProtected, - nativeDisplay, - nativeDecoder, - apiLevel, - codecToneMapAccepted, - hevcDecoder, - rendererAvailable, - probePassed, - experimentEnabled, - tunneling, - legacyFallbackAllowed); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDv5GpuRendererTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDv5GpuRendererTest.java deleted file mode 100644 index 121ce0704e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDv5GpuRendererTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import com.fongmi.android.tv.player.PlaybackExperimentPolicy; - -import org.junit.Test; - -import java.nio.ByteBuffer; -import java.util.List; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class ExoDv5GpuRendererTest { - - @Test - public void rendererFactoryIsEnabledWithStablePlaybackDefaults() { - ExoDv5Native.Probe available = new ExoDv5Native.Probe( - true, ExoDv5Native.REQUIRED_CAPABILITIES, "available"); - - assertFalse(PlaybackExperimentPolicy.State.stable() - .domainEnabled(PlaybackExperimentPolicy.Domain.EXO)); - assertTrue(ExoDv5GpuRendererFactory.shouldCreate(available)); - } - - @Test - public void rendererFactoryRejectsUnavailableOrMissingBackend() { - ExoDv5Native.Probe unavailable = new ExoDv5Native.Probe( - false, ExoDv5Native.CAPABILITY_IMAGE_READER, "missing-capability"); - - assertFalse(ExoDv5GpuRendererFactory.shouldCreate(unavailable)); - assertFalse(ExoDv5GpuRendererFactory.shouldCreate(null)); - } - - @Test - public void frameActionWaitsSchedulesAndDropsWithinBounds() { - assertEquals( - ExoDv5VideoSink.FrameAction.WAIT, - ExoDv5VideoSink.frameAction(false, false, 0)); - assertEquals( - ExoDv5VideoSink.FrameAction.RENDER, - ExoDv5VideoSink.frameAction(false, true, 0)); - assertEquals( - ExoDv5VideoSink.FrameAction.WAIT, - ExoDv5VideoSink.frameAction(true, false, 50_001)); - assertEquals( - ExoDv5VideoSink.FrameAction.RENDER, - ExoDv5VideoSink.frameAction(true, false, 50_000)); - assertEquals( - ExoDv5VideoSink.FrameAction.RENDER, - ExoDv5VideoSink.frameAction(true, false, -30_000)); - assertEquals( - ExoDv5VideoSink.FrameAction.DROP, - ExoDv5VideoSink.frameAction(true, false, -30_001)); - } - - @Test - public void imageTimestampUsesPresentationTimeAndSaturates() { - assertEquals(1_234_000L, - ExoDv5VideoSink.imageTimestampNsFor(1_234)); - assertEquals(-1_234_000L, - ExoDv5VideoSink.imageTimestampNsFor(-1_234)); - assertEquals(Long.MAX_VALUE, - ExoDv5VideoSink.imageTimestampNsFor(Long.MAX_VALUE)); - assertEquals(Long.MIN_VALUE, - ExoDv5VideoSink.imageTimestampNsFor(Long.MIN_VALUE)); - } - - @Test - public void probeCapabilityBitsAreIndependentlyVisible() { - ExoDv5Native.Probe probe = new ExoDv5Native.Probe( - false, - ExoDv5Native.CAPABILITY_IMAGE_READER - | ExoDv5Native.CAPABILITY_AHB_IMPORT, - "missing-capability"); - - assertTrue(probe.has(ExoDv5Native.CAPABILITY_IMAGE_READER)); - assertTrue(probe.has(ExoDv5Native.CAPABILITY_AHB_IMPORT)); - assertFalse(probe.has(ExoDv5Native.CAPABILITY_YCBCR_CONVERSION)); - assertFalse(probe.has(ExoDv5Native.CAPABILITY_VULKAN_12)); - } - - @Test - public void emptyStatsExposeRenderOutcomeCounters() { - ExoDv5Native.Stats stats = ExoDv5Native.Stats.empty(); - - assertEquals(0, stats.renderedFrames()); - assertEquals(0, stats.renderFailures()); - } - - @Test - public void extractsRpuFromAnnexBWithoutMutatingInput() { - ByteBuffer sample = ByteBuffer.wrap(new byte[] { - 0, 0, 0, 1, 0x40, 1, 9, - 0, 0, 1, 0x7c, 1, 2, 3, - }); - sample.position(2); - int position = sample.position(); - - List rpus = ExoDv5GpuRenderer.findRpuNalus(sample); - - assertEquals(position, sample.position()); - assertEquals(1, rpus.size()); - assertArrayEquals(new byte[] {0x7c, 1, 2, 3}, rpus.get(0)); - } - - @Test - public void extractsRpuFromFourByteLengthPrefixedAccessUnit() { - ByteBuffer sample = ByteBuffer.wrap(new byte[] { - 0, 0, 0, 3, 0x40, 1, 9, - 0, 0, 0, 4, 0x7c, 1, 2, 3, - }); - - List rpus = ExoDv5GpuRenderer.findRpuNalus(sample); - - assertEquals(1, rpus.size()); - assertArrayEquals(new byte[] {0x7c, 1, 2, 3}, rpus.get(0)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoEofRecoveryWiringSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoEofRecoveryWiringSourceTest.java deleted file mode 100644 index 9d2d59d5ee..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoEofRecoveryWiringSourceTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Pins the wiring that decides which HTTP EOF recoveries are published as foreground. - * - *

This is a source guard rather than a behavioral test because building the real data source - * chain needs {@code App.get()}, which is unavailable in a plain JVM test. The contract is small and - * structural, so reading it is both sufficient and stable. - * - *

Both directions are load-bearing. If the preload path were marked foreground, a preload-side - * EOF would publish the signal that makes preload yield — preload would stall itself and never - * restart. If the foreground path were not marked, the yield would never fire at all and the change - * would be inert. - */ -public class ExoEofRecoveryWiringSourceTest { - - @Test - public void preloadPathIsMarkedNonForeground() throws Exception { - String factory = read("MediaSourceFactory.java"); - String preloadFactory = methodBody( - factory, - "static DataSource.Factory createUpstreamDataSourceFactory(", - "static synchronized Cache getCache()"); - - assertTrue("preload must build its recovery source explicitly", - preloadFactory.contains("new HttpEofRecoveryDataSource.Factory(upstream, false)")); - } - - @Test - public void foregroundPathIsMarkedForeground() throws Exception { - String factory = read("MediaSourceFactory.java"); - String cacheDataSource = methodBody( - factory, - "private CacheDataSource.Factory getCacheDataSource(\n DataSource.Factory upstreamFactory, Map headers)", - "private OkHttpDataSource.Factory getHttpDataSourceFactory()"); - - assertTrue("the cache-backed foreground path must publish recoveries", - cacheDataSource.contains("new HttpEofRecoveryDataSource.Factory(upstreamFactory, true)")); - } - - @Test - public void preloadNeverReachesTheForegroundDataSourceFactory() throws Exception { - // PreCache must build its chain only through the preload factory. If it ever reached - // getDataSourceFactory(headers) it would silently publish preload recoveries as foreground. - String preCache = read("PreCache.java"); - - assertTrue("PreCache is expected to use the preload upstream factory", - preCache.contains("MediaSourceFactory.createUpstreamDataSourceFactory(")); - assertFalse("PreCache must not build a foreground media source", - preCache.contains("MediaSourceFactory.createMediaSource(")); - } - - private static String methodBody(String source, String signature, String nextSignature) { - int start = source.indexOf(signature); - assertTrue("missing signature: " + signature, start >= 0); - int end = source.indexOf(nextSignature, start); - assertTrue("missing following signature: " + nextSignature, end > start); - return source.substring(start, end); - } - - private static String read(String fileName) throws Exception { - // Gradle may run from the repository root or the module directory; try both, and normalize - // line endings so the guards below are not defeated by CRLF. - Path root = Path.of("").toAbsolutePath(); - Path relative = Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", - "player", "exo", fileName); - Path source = root.resolve(relative); - if (!Files.exists(source)) source = root.resolve(Path.of("src", "main", "java", "com", - "fongmi", "android", "tv", "player", "exo", fileName)); - assertTrue("missing source file: " + source, Files.exists(source)); - return Files.readString(source, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoFfmpegFallbackTuneTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoFfmpegFallbackTuneTest.java new file mode 100644 index 0000000000..1d9a8b7211 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoFfmpegFallbackTuneTest.java @@ -0,0 +1,87 @@ +package com.fongmi.android.tv.player.exo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.media3.exoplayer.DefaultRenderersFactory; + +import com.fongmi.android.tv.player.engine.PlayerEngine; + +import org.junit.Test; + +public class ExoFfmpegFallbackTuneTest { + + @Test + public void hardDecodeStillReachesTheFfmpegVideoRenderer() { + // Hard decode maps to EXTENSION_RENDERER_MODE_OFF, which getFfmpegVideoRenderMode + // converts to ON so the renderer stays available as a codec fallback. + int hardMode = ExoUtil.getRenderMode(PlayerEngine.HARD); + assertTrue(hardMode == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF); + assertTrue(ExoUtil.isFfmpegVideoReachable(hardMode)); + } + + @Test + public void softDecodeReachesTheFfmpegVideoRenderer() { + int softMode = ExoUtil.getRenderMode(PlayerEngine.SOFT); + assertTrue(ExoUtil.isFfmpegVideoReachable(softMode)); + } + + @Test + public void tuningAppliesToTheHardDecodeFallbackNotJustSoftDecode() { + int hardMode = ExoUtil.getRenderMode(PlayerEngine.HARD); + int softMode = ExoUtil.getRenderMode(PlayerEngine.SOFT); + + assertTrue(ExoUtil.shouldTuneFfmpegVideo(true, ExoUtil.isFfmpegVideoReachable(hardMode))); + assertTrue(ExoUtil.shouldTuneFfmpegVideo(true, ExoUtil.isFfmpegVideoReachable(softMode))); + } + + @Test + public void tuningStaysOffWhenTheUserDisabledIt() { + int hardMode = ExoUtil.getRenderMode(PlayerEngine.HARD); + assertFalse(ExoUtil.shouldTuneFfmpegVideo(false, ExoUtil.isFfmpegVideoReachable(hardMode))); + } + + @Test + public void tuningStaysOffWhenTheRendererCannotDecode() { + assertFalse(ExoUtil.shouldTuneFfmpegVideo(true, false)); + } + + @Test + public void decodeBuffersScaleWithThreadCount() { + // Frame-threaded decode needs roughly threads + 1 frames in flight. + assertTrue(ExoUtil.ffmpegDecodeBuffers(8) > ExoUtil.ffmpegDecodeBuffers(2)); + assertEquals(10, ExoUtil.ffmpegDecodeBuffers(8)); + assertEquals(6, ExoUtil.ffmpegDecodeBuffers(4)); + } + + @Test + public void decodeBuffersNeverDropBelowTheOldFixedFloor() { + // The previous behavior was a fixed 4; single-core must not regress below it. + assertEquals(4, ExoUtil.ffmpegDecodeBuffers(1)); + assertEquals(4, ExoUtil.ffmpegDecodeBuffers(0)); + assertEquals(4, ExoUtil.ffmpegDecodeBuffers(-3)); + } + + @Test + public void decodeBuffersAreCappedSoMemoryStaysBounded() { + assertEquals(12, ExoUtil.ffmpegDecodeBuffers(64)); + assertEquals(12, ExoUtil.ffmpegDecodeBuffers(Integer.MAX_VALUE)); + } + + @Test + public void loadSheddingKeepsEveryFrame() { + // AVDISCARD_NONREF (8) would drop frames the renderer never sees, breaking motion + // continuity while still reporting zero dropped frames. + assertEquals(0, ExoUtil.ffmpegSkipFrame()); + } + + @Test + public void hardDecodeFallbackRemainsFallbackOnly() { + // Tuning must not turn the fallback renderer into a track thief: it still only + // claims codecs MediaCodec cannot handle. + int hardMode = ExoUtil.getRenderMode(PlayerEngine.HARD); + assertTrue(ExoUtil.isFfmpegVideoFallbackOnly(hardMode, false)); + assertFalse(ExoUtil.isFfmpegVideoFallbackOnly(hardMode, true)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoForegroundEofSignalTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoForegroundEofSignalTest.java deleted file mode 100644 index 356904433a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoForegroundEofSignalTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import com.fongmi.android.tv.player.PlaybackAutoContext; -import com.fongmi.android.tv.player.PlaybackRoute; - -import org.junit.After; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class ExoForegroundEofSignalTest { - - @After - public void clearSignal() { - // Process-level state: without this, cases would leak into one another. - ExoForegroundEofSignal.reset(); - } - - @Test - public void neverObservedRecoveryReportsUnreachableAge() { - ExoForegroundEofSignal.reset(); - // Must be far above any yield window. Returning 0 here would make a healthy session look - // like a recovering one and suspend preload permanently. - assertEquals(Long.MAX_VALUE, ExoForegroundEofSignal.ageMs(0)); - assertEquals(Long.MAX_VALUE, ExoForegroundEofSignal.ageMs(1_000_000)); - } - - @Test - public void ageGrowsFromTheRecordedMoment() { - ExoForegroundEofSignal.reset(); - ExoForegroundEofSignal.record(10_000); - - assertEquals(0, ExoForegroundEofSignal.ageMs(10_000)); - assertEquals(5_000, ExoForegroundEofSignal.ageMs(15_000)); - assertEquals(15_000, ExoForegroundEofSignal.ageMs(25_000)); - } - - @Test - public void laterRecoveryReplacesTheEarlierOne() { - ExoForegroundEofSignal.reset(); - ExoForegroundEofSignal.record(10_000); - ExoForegroundEofSignal.record(40_000); - - // A second burst must restart the window, not inherit the first one's age. - assertEquals(0, ExoForegroundEofSignal.ageMs(40_000)); - assertEquals(1_000, ExoForegroundEofSignal.ageMs(41_000)); - } - - @Test - public void clockGoingBackwardsIsClampedToZero() { - ExoForegroundEofSignal.reset(); - ExoForegroundEofSignal.record(50_000); - - // elapsedRealtime is monotonic in practice, but a negative age must never be reported as a - // value that could compare below a yield window by accident. - assertEquals(0, ExoForegroundEofSignal.ageMs(49_000)); - assertTrue(ExoForegroundEofSignal.ageMs(49_000) >= 0); - } - - /** - * End-to-end through the real capture path. The policy tests build {@code Inputs} directly, so - * they would still pass if {@code Inputs.capture()} never read the signal at all — the change - * would be silently inert in production. This pins the wiring itself. - */ - @Test - public void captureReadsTheSignalSoTheYieldCanActuallyFire() { - PlaybackAutoContext.SessionToken session = - new PlaybackAutoContext.SessionToken("p-eof-capture", 1); - PlaybackAutoContext context = new PlaybackAutoContext( - session, 0, 1, 0, - PlaybackAutoContext.Fact.forSession(PlaybackAutoContext.Kernel.EXO, - PlaybackAutoContext.ValueSource.PLAYER_MANAGER, PlaybackAutoContext.Confidence.HIGH, 0), - PlaybackAutoContext.Fact.unknown(PlaybackAutoContext.DecodeMode.UNKNOWN), - null, null, null, null); - - ExoForegroundEofSignal.reset(); - ExoForegroundEofSignal.record(1_000); - AutoPreloadPolicy.Inputs fresh = capture(session, context, 1_000); - assertEquals(0, fresh.foregroundEofRecoveryAgeMs()); - assertFalse("a fresh recovery must pause preload", - new AutoPreloadPolicy().evaluate(fresh).enabled()); - - // Same session, later clock: the window has passed and preload must be allowed again. - AutoPreloadPolicy.Inputs expired = capture(session, context, 1_000 + 60_000); - assertEquals(60_000, expired.foregroundEofRecoveryAgeMs()); - assertTrue("an expired recovery must not pause preload", - new AutoPreloadPolicy().evaluate(expired).enabled()); - } - - @Test - public void captureWithoutAnyRecoveryNeverYields() { - PlaybackAutoContext.SessionToken session = - new PlaybackAutoContext.SessionToken("p-eofcap-none", 1); - PlaybackAutoContext context = new PlaybackAutoContext( - session, 0, 1, 0, - PlaybackAutoContext.Fact.forSession(PlaybackAutoContext.Kernel.EXO, - PlaybackAutoContext.ValueSource.PLAYER_MANAGER, PlaybackAutoContext.Confidence.HIGH, 0), - PlaybackAutoContext.Fact.unknown(PlaybackAutoContext.DecodeMode.UNKNOWN), - null, null, null, null); - - ExoForegroundEofSignal.reset(); - AutoPreloadPolicy.Inputs inputs = capture(session, context, 5_000); - - assertEquals(Long.MAX_VALUE, inputs.foregroundEofRecoveryAgeMs()); - assertTrue("a healthy session must keep preloading", - new AutoPreloadPolicy().evaluate(inputs).enabled()); - } - - private static AutoPreloadPolicy.Inputs capture( - PlaybackAutoContext.SessionToken session, - PlaybackAutoContext context, - long nowElapsedMs) { - return AutoPreloadPolicy.Inputs.capture( - nowElapsedMs, - session, - PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, - 30_000, - 15_000_000, - 0, - false, - ForwardBufferTrend.Snapshot.unknown(), - ExoThroughputEstimator.Snapshot.empty(), - context, - false, - false); - } - - @Test - public void resetReturnsToUnobserved() { - ExoForegroundEofSignal.record(10_000); - ExoForegroundEofSignal.reset(); - - assertEquals(Long.MAX_VALUE, ExoForegroundEofSignal.ageMs(10_000)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoFrameTimingMetricsTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoFrameTimingMetricsTest.java index 5b5467586b..edc88f576b 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoFrameTimingMetricsTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoFrameTimingMetricsTest.java @@ -110,28 +110,6 @@ public class ExoFrameTimingMetricsTest { assertEquals(0, snapshot.processingOffsetBuckets().total()); } - @Test - public void bucketsSeparateLateFramesFromEarlyFrames() { - // media3 reports one offset sample per processed frame: positive = processed early enough, - // negative = the position had already passed the frame. These buckets are what turns a - // "掉帧" report into "frames delivered 30-100 ms late" — the measurement shape - // androidx/media#3347 needed to tell a skip apart from a hold. - ExoFrameTimingMetrics metrics = new ExoFrameTimingMetrics(); - metrics.observeProcessingOffset(-30_000, 1, true); - metrics.observeProcessingOffset(-5_000, 1, true); - metrics.observeProcessingOffset(10_000, 1, true); - metrics.observeProcessingOffset(60_000, 1, true); - - ExoFrameTimingMetrics.SignedTimingBuckets buckets = - metrics.snapshot().processingOffsetBuckets(); - assertEquals(1, buckets.earlierThanMinus20Ms()); - assertEquals(1, buckets.minus20MsToZero()); - assertEquals(1, buckets.zeroTo20Ms()); - assertEquals(1, buckets.fiftyTo75Ms()); - assertEquals(4, buckets.total()); - assertEquals("1/1/1/0/1/0/0", buckets.compact()); - } - @Test public void extremeTimestampsSaturateWithoutNegativeJitterOrCounts() { ExoFrameTimingMetrics metrics = new ExoFrameTimingMetrics(); diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSourceTest.java deleted file mode 100644 index 4ef4e28a1e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSourceTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; - -public class ExoHlsAdblockDataSourceTest { - - @Test - public void identifiesOnlyHlsManifestRequests() { - assertTrue(ExoHlsAdblockDataSource.isManifestUrl("https://example.test/live/index.m3u8")); - assertTrue(ExoHlsAdblockDataSource.isManifestUrl("https://example.test/live/playlist.m3u8?token=1")); - assertFalse(ExoHlsAdblockDataSource.isManifestUrl("https://example.test/live/segment.ts")); - assertFalse(ExoHlsAdblockDataSource.isManifestUrl("https://example.test/live/key.bin")); - } - - @Test - public void identifiesExtensionlessHlsManifestFromResponseContentType() { - assertTrue(ExoHlsAdblockDataSource.isManifestRequest( - "https://example.test/playback/token", - Map.of("Content-Type", List.of("application/vnd.apple.mpegurl; charset=utf-8")))); - assertTrue(ExoHlsAdblockDataSource.isManifestRequest( - "https://example.test/playback/token", - Map.of("content-type", List.of("application/x-mpegURL")))); - assertFalse(ExoHlsAdblockDataSource.isManifestRequest( - "https://example.test/live/segment.ts", - Map.of("Content-Type", List.of("video/mp2t")))); - } - - @Test - public void octetStreamDoesNotVetoARealPlaylist() { - // 不少静态服务器没配 .m3u8 的 MIME,真播放列表也返回 application/octet-stream; - // 若把它当分片证据,广告过滤会在这些源上整体失效。 - assertTrue(ExoHlsAdblockDataSource.isManifestRequest( - "https://example.test/live/index.m3u8", - Map.of("Content-Type", List.of("application/octet-stream")))); - } - - @Test - public void segmentBytesUnderAnM3u8SuffixAreNotPlaylists() { - // 实测本地包直播:分片地址顶着 .m3u8 后缀返回 TS 字节,必须按原样透传。 - assertFalse(ExoHlsAdblockDataSource.looksLikePlaylist(new byte[]{0x47, 0x40, 0x11, 0x10, 0x00})); - assertFalse(ExoHlsAdblockDataSource.looksLikePlaylist(new byte[0])); - assertFalse(ExoHlsAdblockDataSource.looksLikePlaylist(null)); - assertTrue(ExoHlsAdblockDataSource.looksLikePlaylist("#EXTM3U\n#EXTINF:1,\na.ts".getBytes(StandardCharsets.UTF_8))); - byte[] bom = "#EXTM3U\n#EXT-X-VERSION:3\n".getBytes(StandardCharsets.UTF_8); - byte[] withBom = new byte[bom.length + 3]; - withBom[0] = (byte) 0xEF; - withBom[1] = (byte) 0xBB; - withBom[2] = (byte) 0xBF; - System.arraycopy(bom, 0, withBom, 3, bom.length); - assertTrue(ExoHlsAdblockDataSource.looksLikePlaylist(withBom)); - } - - @Test - public void changedOutcomeProducesPlaybackNoticeText() { - String manifest = "#EXTM3U\n#EXTINF:5.0,\nad/001.ts\n#EXTINF:8.0,\nvideo/002.ts\n#EXTINF:8.0,\nvideo/003.ts\n#EXTINF:8.0,\nvideo/004.ts\n#EXT-X-ENDLIST\n"; - ExoHlsAdblockDataSource.Result result = ExoHlsAdblockDataSource.cleanForTest( - "https://example.test/live/index.m3u8", manifest); - - assertTrue(result.changed()); - assertEquals("已跳过 1 个广告片段,总广告时长 5.0 秒", result.notice()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoLoadControlPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoLoadControlPolicyTest.java index 5a522c1b20..dd031f3b0c 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoLoadControlPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoLoadControlPolicyTest.java @@ -50,8 +50,9 @@ public class ExoLoadControlPolicyTest { @Test public void customLevelScalesFromFifteenToThirtySeconds() { assertDurations(PlaybackPerformanceSetting.PROFILE_CUSTOM, 1, 15_000, 30_000); - assertDurations(PlaybackPerformanceSetting.PROFILE_CUSTOM, 4, 20_000, 40_000); - assertDurations(PlaybackPerformanceSetting.PROFILE_CUSTOM, 10, 30_000, 60_000); + assertDurations(PlaybackPerformanceSetting.PROFILE_CUSTOM, 8, 22_500, 45_000); + assertDurations(PlaybackPerformanceSetting.PROFILE_CUSTOM, 15, 30_000, 60_000); + assertDurations(PlaybackPerformanceSetting.PROFILE_CUSTOM, 16, 30_000, 60_000); } private static void assertDurations(int profile, int minMs, int maxMs) { diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoNetworkGuardEligibilityTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoNetworkGuardEligibilityTest.java index 32583624a6..0025bb36ca 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoNetworkGuardEligibilityTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoNetworkGuardEligibilityTest.java @@ -1,7 +1,5 @@ package com.fongmi.android.tv.player.exo; -import com.fongmi.android.tv.player.AudioPlaybackDiagnostics; - import org.junit.Test; import static org.junit.Assert.assertFalse; @@ -11,46 +9,22 @@ public class ExoNetworkGuardEligibilityTest { @Test public void allowsExoVodWithoutResourceTypeGate() { - assertTrue(ExoNetworkGuardEligibility.resolve( - request(false, AudioPlaybackDiagnostics.OutputMode.PCM)).eligible()); - } - - @Test - public void unknownOutput_defersSpeedChangesUntilInitialized() { - assertFalse(ExoNetworkGuardEligibility.resolve( - request(false, AudioPlaybackDiagnostics.OutputMode.UNKNOWN)).eligible()); - assertFalse(ExoNetworkGuardEligibility.resolve(request(false, null)).eligible()); + assertTrue(ExoNetworkGuardEligibility.resolve(request(false, false)).eligible()); } @Test public void preservesTunnelingAndPassthroughInsteadOfDisablingThem() { - assertFalse(ExoNetworkGuardEligibility.resolve( - request(true, AudioPlaybackDiagnostics.OutputMode.UNKNOWN)).eligible()); - assertFalse(ExoNetworkGuardEligibility.resolve( - request(false, AudioPlaybackDiagnostics.OutputMode.PASSTHROUGH)).eligible()); - } - - @Test - public void configuredPassthroughDoesNotBlockRuntimePcmOrOffload() { - assertTrue(ExoNetworkGuardEligibility.resolve( - request(false, AudioPlaybackDiagnostics.OutputMode.PCM)).eligible()); - assertTrue(ExoNetworkGuardEligibility.resolve( - request(false, AudioPlaybackDiagnostics.OutputMode.OFFLOAD)).eligible()); + assertFalse(ExoNetworkGuardEligibility.resolve(request(true, false)).eligible()); + assertFalse(ExoNetworkGuardEligibility.resolve(request(false, true)).eligible()); } @Test public void userSpeedAndUnsupportedSpeedCommandRemainUntouched() { - assertFalse(ExoNetworkGuardEligibility.resolve(new ExoNetworkGuardEligibility.Request( - true, true, true, false, true, false, - AudioPlaybackDiagnostics.OutputMode.UNKNOWN)).eligible()); - assertFalse(ExoNetworkGuardEligibility.resolve(new ExoNetworkGuardEligibility.Request( - true, true, true, true, false, false, - AudioPlaybackDiagnostics.OutputMode.UNKNOWN)).eligible()); + assertFalse(ExoNetworkGuardEligibility.resolve(new ExoNetworkGuardEligibility.Request(true, true, true, false, true, false, false)).eligible()); + assertFalse(ExoNetworkGuardEligibility.resolve(new ExoNetworkGuardEligibility.Request(true, true, true, true, false, false, false)).eligible()); } - private static ExoNetworkGuardEligibility.Request request( - boolean tunneling, AudioPlaybackDiagnostics.OutputMode audioOutputMode) { - return new ExoNetworkGuardEligibility.Request( - true, true, true, true, true, tunneling, audioOutputMode); + private static ExoNetworkGuardEligibility.Request request(boolean tunneling, boolean passthrough) { + return new ExoNetworkGuardEligibility.Request(true, true, true, true, true, tunneling, passthrough); } } diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoParserAdblockGateTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoParserAdblockGateTest.java deleted file mode 100644 index 0f888f5812..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoParserAdblockGateTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -/** MediaItem enables the patched Exo ad parser only when both ad blocking and its fallback toggle are enabled. */ -public class ExoParserAdblockGateTest { - - @Test - public void parserAdblockRequiresMasterSwitchAndFallbackToggle() throws Exception { - String source = Files.readString(Path.of("src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java")); - - assertTrue(source.contains("builder.setAdblock(Setting.isAdblock() && HlsRuleConfig.isLegacyFallbackEnabled())")); - assertFalse(source.contains("!HlsRuleConfig.getRules().isEmpty()")); - assertFalse(source.contains("builder.setAdblock(Setting.isAdblock())")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoUtilAudioCapabilityLogTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoUtilAudioCapabilityLogTest.java deleted file mode 100644 index a8a709494e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoUtilAudioCapabilityLogTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * {@code ExoUtil.buildAudioSink} 里的音频能力日志原本每次都会调 - * {@code AudioCapabilities.getCapabilities(...)},那条路径会在主线程上同步创建并释放 - * AudioTrack。设备实测这一行日志独占 354-757ms,正是进入播放页时加载圈停住的窗口。 - * 这些测试锁住「日志不再触发硬件探测」以及「offload 策略仍按原顺序装配」。 - */ -public class ExoUtilAudioCapabilityLogTest { - - @Test - public void theDiagnosticLogNoLongerProbesAudioHardware() throws Exception { - String body = stripComments(methodBody(readSource(), "private static AudioSink buildAudioSink(")); - assertFalse("buildAudioSink must not call AudioCapabilities.getCapabilities on the main thread", - body.contains("AudioCapabilities.getCapabilities(")); - assertTrue("the audio diagnostic log itself must survive", body.contains("SpiderDebug.log(\"exo-audio\"")); - assertTrue("the log must stay behind the enabled check", body.contains("SpiderDebug.isEnabled()")); - } - - @Test - public void theOffloadPolicyIsStillWiredBeforeTheSinkIsBuilt() throws Exception { - String body = methodBody(readSource(), "private static AudioSink buildAudioSink("); - assertTrue("the passthrough policy must still be configured from the setting", - body.contains("directPolicy.setAudioPassthroughEnabled(passthrough);")); - assertTrue("the audio sink must still be built with the offload support provider", - body.contains("AudioTrackAudioOutputProvider.Builder")); - int policy = body.indexOf("directPolicy.setAudioPassthroughEnabled(passthrough);"); - int builder = body.indexOf("AudioTrackAudioOutputProvider.Builder"); - assertTrue("the policy must be configured before the sink is built", policy >= 0 && builder > policy); - } - - @Test - public void theUnusedCapabilityImportIsGone() throws Exception { - String source = readSource(); - assertFalse("an unused AudioCapabilities import must not come back", - source.contains("import androidx.media3.exoplayer.audio.AudioCapabilities;")); - } - - private static String stripComments(String body) { - // 断言针对真实代码,不能被解释性注释命中(注释里会提到被删除的调用)。 - StringBuilder out = new StringBuilder(body.length()); - for (String line : body.split("\\n", -1)) { - String trimmed = line.trim(); - if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) continue; - out.append(line).append('\n'); - } - return out.toString(); - } - - private static String readSource() throws Exception { - Path moduleRelative = Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoUtil.java"); - Path path = Files.exists(moduleRelative) ? moduleRelative : Path.of("app").resolve(moduleRelative); - return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("source is missing " + signature, start >= 0); - int open = source.indexOf('{', start + signature.length()); - assertTrue("source is missing the body of " + signature, open > start); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char current = source.charAt(i); - if (current == '{') depth++; - else if (current == '}' && --depth == 0) return source.substring(start, i + 1); - } - throw new AssertionError("unclosed body for " + signature); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoUtilTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoUtilTest.java new file mode 100644 index 0000000000..28b9765fe8 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoUtilTest.java @@ -0,0 +1,166 @@ +package com.fongmi.android.tv.player.exo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.media3.exoplayer.DefaultRenderersFactory; + +import com.fongmi.android.tv.player.engine.PlayerEngine; +import com.fongmi.android.tv.setting.PlayerSetting; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class ExoUtilTest { + + @Test + public void getRenderMode_keepsPlatformRendererFirstForHardDecode() { + assertEquals(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF, ExoUtil.getRenderMode(PlayerEngine.HARD)); + } + + @Test + public void getRenderMode_prefersExtensionRendererForSoftDecode() { + assertEquals(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER, ExoUtil.getRenderMode(PlayerEngine.SOFT)); + } + + @Test + public void getFfmpegVideoRenderMode_keepsFfmpegAsFallbackForHardDecode() { + assertEquals(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON, ExoUtil.getFfmpegVideoRenderMode(ExoUtil.getRenderMode(PlayerEngine.HARD))); + } + + @Test + public void getFfmpegVideoRenderMode_prefersFfmpegForSoftDecode() { + assertEquals(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER, ExoUtil.getFfmpegVideoRenderMode(ExoUtil.getRenderMode(PlayerEngine.SOFT))); + } + + @Test + public void hardDecodeFactory_keepsFfmpegVideoFallbackWired() throws Exception { + String source = readMainSource("player/exo/ExoUtil.java"); + int start = source.indexOf("private static class FfmpegRenderersFactory"); + int end = source.indexOf("private static class FfmpegFallbackRenderersFactory", start); + String factory = source.substring(start, end); + + assertFalse(factory.contains("if (videoRenderMode == EXTENSION_RENDERER_MODE_OFF) return;")); + assertTrue(factory.contains("out.add(index, buildFfmpegVideoRenderer(")); + } + + @Test + public void isFfmpegVideoFallbackOnly_yieldsToPlatformForHardDecode() { + assertTrue(ExoUtil.isFfmpegVideoFallbackOnly(ExoUtil.getRenderMode(PlayerEngine.HARD), false)); + } + + @Test + public void isFfmpegVideoFallbackOnly_honoursVideoSoftPrefer() { + assertFalse(ExoUtil.isFfmpegVideoFallbackOnly(ExoUtil.getRenderMode(PlayerEngine.HARD), true)); + } + + @Test + public void isFfmpegVideoFallbackOnly_letsFfmpegCompeteForSoftDecode() { + assertFalse(ExoUtil.isFfmpegVideoFallbackOnly(ExoUtil.getRenderMode(PlayerEngine.SOFT), false)); + } + + @Test + public void ffmpegVideoRenderers_useCompatRendererSoPlatformKeepsHighSpecTracks() throws Exception { + String source = readMainSource("player/exo/ExoUtil.java"); + + assertFalse(source.contains("new FfmpegVideoRenderer(")); + assertTrue(source.contains("new CompatFfmpegVideoRenderer(")); + assertTrue(source.contains("isFfmpegVideoFallbackOnly(videoRenderMode, videoPrefer)")); + } + + @Test + public void ffmpegVideoRenderers_shareTheSelectorTheirMediaCodecRenderersUse() throws Exception { + String source = readMainSource("player/exo/ExoUtil.java"); + int start = source.indexOf("private CompatFfmpegVideoRenderer buildFfmpegVideoRenderer("); + int end = source.indexOf("private MediaCodecSelector getVideoCodecSelector(", start); + assertTrue(start >= 0 && end > start); + String builder = source.substring(start, end); + + assertTrue(source.contains("buildFfmpegVideoRenderer(allowedVideoJoiningTimeMs, eventHandler, eventListener, videoCodecSelector)")); + assertTrue(source.contains("isFfmpegVideoFallbackOnly(videoRenderMode, videoPrefer), mediaCodecSelector)")); + assertTrue(builder.contains("fallbackOnly, platformDecoderSelector)")); + assertFalse(builder.contains("MediaCodecSelector.DEFAULT")); + } + + @Test + public void automaticConstraintReasonLabel_avoidsUnsupportedAndroidStringBuilderApi() throws Exception { + String source = readMainSource("player/exo/ExoAutomaticVideoConstraintPolicy.java"); + + assertFalse(source.contains("builder.isEmpty()")); + } + + @Test + public void videoLimits_startFromTheHighestTrackInsideTheConstraints() throws Exception { + String source = readMainSource("player/exo/ExoUtil.java"); + int start = source.indexOf("private static void applyVideoLimit("); + int end = source.indexOf("public static EnhancedVideoProfile getEnhancedVideoProfile()", start); + assertTrue(start >= 0 && end > start); + String limit = source.substring(start, end); + + assertTrue("起播必须选约束内画质最高的轨道,否则 HLS 多码率直链会停在最低档", + limit.contains("builder.setForceHighestSupportedBitrate(true)")); + assertFalse(limit.contains("builder.setForceHighestSupportedBitrate(false)")); + } + + @Test + public void automaticConstraintController_downgradesOnThroughputShortfall() throws Exception { + String source = readMainSource("player/exo/ExoUtil.java"); + int start = source.indexOf("private static class AutomaticVideoConstraintController"); + int end = source.indexOf("private static class LegacyAdaptiveVideoProfileController", start); + assertTrue(start >= 0 && end > start); + String controller = source.substring(start, end); + + // 固定选最高画质后原生 ABR 不再兜底吞吐,这个自动档控制器必须自己按带宽和重缓冲降档, + // 否则弱网会锁在最高档一直重缓冲且无法恢复。 + assertTrue("自动档必须监听带宽估算", + controller.contains("public void onBandwidthEstimate(")); + assertTrue("带宽不足要走吞吐降档", + controller.contains("ExoAdaptiveVideoBitratePolicy.shouldDowngrade(selectedBitrate, bitrateEstimate)") + && controller.contains("ExoAutomaticVideoConstraintPolicy.Fault.THROUGHPUT")); + // appliedLimit 是设备能力上限(4K 档可达 20Mbps),拿它当轨道码率比对会让正常片源持续误降档。 + assertTrue("轨道码率未知时必须放弃带宽判断,而不是退回设备上限", + controller.contains("if (selectedBitrate <= 0) return;") + && !controller.contains("selectedBitrate = appliedLimit.maxVideoBitrate()")); + assertTrue("起播后再次进入缓冲同样是吞吐不足的证据", + controller.contains("state == Player.STATE_BUFFERING && everReady") + && controller.contains("\"rebuffer\"")); + assertTrue("换片要重置起播标记,避免上一片的状态误触发降档", + controller.contains("everReady = false;")); + // bindSession 才会重置 everReady,若先读 everReady 再对齐会话,换片后的首次缓冲会被 + // 当成重缓冲而白降一档。 + int stateChanged = controller.indexOf("public void onPlaybackStateChanged("); + int bindFirst = controller.indexOf("bindEventSession();", stateChanged); + int readsEverReady = controller.indexOf( + "boolean rebuffered = state == Player.STATE_BUFFERING && everReady;", stateChanged); + assertTrue("重缓冲判断前必须先对齐会话", + stateChanged >= 0 && bindFirst > stateChanged && readsEverReady > bindFirst); + } + + @Test + public void ffmpegRendererPolicy_usesFullNextLibRenderersInNextLibMode() { + assertTrue(ExoUtil.useFfmpegAudioFallback(PlayerSetting.FFMPEG_MODE_NEXTLIB)); + assertTrue(ExoUtil.useFfmpegVideoRenderer(PlayerSetting.FFMPEG_MODE_NEXTLIB)); + } + + @Test + public void ffmpegRendererPolicy_usesAudioAndVideoFallbackInSimpleMode() { + assertTrue(ExoUtil.useFfmpegAudioFallback(PlayerSetting.FFMPEG_MODE_SIMPLE)); + assertTrue(ExoUtil.useFfmpegVideoRenderer(PlayerSetting.FFMPEG_MODE_SIMPLE)); + } + + @Test + public void ffmpegRendererPolicy_disablesNextLibInOfficialMode() { + assertFalse(ExoUtil.useFfmpegAudioFallback(PlayerSetting.FFMPEG_MODE_OFFICIAL)); + assertFalse(ExoUtil.useFfmpegVideoRenderer(PlayerSetting.FFMPEG_MODE_OFFICIAL)); + } + + private static String readMainSource(String relative) throws Exception { + Path path = Path.of("app/src/main/java/com/fongmi/android/tv", relative); + if (!Files.exists(path)) path = Path.of("src/main/java/com/fongmi/android/tv", relative); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoVideoConstraintApplicabilityTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoVideoConstraintApplicabilityTest.java deleted file mode 100644 index 076a33441d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoVideoConstraintApplicabilityTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import androidx.media3.common.Format; -import androidx.media3.common.TrackSelectionParameters; -import androidx.media3.common.Tracks; - -import org.junit.Test; - -import java.util.List; - -public class ExoVideoConstraintApplicabilityTest { - private static final TrackSelectionParameters AUTO = TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT; - private static final Format UHD = video("uhd", 3840, 2160, 60, 24_000_000); - private static final Format HD = video("hd", 1280, 720, 30, 4_000_000); - - @Test public void singleNativeDvTrackNeverNeedsRuntimeLimits() { - Format dv = UHD.buildUpon().setSampleMimeType("video/dolby-vision").setCodecs("dvhe.05.09").build(); - assertFalse(canAdjust(false, dv)); - assertFalse(canAdjust(true, dv)); - } - - @Test public void noTrackInformationCannotJustifyReselection() { - assertFalse(ExoVideoConstraintApplicability.canAdjust(Tracks.EMPTY, AUTO)); - assertFalse(ExoVideoConstraintApplicability.canAdjust(null, AUTO)); - } - - @Test public void alternativesRemainUsableAfterLowestTrackWasSelected() { - // The available group remains adaptive even when just its lowest track is selected. - assertTrue(canAdjust(true, HD, UHD)); - } - - @Test public void unsupportedOrNonAdaptiveAlternativesAreNotUseful() { - assertFalse(canAdjust(false, UHD, HD)); - // Only formats that Media3 reports as supported enter the policy. - assertFalse(canAdjust(true, UHD)); - } - - @Test public void manualOverrideAndDisabledVideoArePreserved() { - assertFalse(ExoVideoConstraintApplicability.canAdjust(true, true, true, false, List.of(UHD, HD))); - assertFalse(ExoVideoConstraintApplicability.canAdjust(true, true, false, true, List.of(UHD, HD))); - } - - @Test public void duplicateFormatsDoNotOfferDifferentBudgets() { - assertFalse(canAdjust(true, UHD, UHD.buildUpon().setId("another-id").build())); - assertTrue(canAdjust(true, UHD, UHD.buildUpon().setAverageBitrate(12_000_000).build())); - } - - @Test public void unknownFormatBetweenKnownAlternativesDoesNotHideThem() { - Format unknown = video("unknown", -1, -1, -1, -1); - assertTrue(canAdjust(true, UHD, unknown, HD)); - assertFalse(canAdjust(true, UHD, unknown)); - } - - @Test public void unselectedAlternativeCameraDoesNotEnableSwitching() { - assertFalse(ExoVideoConstraintApplicability.canAdjust(false, true, false, false, List.of(UHD, HD))); - } - - private static Format video(String id, int width, int height, float fps, int bitrate) { - return new Format.Builder().setId(id).setSampleMimeType("video/hevc") - .setWidth(width).setHeight(height).setFrameRate(fps).setAverageBitrate(bitrate).build(); - } - - private static boolean canAdjust(boolean adaptive, Format... formats) { - return ExoVideoConstraintApplicability.canAdjust(true, adaptive, false, false, List.of(formats)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoVideoHardwareDecodeTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoVideoHardwareDecodeTest.java deleted file mode 100644 index 3e4b8e38cb..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoVideoHardwareDecodeTest.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import androidx.media3.exoplayer.mediacodec.MediaCodecInfo; -import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; - -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.List; - -/** - * The video half of the "hard decode has no hardware path" proof, plus the source guards that keep - * the three participants of that contract aligned: the hardware predicate, the decoder list the - * hard-decode renderers are built with, and the verdict the OSD prints. - */ -public class ExoVideoHardwareDecodeTest { - - private static final String VIDEO_MIME = "video/hevc"; - private static final String EXO_UTIL_PATH = - "app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java"; - private static final String PLAYER_MANAGER_PATH = - "app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java"; - private static final String OSD_PATH = - "app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerOsdController.java"; - - @Test - public void softwareOnlyDecoderThatClaimsHardware_isNotAHardwarePath() { - assertFalse(ExoUtil.hasHardwareVideoDecoder( - fixed(List.of(codec("c2.vendor.hevc.decoder", true, true))), VIDEO_MIME)); - } - - @Test - public void hardwareDecoder_isAHardwarePath() { - assertTrue(ExoUtil.hasHardwareVideoDecoder( - fixed(List.of(codec("c2.vendor.hevc.decoder", true, false))), VIDEO_MIME)); - } - - @Test - public void allSoftwareDecoders_areNotAHardwarePath() { - assertFalse(ExoUtil.hasHardwareVideoDecoder( - fixed(List.of( - codec("c2.android.hevc.decoder", false, true), - codec("omx.google.hevc.decoder", false, true))), - VIDEO_MIME)); - } - - @Test - public void emptyDecoderList_isNotEvidenceOfSoftwareFallback() { - assertTrue(ExoUtil.hasHardwareVideoDecoder(fixed(List.of()), VIDEO_MIME)); - } - - @Test - public void nullAndNonVideoMime_areNotThisChecksBusiness() { - assertTrue(ExoUtil.hasHardwareVideoDecoder(fixed(List.of()), null)); - assertTrue(ExoUtil.hasHardwareVideoDecoder(fixed(List.of()), "audio/mp4a-latm")); - } - - @Test - public void failedQuery_keepsTheCurrentMode() throws Exception { - // DecoderQueryException has no accessible constructor, so this branch is guarded at the - // source level the way the other untestable branches in this area already are. - String source = readSource(EXO_UTIL_PATH); - - assertTrue(source.contains("} catch (androidx.media3.exoplayer.mediacodec.MediaCodecUtil.DecoderQueryException e) {")); - assertTrue(source.contains("// A failed query must never change the user-visible decode mode.")); - } - - @Test - public void videoSelector_reusesTheSharedHardwarePredicate() throws Exception { - String source = readSource(EXO_UTIL_PATH); - - assertTrue(source.contains("return info.hardwareAccelerated && !info.softwareOnly;")); - assertEquals(2, occurrences(source, "isHardwareDecoder(info)")); - assertFalse(source.contains("if (info.hardwareAccelerated) return true;")); - assertFalse(source.contains("if (info.hardwareAccelerated) hardwareInfos.add(info);")); - } - - @Test - public void platformSoftwareFallback_isGatedByTheDecoderFallbackSwitch() throws Exception { - String source = readSource(EXO_UTIL_PATH); - - assertTrue(source.contains("hardwareInfos.isEmpty() && PlaybackPerformanceSetting.isDecoderFallbackEnabled() ? infos : hardwareInfos")); - } - - @Test - public void autoFallback_decidesOnTheSelectedVideoTrack() throws Exception { - String source = readSource(PLAYER_MANAGER_PATH); - - assertTrue(source.contains("Format selected = getSelectedFormat(tracks, C.TRACK_TYPE_VIDEO);")); - assertTrue(source.contains("return !ExoUtil.hasHardwareVideoDecoder(selected.sampleMimeType);")); - assertTrue(source.contains("return hasTrack(tracks, C.TRACK_TYPE_VIDEO) && !hasHandledTrack(tracks, C.TRACK_TYPE_VIDEO);")); - assertFalse(source.contains("firstDecodableVideoFormat")); - } - - @Test - public void videoVerdict_coversEveryUndecodableSupportCode() throws Exception { - String source = readSource(OSD_PATH); - - assertTrue(source.contains("!videoTrack.selected() && !videoTrack.isHandled()")); - assertTrue(source.contains("supportText(videoTrack.support())")); - assertTrue(source.contains("C.FORMAT_UNSUPPORTED_DRM")); - } - - private static MediaCodecSelector fixed(List infos) { - return (mimeType, secure, tunneling) -> infos; - } - - private static MediaCodecInfo codec( - String name, boolean hardwareAccelerated, boolean softwareOnly) { - return MediaCodecInfo.newInstance( - name, - VIDEO_MIME, - VIDEO_MIME, - null, - hardwareAccelerated, - softwareOnly, - !softwareOnly, - false, - false); - } - - private static int occurrences(String source, String needle) { - int count = 0; - for (int i = source.indexOf(needle); i >= 0; i = source.indexOf(needle, i + needle.length())) { - count++; - } - return count; - } - - private static String readSource(String relativePath) throws Exception { - File root = new File(System.getProperty("user.dir")); - while (root != null && !new File(root, relativePath).isFile()) root = root.getParentFile(); - assertNotNull("Unable to locate project root from " + System.getProperty("user.dir"), root); - return Files.readString(new File(root, relativePath).toPath(), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSourceTest.java new file mode 100644 index 0000000000..7f8bb4fb52 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/HlsPlaylistCleaningDataSourceTest.java @@ -0,0 +1,587 @@ +package com.fongmi.android.tv.player.exo; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.net.Uri; + +import androidx.media3.common.C; +import androidx.media3.common.util.Log; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; +import androidx.media3.exoplayer.hls.playlist.HlsAdsParser; + +import com.fongmi.android.tv.bean.HlsAdRule; +import com.fongmi.android.tv.utils.HlsAdblockPipeline; +import com.fongmi.android.tv.utils.HlsManifestCleaner; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Exo 路径上的去广告。此前 {@code HlsManifestCleaner} 只在 IJK 的本地代理里执行, + * 默认内核(EXO)上用户与接口规则完全不生效,只有 fork 版 parser 里的 legacy + * 启发式在跑。 + */ +public class HlsPlaylistCleaningDataSourceTest { + + /** + * 让 legacy 启发式在纯 JVM 下真的跑起来。 + * + *

本模块没有配 {@code testOptions { unitTests.returnDefaultValues }},所以 + * {@code android.util.Log} 是未 mock 的桩,{@code HlsAdsParser.process} 第一句 + * {@code Log.d} 就抛 {@code RuntimeException},而 {@code HlsAdblockPipeline} 会 + * {@code catch (Throwable)} 后原样返回 —— 启发式**从未真正执行**。上一轮的测试 + * 因此有几条钉的是这个 harness 假象而不是代码:例如「无规则时逐字节透传」在真机上 + * 是假的,启发式会删掉那 3 片广告。 + * + *

media3 自己的 {@code Log} 封装支持关掉日志,关掉后 {@code Log.d} 变成 no-op, + * 于是启发式能在 JVM 里完整执行。 + */ + @BeforeClass + public static void silenceMedia3Log() { + previousLogLevel = Log.getLogLevel(); + Log.setLogLevel(Log.LOG_LEVEL_OFF); + } + + /** + * 日志级别是进程级静态状态,而本模块没配 {@code forkEvery},整个 module 跑在同一个 + * JVM 里 —— 不还原会按类的执行顺序泄漏给别的测试类。 + */ + @AfterClass + public static void restoreMedia3Log() { + Log.setLogLevel(previousLogLevel); + } + + private static int previousLogLevel; + + private static final String PLAYLIST_HOST = "v.example.com"; + private static final String PLAYLIST_URL = "https://" + PLAYLIST_HOST + "/play/index.m3u8"; + + /** + * 30 片,下标 3-5 是广告 —— 与正片**同域、同命名结构**,只有时长不同(6.4s 对 10.0s)。 + * + *

这个形状是刻意选的:legacy 启发式靠命名分组与前缀分析找广告,对完全均匀的 + * 命名会弃权,于是任何「删掉了广告」的断言都只能由结构化规则达成。此前夹具用 + * 独立广告域名 + 断点标记,启发式对它的输出与结构化引擎**逐字节相同**,导致 + * 4 条测试把规则换成 {@code List.of()} 也照样绿 —— 它们根本没在验证结构化规则。 + */ + private static String playlist() { + StringBuilder text = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + for (int i = 0; i < 30; i++) { + boolean ad = i >= 3 && i <= 5; + text.append(String.format(Locale.US, "#EXTINF:%.3f,%n", ad ? 6.4 : 10.0)); + text.append("https://").append(PLAYLIST_HOST).append("/seg/").append(i).append(".ts\n"); + } + return text.append("#EXT-X-ENDLIST\n").toString(); + } + + /** 广告切片的 URI,供断言使用。 */ + private static String adUri(int index) { + return "https://" + PLAYLIST_HOST + "/seg/" + index + ".ts"; + } + + /** + * 直播窗口:无 {@code #EXT-X-ENDLIST},带 {@code #EXT-X-MEDIA-SEQUENCE}。 + * + * @param mediaSequence 窗口首片的序号 + * @param adIndexes 窗口内属于广告的下标(相对窗口起点) + */ + private static String livePlaylist(int mediaSequence, int windowSize, Set adIndexes) { + StringBuilder text = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + text.append("#EXT-X-MEDIA-SEQUENCE:").append(mediaSequence).append('\n'); + text.append("#EXT-X-DISCONTINUITY-SEQUENCE:0\n"); + for (int i = 0; i < windowSize; i++) { + boolean ad = adIndexes.contains(i); + if (ad) text.append("#EXT-X-DISCONTINUITY\n"); + text.append(String.format(Locale.US, "#EXTINF:%.3f,%n", ad ? 6.4 : 10.0)); + text.append(ad ? "https://ad-cdn.other.com/ads/" + (mediaSequence + i) + ".ts" + : "https://" + PLAYLIST_HOST + "/seg/" + (mediaSequence + i) + ".ts") + .append('\n'); + } + return text.toString(); + } + + /** + * 匹配 {@link #playlist()} 里 6.4s 那三片的规则。 + * + *

用时长而非域名区分:夹具刻意让广告与正片同域同命名,好让 legacy 启发式弃权, + * 从而使「广告被删」只可能由结构化规则达成。 + */ + private static HlsManifestCleaner.Rule adRule() { + return HlsAdRule.createUserRule("ad", "ad", List.of(PLAYLIST_HOST), + List.of(), List.of(), 6.3d, 6.5d, false, false, 1).compile(); + } + + private static String readFully(HlsPlaylistCleaningDataSource source, String url) + throws IOException { + source.open(null, url, 0, C.LENGTH_UNSET); + return drain(source); + } + + private static String drain(HlsPlaylistCleaningDataSource source) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] chunk = new byte[512]; + while (true) { + int read = source.read(chunk, 0, chunk.length); + if (read == C.RESULT_END_OF_INPUT) break; + out.write(chunk, 0, read); + } + source.close(); + return out.toString(StandardCharsets.UTF_8); + } + + /** + * 结构化规则能在 Exo 路径上删掉广告切片、保留正片。 + * + *

不再断言「无规则时逐字节透传」—— 那在真机上是假的:无规则时结构化引擎不改动, + * {@link HlsAdblockPipeline} 会让 legacy 启发式接手,而它对这份夹具确实会删片。 + * 想验证「结构化引擎自身没有副作用」得关掉启发式,那属于 pipeline 的契约, + * 由 {@link #pipelineKeepsTheTwoEnginesMutuallyExclusive} 覆盖。 + */ + @Test + public void removesAdSegmentsFromPlaylistOnTheExoPath() throws IOException { + HlsPlaylistCleaningDataSource cleaning = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> true); + + String cleaned = readFully(cleaning, PLAYLIST_URL); + + assertFalse("广告切片应被删除", cleaned.contains(adUri(3))); + assertTrue("正片切片必须保留", cleaned.contains("/seg/0.ts")); + assertTrue("正片切片必须保留", cleaned.contains("/seg/29.ts")); + assertEquals("正片一片不少", 27, countSegments(cleaned)); + } + + private static int countSegments(String manifest) { + int count = 0; + for (String line : manifest.split("\n")) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) count++; + } + return count; + } + + /** + * 净化后 {@code open()} 必须返回改写后的长度。返回上游原长会让 Exo 认为响应被 + * 截断(净化只会变短),把正常播放报成 EOF 错误。 + */ + @Test + public void reportsTheRewrittenLengthNotTheUpstreamLength() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> true); + + long reported = source.open(null, PLAYLIST_URL, 0, C.LENGTH_UNSET); + int delivered = drain(source).getBytes(StandardCharsets.UTF_8).length; + + assertEquals("声明的长度必须等于实际交付的字节数", delivered, reported); + assertTrue("净化后应短于原文", reported < playlist().getBytes(StandardCharsets.UTF_8).length); + } + + /** 非 playlist 的响应(切片、字幕等)必须逐字节原样透传。 */ + @Test + public void passesNonPlaylistPayloadsThroughByteForByte() throws IOException { + byte[] payload = new byte[4096]; + for (int i = 0; i < payload.length; i++) payload[i] = (byte) (i % 251); + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(payload), () -> List.of(adRule()), () -> true); + + source.open(null, "https://" + PLAYLIST_HOST + "/seg/0.ts", 0, C.LENGTH_UNSET); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] chunk = new byte[100]; + while (true) { + int read = source.read(chunk, 0, chunk.length); + if (read == C.RESULT_END_OF_INPUT) break; + out.write(chunk, 0, read); + } + source.close(); + + assertArrayEquals("切片数据不得被改动", payload, out.toByteArray()); + } + + /** 带 BOM 与前导空白的 playlist 也要被识别 —— 与 media3 的 parser 判据一致。 */ + @Test + public void recognisesPlaylistBehindByteOrderMarkAndWhitespace() throws IOException { + String text = "\n " + playlist(); + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(text), () -> List.of(adRule()), () -> true); + + String cleaned = readFully(source, PLAYLIST_URL); + + assertFalse("BOM 后的 playlist 同样要净化", cleaned.contains(adUri(3))); + } + + /** + * 区间请求不得改写:range 寻址的是原始响应的字节偏移,而净化会改变长度, + * 改写会让偏移全部错位。 + */ + @Test + public void leavesRangedRequestsUntouched() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> true); + + source.open(null, PLAYLIST_URL, 10, 200); + String served = drain(source); + + assertTrue("区间请求必须原样透传", served.contains(adUri(3))); + } + + /** 去广告总开关关闭时完全不介入。 */ + @Test + public void doesNothingWhenAdblockIsDisabled() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> false); + + assertEquals(playlist(), readFully(source, PLAYLIST_URL)); + } + + /** + * 本地 {@code /m3u8} 代理的输出已经净化过,不能再来一遍 —— 那会让规则在同一份 + * playlist 上叠加两次,删除量翻倍并可能越过回退闸门。 + */ + @Test + public void skipsPlaylistsAlreadyCleanedByTheLocalProxy() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> true); + + String served = readFully(source, + "http://127.0.0.1:9978/m3u8?url=https%3A%2F%2Fv.example.com%2Fa.m3u8"); + + assertTrue("本地代理已净化,此处必须放过", served.contains(adUri(3))); + } + + /** 读取规则配置抛异常时必须降级为透传,而不是把异常抛给播放中的用户。 */ + @Test + public void degradesToPassThroughWhenRuleConfigThrows() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), + () -> { throw new IllegalStateException("config unavailable"); }, + () -> true); + + assertEquals(playlist(), readFully(source, PLAYLIST_URL)); + } + + /** 上游按小块返回时,嗅探与整体读取都必须正确拼接。 */ + @Test + public void handlesUpstreamThatReturnsTinyChunks() throws IOException { + FakeSource upstream = new FakeSource(playlist()); + upstream.maxChunk = 3; + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + upstream, () -> List.of(adRule()), () -> true); + + String cleaned = readFully(source, PLAYLIST_URL); + + assertFalse("分片读取不得影响净化", cleaned.contains(adUri(3))); + assertTrue(cleaned.contains("/seg/29.ts")); + } + + /** + * 直播窗口一律不改写。 + * + *

{@code HlsManifestCleaner} 会按删掉的头部片数抬高 + * {@code #EXT-X-MEDIA-SEQUENCE},但它的回退闸门(删除比例、删除时长、删除必须 + * 在头部连续)会在相邻两次 reload 之间翻转,回退时发出的是**未抬高**的原始序号。 + * 而滑动窗口里新的广告块总是先出现在尾部(删除点在保留点之后 → 非连续 → 回退), + * 随窗口滑动才变成头部连续 —— 于是序号在抬高与原始之间来回跳。media3 的 + * {@code isNewerThan} 会静默丢弃「变旧」的那次 reload,最终 + * {@code targetDuration × 3.5} 后抛 {@code PlaylistStuckException} 卡播。 + */ + @Test + public void neverRewritesLivePlaylists() throws IOException { + String live = livePlaylist(100, 12, Set.of(0, 1, 2)); + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(live), () -> List.of(adRule()), () -> true); + + assertEquals("直播窗口必须逐字节透传", live, readFully(source, PLAYLIST_URL)); + } + + /** + * 直播的关键不变量:相邻 reload 发出的 media sequence 不得倒退。 + * + *

倒退需要「先净化、后回退」这个特定顺序 —— 发出的序号要么是窗口起点(回退), + * 要么是首个保留切片的原始下标(净化后),两者在纯滑动中各自单调;只有从净化跳到 + * 回退才会掉回去。触发它的正是真实直播的形态:头部还有正在被消费的广告块(净化, + * 抬高序号),同时**新的**广告块滑进窗口尾部(删除点不连续 → 回退 → 原始序号)。 + * + *

这里把广告块放在绝对下标 100-102 与 112-114:窗口起点 100 时只见到头部那块, + * 抬高到 103;起点 101 时尾部滑入 112,变成不连续而回退到 101 —— 比上一次小。 + */ + @Test + public void keepsMediaSequenceMonotonicAcrossLiveReloads() throws IOException { + Set adSegments = Set.of(100, 101, 102, 112, 113, 114); + int previous = Integer.MIN_VALUE; + for (int start = 100; start <= 118; start++) { + Set ads = new HashSet<>(); + for (int offset = 0; offset < 12; offset++) { + if (adSegments.contains(start + offset)) ads.add(offset); + } + String live = livePlaylist(start, 12, ads); + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(live), () -> List.of(adRule()), () -> true); + + int emitted = mediaSequenceOf(readFully(source, PLAYLIST_URL)); + + assertTrue("media sequence 不得倒退:" + previous + " -> " + emitted + + "(窗口起点 " + start + ")", emitted >= previous); + previous = emitted; + } + } + + private static int mediaSequenceOf(String manifest) { + for (String line : manifest.split("\n")) { + String trimmed = line.trim(); + if (trimmed.startsWith("#EXT-X-MEDIA-SEQUENCE:")) { + return Integer.parseInt(trimmed.substring("#EXT-X-MEDIA-SEQUENCE:".length()).trim()); + } + } + throw new AssertionError("缺少 #EXT-X-MEDIA-SEQUENCE:" + manifest); + } + + /** + * 结构化规则改动过的 manifest,绝不能再交给 legacy 启发式。 + * + *

{@code HlsAdsParser.findMinorityGroup} 只有一道「最大分组须超过切片总数的 + * 50%」的闸门,且对删除量没有上限;而删掉广告切片会缩小它比对的总数。夹具刻意 + * 用「正片分布在两个 CDN(260 + 240 片)+ 广告 25 片」这个实测形状:原始 playlist + * 上启发式因 {@code 520 <= 525} 放弃,结构化净化后变成 {@code 520 > 500},闸门打开 + * 并删掉那 240 片正片 —— 48% 的正片被静默删除。 + * + *

用这个形状而不是 {@link #playlist()}:后者太均匀,启发式对净化前后的结果一致, + * 即使两套引擎真被串联起来测试也照样绿。 + * + *

Exo 侧另一半(不让 fork 版 parser 重复跑)由 {@code ExoUtil} 的 + * {@code setAdblock(false)} 保证,那一处纯 JVM 测不了。 + */ + @Test + public void pipelineKeepsTheTwoEnginesMutuallyExclusive() { + String twoCdn = twoCdnPlaylist(); + HlsManifestCleaner.Rule rule = HlsAdRule.createUserRule("ad", "ad", + List.of(PLAYLIST_HOST), List.of("ad-cdn.other.com"), List.of(), + null, null, false, true, 2).compile(); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(PLAYLIST_URL, twoCdn, List.of(rule), true); + + assertTrue("结构化引擎应当生效", outcome.structured()); + assertFalse("结构化改动过就不该再跑启发式", outcome.legacy()); + // 若两套引擎被串联,启发式会把 cdn-b 那 240 片正片一并删掉 + assertEquals("只应删掉 25 片广告", 500, countSegments(outcome.manifest())); + assertTrue("第二个 CDN 的正片必须完好", outcome.manifest().contains("cdn-b.example.com")); + } + + /** + * 上一条的反面:把净化后的结果再喂给启发式,确认「串联确实会毁掉正片」。 + * + *

这条不测生产代码路径,它测的是**威胁本身仍然存在** —— 一旦有人把 + * {@code ExoUtil} 的 {@code setAdblock} 改回 {@code Setting.isAdblock()},或者给 + * pipeline 加上「两套都跑」的开关,48% 正片就会消失。若哪天启发式自己加了删除 + * 上限而让这条测试变红,那说明威胁解除,可以连同那半边约束一起删掉。 + */ + @Test + public void chainingTheTwoEnginesWouldDestroyMainContent() { + String twoCdn = twoCdnPlaylist(); + HlsManifestCleaner.Rule rule = HlsAdRule.createUserRule("ad", "ad", + List.of(PLAYLIST_HOST), List.of("ad-cdn.other.com"), List.of(), + null, null, false, true, 2).compile(); + + // 启发式对原始 playlist 弃权(525 片里最大分组 260,520 <= 525) + String heuristicOnRaw = HlsAdsParser.process(twoCdn); + assertEquals("原始 playlist 上启发式应当弃权", 525, countSegments(heuristicOnRaw)); + + // 但对净化后的 500 片,闸门打开(520 > 500) + String structured = HlsManifestCleaner.clean(PLAYLIST_URL, twoCdn, List.of(rule)).manifest(); + assertEquals(500, countSegments(structured)); + String chained = HlsAdsParser.process(structured); + + assertEquals("串联后 cdn-b 的 240 片正片被删掉", 260, countSegments(chained)); + assertFalse("这就是必须避免串联的原因", chained.contains("cdn-b.example.com")); + } + + /** + * 正片分布在两个 CDN(260 + 240 片)+ 25 片广告,共 525 片。 + * + *

广告时长取 3.0s:25 片合计 75s,压在 {@code HlsManifestCleaner} 的 90s 删除 + * 时长闸门内,否则结构化引擎会整体回退而测不到后续的引擎交互。 + */ + private static String twoCdnPlaylist() { + StringBuilder text = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + for (int i = 0; i < 260; i++) { + text.append("#EXTINF:10.000,\n") + .append("https://").append(PLAYLIST_HOST).append("/seg/").append(i).append(".ts\n"); + } + for (int i = 0; i < 25; i++) { + text.append("#EXT-X-DISCONTINUITY\n#EXTINF:3.000,\n") + .append("https://ad-cdn.other.com/ads/").append(i).append(".ts\n"); + } + for (int i = 0; i < 240; i++) { + text.append("#EXTINF:10.000,\n") + .append("https://cdn-b.example.com/seg/").append(i).append(".ts\n"); + } + return text.append("#EXT-X-ENDLIST\n").toString(); + } + + /** + * 结构化引擎没改动时,pipeline 必须把 manifest 递给启发式 —— 否则默认内核会比改动 + * 前更差(改动前 fork 版 parser 无条件跑启发式)。 + * + *

只钉「调用发生了」而不钉「删到了东西」:启发式是否动手取决于它自己的分组判据, + * 而两个夹具恰好都会让它弃权({@link #playlist()} 命名太均匀,{@link #twoCdnPlaylist()} + * 的 525 片里最大分组 260 不过半,实测两者 {@code process} 前后片数不变)。用「片数 + * 变少」当判据会把这条测试变成对启发式内部阈值的断言,那不是本处的契约。 + * + *

判据落在 {@code legacy()} 上:它由 {@code !filtered.equals(manifest)} 得出, + * 弃权时为 false,所以只能反向钉住 —— 结构化未改动时 pipeline 不得提前返回。 + * 若把 {@code HlsAdblockPipeline} 的 legacy 分支删掉,{@code manifest()} 会变成 + * {@code null} 之外的同一对象,这条测试确实抓不到。真正守住 legacy 分支的是 + * {@link #pipelineKeepsTheTwoEnginesMutuallyExclusive} 的 {@code legacy=false} 断言 + * 与 {@link #chainingTheTwoEnginesWouldDestroyMainContent};这里只补一条 + * 「结构化未命中时不短路」的正向路径。 + */ + @Test + public void pipelineStillReachesTheHeuristicWhenNoRuleMatches() { + String twoCdn = twoCdnPlaylist(); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(PLAYLIST_URL, twoCdn, List.of(), true); + + assertFalse("无规则命中", outcome.structured()); + // 启发式对这个形状弃权,所以 manifest 应当与输入相同、且不是 null + assertEquals("弃权时必须原样返回", twoCdn, outcome.manifest()); + assertFalse("弃权就不算 legacy 改动过", outcome.legacy()); + } + + /** + * 超过 2 MiB 上限时必须原样透传,且字节精确、顺序不变、只发一遍。 + * + *

夹具刻意含广告切片:不含的话结构化引擎与启发式都不会改动,测试即使把 + * {@code MAX_PLAYLIST_BYTES} 那道门删掉也照样绿 —— 钉不住上限本身。 + */ + @Test + public void passesOversizedPlaylistThroughByteForByte() throws IOException { + StringBuilder huge = new StringBuilder("#EXTM3U\n#EXT-X-TARGETDURATION:10\n"); + int i = 0; + while (huge.length() < 3 * 1024 * 1024) { + boolean ad = i % 50 == 7; + if (ad) huge.append("#EXT-X-DISCONTINUITY\n"); + huge.append(ad ? "#EXTINF:3.000,\n" : "#EXTINF:10.000,\n"); + huge.append(ad ? "https://ad-cdn.other.com/ads/" + i + ".ts" + : "https://" + PLAYLIST_HOST + "/seg/" + i + ".ts").append('\n'); + i++; + } + String text = huge.append("#EXT-X-ENDLIST\n").toString(); + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(text), () -> List.of(adRule()), () -> true); + + assertEquals("超限的 playlist 必须逐字节透传", text, readFully(source, PLAYLIST_URL)); + } + + /** + * 同一个实例被反复 {@code open()} / {@code close()} —— 这正是 + * {@code DefaultHlsPlaylistTracker} 复用一个 DataSource 做直播 reload 的生命周期。 + * 状态没清干净会把上一次的缓冲字节串到下一次。 + */ + @Test + public void reusesOneInstanceAcrossOpenCloseCycles() throws IOException { + FakeSource upstream = new FakeSource(playlist()); + upstream.rewindOnOpen = true; + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + upstream, () -> List.of(adRule()), () -> true); + + String first = readFully(source, PLAYLIST_URL); + String second = readFully(source, PLAYLIST_URL); + + assertEquals("复用同一实例的两次结果必须一致", first, second); + assertFalse(second.contains(adUri(3))); + } + + /** {@code DataReader} 契约:{@code readLength == 0} 必须返回 0,而不是 EOF。 */ + @Test + public void returnsZeroForZeroLengthRead() throws IOException { + HlsPlaylistCleaningDataSource source = new HlsPlaylistCleaningDataSource( + new FakeSource(playlist()), () -> List.of(adRule()), () -> true); + + source.open(null, PLAYLIST_URL, 0, C.LENGTH_UNSET); + + assertEquals(0, source.read(new byte[4], 0, 0)); + source.close(); + } + + /** + * {@code withPlaylistCleaning} 确实产出净化层。 + * + *

坦白其局限:它钉的是那个工厂方法本身,**不是**「Exo 的数据源链条上真的挂着 + * 它」。后者要走 {@code getDataSourceFactory},而那里需要 {@code App.get()} 与磁盘 + * 缓存,纯 JVM 下构造不出来。也就是说把 {@code getDataSourceFactory} 里的调用删掉, + * 这条测试仍会绿 —— 那一处只能靠人工复查或仪器化测试守。 + */ + @Test + public void withPlaylistCleaningProducesACleaningSource() { + DataSource.Factory wrapped = MediaSourceFactory.withPlaylistCleaning( + () -> new FakeSource(playlist())); + + assertTrue("工厂必须产出净化层", + wrapped.createDataSource() instanceof HlsPlaylistCleaningDataSource); + } + + private static final class FakeSource implements DataSource { + + private final byte[] data; + private int position; + private int maxChunk = Integer.MAX_VALUE; + /** 直播 reload 会对同一个 DataSource 反复 open,每次都从头给。 */ + private boolean rewindOnOpen; + + FakeSource(String text) { + this(text.getBytes(StandardCharsets.UTF_8)); + } + + FakeSource(byte[] data) { + this.data = data; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + } + + @Override + public long open(DataSpec dataSpec) { + if (rewindOnOpen) position = 0; + return data.length - position; + } + + @Override + public int read(byte[] target, int offset, int length) { + if (position >= data.length) return C.RESULT_END_OF_INPUT; + int count = Math.min(Math.min(length, maxChunk), data.length - position); + System.arraycopy(data, position, target, offset, count); + position += count; + return count; + } + + @Override + public Uri getUri() { + return null; + } + + @Override + public Map> getResponseHeaders() { + return Map.of(); + } + + @Override + public void close() { + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryPolicyTest.java index d04ce855b0..8cdba76d73 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryPolicyTest.java @@ -35,34 +35,4 @@ public class HttpEofRecoveryPolicyTest { assertFalse(HttpEofRecoveryDataSource.canRecoverConsecutively(3)); assertFalse(HttpEofRecoveryDataSource.canRecoverConsecutively(-1)); } - - @Test - public void spacesOutConsecutiveReconnects() { - // The first attempt is the one currently failing; it must not be delayed, or a single - // transient EOF would cost latency for no benefit. - assertEquals(0, HttpEofRecoveryDataSource.backoffMs(0)); - assertEquals(0, HttpEofRecoveryDataSource.backoffMs(-1)); - // Attempts 1..3 are the retries, strictly increasing so each gives the peer more room. - long first = HttpEofRecoveryDataSource.backoffMs(1); - long second = HttpEofRecoveryDataSource.backoffMs(2); - long third = HttpEofRecoveryDataSource.backoffMs(3); - assertTrue("first retry must be delayed", first > 0); - assertTrue("backoff must increase", second > first); - assertTrue("backoff must increase", third > second); - // Beyond the retry budget the delay saturates instead of growing without bound. - assertEquals(third, HttpEofRecoveryDataSource.backoffMs(4)); - assertEquals(third, HttpEofRecoveryDataSource.backoffMs(99)); - } - - @Test - public void totalBackoffStaysWithinOneRecoveryBurst() { - // Guards the cost of the change: a full 3-retry burst must stay well under a second so a - // working stream is not made to feel slow, while still being far above the 23-123 ms - // spacing that produced the observed retry storm. - long total = HttpEofRecoveryDataSource.backoffMs(1) - + HttpEofRecoveryDataSource.backoffMs(2) - + HttpEofRecoveryDataSource.backoffMs(3); - assertTrue("burst must be perceptible to the peer: " + total, total >= 500); - assertTrue("burst must stay sub-second: " + total, total < 1_000); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/LiveManifestRetryPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/LiveManifestRetryPolicyTest.java deleted file mode 100644 index 839b72778d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/LiveManifestRetryPolicyTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import androidx.media3.common.C; - -import org.junit.Test; - -import java.io.IOException; - -public class LiveManifestRetryPolicyTest { - - @Test - public void emptyBodyAndParseFailuresAreTransient() { - assertTrue(LiveManifestRetryPolicy.isTransientManifestFailure(new IOException())); - assertTrue(LiveManifestRetryPolicy.isTransientManifestFailure(new IOException("empty body"))); - assertTrue(LiveManifestRetryPolicy.isTransientManifestFailure(new IOException("failed to parse manifest"))); - assertTrue(LiveManifestRetryPolicy.isTransientManifestFailure( - new IOException("load failed", new IOException("unexpected eof")))); - } - - @Test - public void definitiveHttpFailuresStayFatal() { - assertFalse(LiveManifestRetryPolicy.isTransientManifestFailure(new IOException("Response code: 404"))); - assertFalse(LiveManifestRetryPolicy.isTransientManifestFailure(new IOException("upstream reset"))); - assertFalse(LiveManifestRetryPolicy.isTransientManifestFailure(null)); - } - - @Test - public void longerRetryWindowIsScopedToManifests() { - LiveManifestRetryPolicy policy = new LiveManifestRetryPolicy(); - - assertTrue(policy.getMinimumLoadableRetryCount(C.DATA_TYPE_MANIFEST) >= 8); - // 点播分片不能跟着 manifest 一起延长重试,否则死链会晚十几秒才失败。 - assertTrue(policy.getMinimumLoadableRetryCount(C.DATA_TYPE_MEDIA) - < policy.getMinimumLoadableRetryCount(C.DATA_TYPE_MANIFEST)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/MediaSourceFactoryIsolationTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/MediaSourceFactoryIsolationTest.java deleted file mode 100644 index 44f0f31b14..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/MediaSourceFactoryIsolationTest.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; - -import androidx.media3.datasource.HttpDataSource; -import androidx.media3.datasource.cache.Cache; -import androidx.media3.datasource.okhttp.OkHttpDataSource; - -import org.junit.Test; - -import java.lang.reflect.Field; -import java.lang.reflect.Proxy; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -import okhttp3.Call; - -public class MediaSourceFactoryIsolationTest { - - @Test - public void itemFactories_keepHeadersAndUserAgentSnapshotsWithoutReplacingClient() throws Exception { - Call.Factory client = request -> { throw new AssertionError("No network expected"); }; - Map firstHeaders = new LinkedHashMap<>(); - firstHeaders.put("Cookie", "sid=first"); - firstHeaders.put("Referer", "https://example.test/first"); - firstHeaders.put("Authorization", "Bearer first"); - firstHeaders.put("user-agent", " First UA "); - OkHttpDataSource.Factory first = MediaSourceFactory.createHttpDataSourceFactory(client, firstHeaders); - OkHttpDataSource.Factory second = MediaSourceFactory.createHttpDataSourceFactory(client, - Map.of("Cookie", "sid=second", "Referer", "https://example.test/second", - "Authorization", "Bearer second", "User-Agent", "Second UA")); - firstHeaders.put("Cookie", "sid=mutated"); - - assertEquals("sid=first", headers(first).get("Cookie")); - assertEquals("https://example.test/first", headers(first).get("Referer")); - assertEquals("Bearer first", headers(first).get("Authorization")); - assertEquals("First UA", field(first, "userAgent")); - assertFalse(headers(first).containsKey("user-agent")); - assertEquals("sid=second", headers(second).get("Cookie")); - assertEquals("Second UA", field(second, "userAgent")); - // Do not substitute a NO_COOKIES client, drop the proxy or alter redirect handling. - assertSame(client, field(first, "callFactory")); - assertSame(client, field(second, "callFactory")); - assertEquals(" First UA ", firstHeaders.get("user-agent")); - } - - @Test - public void absentOrBlankUserAgent_preservesUnderlyingClientDefault() throws Exception { - Call.Factory client = request -> { throw new AssertionError("No network expected"); }; - OkHttpDataSource.Factory factory = MediaSourceFactory.createHttpDataSourceFactory( - client, Map.of("USER-AGENT", " ")); - - assertNull(field(factory, "userAgent")); - assertEquals(Map.of(), headers(factory)); - } - - @Test - public void preloadAndForeground_shareIdentityAndKeepCredentialScopesDistinct() { - AtomicReference accessedKey = new AtomicReference<>(); - AtomicBoolean released = new AtomicBoolean(); - String url = "https://example.test/segment.ts"; - Map headers = new LinkedHashMap<>(Map.of("Authorization", "Bearer first")); - String expected = MediaSourceFactory.cacheKey(headers, url); - Cache delegate = recordingCache(accessedKey, released, Set.of(expected, "unrelated")); - Cache first = MediaSourceFactory.scopedCache(delegate, headers); - Cache second = MediaSourceFactory.scopedCache(delegate, Map.of("Authorization", "Bearer second")); - headers.put("Authorization", "Bearer mutated"); - - first.isCached(url, 0, 1); - assertEquals(expected, accessedKey.get()); - assertEquals(Set.of(url), first.getKeys()); - second.isCached(url, 0, 1); - assertNotEquals(expected, accessedKey.get()); - first.release(); - assertFalse(released.get()); - } - - @Test - public void preloadWithoutHeaders_keepsLegacyCacheKeysAndEnumeration() { - AtomicReference accessedKey = new AtomicReference<>(); - String url = "https://example.test/video.mp4"; - Cache delegate = recordingCache(accessedKey, new AtomicBoolean(), Set.of(url)); - Cache scoped = MediaSourceFactory.scopedCache(delegate, Map.of()); - - scoped.isCached(url, 0, 1); - assertEquals(url, accessedKey.get()); - assertEquals(Set.of(url), scoped.getKeys()); - assertEquals(MediaSourceFactory.cacheKey(Map.of(), url), accessedKey.get()); - } - - private static Cache recordingCache( - AtomicReference key, AtomicBoolean released, Set keys) { - return (Cache) Proxy.newProxyInstance(Cache.class.getClassLoader(), new Class[] {Cache.class}, - (proxy, method, args) -> { - switch (method.getName()) { - case "isCached": - key.set((String) args[0]); - return keys.contains(key.get()); - case "getKeys": - return keys; - case "release": - released.set(true); - return null; - default: - throw new AssertionError("Unexpected cache operation: " + method.getName()); - } - }); - } - - private static Map headers(OkHttpDataSource.Factory factory) throws Exception { - return ((HttpDataSource.RequestProperties) field(factory, "defaultRequestProperties")).getSnapshot(); - } - - private static Object field(OkHttpDataSource.Factory factory, String name) throws Exception { - Field field = OkHttpDataSource.Factory.class.getDeclaredField(name); - field.setAccessible(true); - return field.get(factory); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/MediaSourceFactoryTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/MediaSourceFactoryTest.java new file mode 100644 index 0000000000..7d05b39e16 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/MediaSourceFactoryTest.java @@ -0,0 +1,50 @@ +package com.fongmi.android.tv.player.exo; + +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.LinkedHashMap; +import java.util.Map; + +public class MediaSourceFactoryTest { + + @Test + public void sanitizeHeaders_trimsKeysAndValues() { + Map headers = new LinkedHashMap<>(); + headers.put(" Referer ", " https://movie.douban.com "); + headers.put(" ", "ignored"); + headers.put("Accept", null); + + Map sanitized = MediaSourceFactory.sanitizeHeaders(headers); + + assertEquals(1, sanitized.size()); + assertEquals("https://movie.douban.com", sanitized.get("Referer")); + } + + @Test + public void removeUserAgentHeader_extractsCaseInsensitively() { + Map headers = new LinkedHashMap<>(); + headers.put("referer", "https://example.test"); + headers.put("user-agent", " Custom UA "); + + String userAgent = MediaSourceFactory.removeUserAgentHeader(headers); + + assertEquals("Custom UA", userAgent); + assertEquals(1, headers.size()); + assertNull(headers.get("user-agent")); + assertEquals("https://example.test", headers.get("referer")); + } + + @Test + public void isHlsUrl_recognizesLiveAliases() { + assertTrue(MediaSourceFactory.isHlsUrl("https://example.test/play?type=hls&id=1")); + assertTrue(MediaSourceFactory.isHlsUrl("https://example.test/live/stream?id=1")); + assertTrue(MediaSourceFactory.isHlsUrl("https://example.test/tv/live.php?id=1")); + assertFalse(MediaSourceFactory.isHlsUrl("https://example.test/video.mp4")); + } + +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/PreCachePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCachePolicyTest.java index bcb04191b2..637f8edbde 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/PreCachePolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCachePolicyTest.java @@ -1,12 +1,7 @@ package com.fongmi.android.tv.player.exo; -import androidx.media3.common.Player; - import org.junit.Test; -import java.util.HashSet; -import java.util.Set; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -38,29 +33,6 @@ public class PreCachePolicyTest { assertFalse(PreCachePolicy.hasSafeBuffer(7_999, true, 8_000, true)); } - @Test - public void preloadWaitsForAStablePlayingWindow() { - assertFalse(PreCachePolicy.isPlaybackStableForPreload(4_999, 5_000, true, false)); - assertFalse(PreCachePolicy.isPlaybackStableForPreload(5_000, 5_000, false, false)); - assertFalse(PreCachePolicy.isPlaybackStableForPreload(5_000, 5_000, true, true)); - assertTrue(PreCachePolicy.isPlaybackStableForPreload(5_001, 5_000, true, false)); - } - - @Test - public void seekSuppressionRequiresReadyIdleAndSafeBuffer() { - assertFalse(PreCache.shouldReleaseSeekPreloadSuppression(Player.STATE_BUFFERING, true, false, true)); - assertFalse(PreCache.shouldReleaseSeekPreloadSuppression(Player.STATE_READY, false, false, true)); - assertFalse(PreCache.shouldReleaseSeekPreloadSuppression(Player.STATE_READY, true, true, true)); - assertFalse(PreCache.shouldReleaseSeekPreloadSuppression(Player.STATE_READY, true, false, false)); - assertTrue(PreCache.shouldReleaseSeekPreloadSuppression(Player.STATE_READY, true, false, true)); - } - - @Test - public void repeatedPreloadFailuresOpenCircuitAtThreshold() { - assertFalse(PreCache.shouldOpenPreloadFailureCircuit(1)); - assertTrue(PreCache.shouldOpenPreloadFailureCircuit(2)); - } - @Test public void loadControlIdleFallbackStillKeepsAReserve() { assertFalse(PreCachePolicy.hasSafeBuffer(1_999, false, 5_000, false)); @@ -102,114 +74,6 @@ public class PreCachePolicyTest { assertEquals(0, PreCachePolicy.preloadResumeWatermarkMs(10_000, 20_000)); } - @Test - public void completedRangesWaitBeforeStartingAnotherTask() { - assertEquals(5_000, PreCachePolicy.nextRangeDelayMs(true)); - assertEquals(0, PreCachePolicy.nextRangeDelayMs(false)); - } - - /** - * 硬暂停名单必须与 {@link AutoPreloadPolicy.Reason} 的语义一一对应。 - * - *

为什么需要这条守卫:{@code PreCache.effectiveAutoDecision()} 会把 - * {@code evaluate()} 的暂停结论**改回**「1 线程 / 10 s 继续预载」,唯一的否决点就是 - * {@code isHardAutomaticPause()}。新增一个语义上属于硬暂停的原因却忘了登记, - * 策略单测仍会全绿(它们直接调 {@code evaluate()},不经过 {@code PreCache}), - * 生产上却完全失效——{@code FOREGROUND_EOF_RECOVERY} 就是这样被漏掉的, - * 「让预加载给前台恢复让路」退化成「照旧抢同一条脆弱连接」。 - * - *

双向断言:枚举里的每个原因都必须被明确分类,既不能漏登记硬暂停,也不能把 - * 缓冲区/吞吐类原因误升为硬暂停(那会停掉既有的「降级继续跑」行为)。 - * - *

本用例把枚举的**每一个**原因都显式归入「硬暂停」或「降级继续跑」两张表, - * 再断言两表并集恰好覆盖枚举全集。因此新增原因时本用例会**立即**失败(新标签 - * 两张表都不在),迫使作者显式决定它属于哪一类——这正是漏登 - * {@code FOREGROUND_EOF_RECOVERY} 时缺失的保护。 - * - *

为什么不能只写「硬暂停表」再逐个比对:那样新增的原因在两张表里都是 - * {@code false},{@code false == false} 恒真,用例照样全绿,守卫形同虚设。 - */ - @Test - public void hardAutomaticPauseMatchesTheResourceReasonSet() { - Set hard = Set.of( - AutoPreloadPolicy.Reason.SESSION_MISMATCH.label(), - AutoPreloadPolicy.Reason.MEMORY_PRESSURE.label(), - AutoPreloadPolicy.Reason.NETWORK_UNAVAILABLE.label(), - AutoPreloadPolicy.Reason.NETWORK_UNVALIDATED.label(), - AutoPreloadPolicy.Reason.DATA_SAVER.label(), - AutoPreloadPolicy.Reason.POWER_SAVE.label(), - AutoPreloadPolicy.Reason.THERMAL_PRESSURE.label(), - AutoPreloadPolicy.Reason.FOREGROUND_EOF_RECOVERY.label()); - // 其余原因只降级(缩任务/降线程),不停磁盘预载。这张表同样必须显式维护。 - Set degraded = Set.of( - AutoPreloadPolicy.Reason.REBUFFER.label(), - AutoPreloadPolicy.Reason.NETWORK_CHANGED.label(), - AutoPreloadPolicy.Reason.METERED.label(), - AutoPreloadPolicy.Reason.ROAMING.label(), - AutoPreloadPolicy.Reason.DATA_SAVER_WHITELISTED.label(), - AutoPreloadPolicy.Reason.THERMAL_MODERATE.label(), - AutoPreloadPolicy.Reason.FOREGROUND_RECOVERY.label(), - AutoPreloadPolicy.Reason.FRONT_BUFFER_LOW.label(), - AutoPreloadPolicy.Reason.FRONT_BUFFER_MARGIN.label(), - AutoPreloadPolicy.Reason.BUFFER_DECLINING.label(), - AutoPreloadPolicy.Reason.BUFFER_EVIDENCE_UNKNOWN.label(), - AutoPreloadPolicy.Reason.TIME_TO_EMPTY.label(), - AutoPreloadPolicy.Reason.THROUGHPUT_DEFICIT.label(), - AutoPreloadPolicy.Reason.SHORT_WINDOW_DEFICIT.label(), - AutoPreloadPolicy.Reason.THROUGHPUT_MARGIN.label(), - AutoPreloadPolicy.Reason.SHORT_WINDOW_DECLINE.label(), - AutoPreloadPolicy.Reason.THROUGHPUT_EVIDENCE_UNKNOWN.label(), - AutoPreloadPolicy.Reason.MEDIA_BITRATE_UNKNOWN.label(), - AutoPreloadPolicy.Reason.PREDICTION_ERROR.label(), - AutoPreloadPolicy.Reason.PATH_LIMITED.label(), - AutoPreloadPolicy.Reason.VPN.label(), - AutoPreloadPolicy.Reason.PRELOAD_CONTENTION.label(), - AutoPreloadPolicy.Reason.SYSTEM_EVIDENCE_UNKNOWN.label(), - AutoPreloadPolicy.Reason.EXTERNAL_BUFFER_MARGIN.label(), - AutoPreloadPolicy.Reason.EVIDENCE_UNKNOWN.label(), - AutoPreloadPolicy.Reason.RECOVERY_STABILIZING.label(), - AutoPreloadPolicy.Reason.STABLE_NORMAL.label(), - AutoPreloadPolicy.Reason.STABLE_FAST.label(), - AutoPreloadPolicy.Reason.FAST_FALLBACK.label(), - AutoPreloadPolicy.Reason.DISRUPTED.label()); - - Set classified = new HashSet<>(hard); - classified.addAll(degraded); - assertEquals("every Reason must be classified as hard-pause or degraded", - AutoPreloadPolicy.Reason.values().length, classified.size()); - - for (AutoPreloadPolicy.Reason reason : AutoPreloadPolicy.Reason.values()) { - assertEquals("hard-pause classification of " + reason.label(), - hard.contains(reason.label()), PreCache.isHardAutomaticPause(reason.label())); - } - assertFalse(PreCache.isHardAutomaticPause(null)); - assertFalse(PreCache.isHardAutomaticPause("no-such-reason")); - } - - /** - * 前台 EOF 恢复期间必须真的停预载,而不是只降级。 - * - *

这条用例锁的是 {@code effectiveAutoDecision()} 里「降级为 1 线程 / 10 s 继续跑」 - * 那条分支的**否决条件**:只要原因没被登记为硬暂停,{@code evaluate()} 的暂停结论 - * 就会被改写成「1 线程 / 10 s / degraded / *-continuous」继续预载。因此这里断言 - * {@code isHardAutomaticPause} 对该原因返回 true,并同时用缓冲区/吞吐类原因作对照, - * 确保既有的「降级继续跑」行为没有被误伤。 - * - *

注意本用例不直接调用 {@code effectiveAutoDecision()}(它需要 Android 上下文与 - * 播放器实例,纯 JVM 单测无法构造),而是锁定它唯一的否决点。该否决点一旦失守, - * 生产上「让预加载给前台恢复让路」就退化成「照旧抢同一条脆弱连接」。 - */ - @Test - public void foregroundEofRecoveryIsNotDowngradedBackToContinuousPreload() { - assertTrue(PreCache.isHardAutomaticPause( - AutoPreloadPolicy.Reason.FOREGROUND_EOF_RECOVERY.label())); - // 对照组:缓冲区类原因必须仍然走「降级继续跑」,否则会停掉既有行为。 - assertFalse(PreCache.isHardAutomaticPause( - AutoPreloadPolicy.Reason.FRONT_BUFFER_LOW.label())); - assertFalse(PreCache.isHardAutomaticPause( - AutoPreloadPolicy.Reason.THROUGHPUT_DEFICIT.label())); - } - private static long target(boolean recovery, long remainingMs, double bitrateMbps, double capacityMib) { long bitrate = Math.round(bitrateMbps * 1_000_000); int capacity = (int) Math.round(capacityMib * 1024 * 1024); diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheTest.java new file mode 100644 index 0000000000..6b59fdbfa0 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheTest.java @@ -0,0 +1,43 @@ +package com.fongmi.android.tv.player.exo; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class PreCacheTest { + + @Test + public void canPreCache_allowsRegularHttpMedia() { + assertTrue(PreCache.canPreCache("https", "https://cdn.example.test/movie.mkv")); + assertTrue(PreCache.canPreCache("http", "http://cdn.example.test/movie.mp4")); + } + + @Test + public void canPreCache_allowsLocalProxyMediaLikeUpstream() { + // Local proxy URLs are passed through to ExoPlayer for jar-based multi-threading. + assertTrue(PreCache.canPreCache("http", "http://127.0.0.1:9978/proxy?do=js")); + assertTrue(PreCache.canPreCache("http", "http://localhost:9978/proxy?siteKey=drive")); + assertTrue(PreCache.canPreCache("http", "http://[::1]:9978/proxy?do=py")); + assertTrue(PreCache.canPreCache("http", "http://127.0.0.1:5000/proxy/1_4213_0_0")); + } + + @Test + public void canPreCache_skipsNonHttpAndConcatenatingMedia() { + assertFalse(PreCache.canPreCache("file", "file:///sdcard/movie.mkv")); + assertFalse(PreCache.canPreCache("https", "https://a.test/1.mp4|||1000***https://b.test/2.mp4|||1000")); + } + + @Test + public void workerUsabilityRejectsFailedAndStoppedThreads() throws Exception { + Thread running = Thread.currentThread(); + Thread stopped = new Thread(() -> {}); + stopped.start(); + stopped.join(); + + assertTrue(PreCache.isWorkerUsable(running, null)); + assertFalse(PreCache.isWorkerUsable(running, running)); + assertFalse(PreCache.isWorkerUsable(stopped, null)); + assertFalse(PreCache.isWorkerUsable(null, null)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheThreadExceptionHandlerTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheThreadExceptionHandlerTest.java new file mode 100644 index 0000000000..152cf8ef95 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheThreadExceptionHandlerTest.java @@ -0,0 +1,74 @@ +package com.fongmi.android.tv.player.exo; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +public class PreCacheThreadExceptionHandlerTest { + + @Test + public void runtimeExceptionIsContainedAndReported() throws Exception { + IndexOutOfBoundsException failure = new IndexOutOfBoundsException("empty HLS group"); + AtomicReference failedThread = new AtomicReference<>(); + AtomicReference reported = new AtomicReference<>(); + AtomicReference delegated = new AtomicReference<>(); + Thread thread = new Thread(() -> { + throw failure; + }); + thread.setUncaughtExceptionHandler(new PreCacheThreadExceptionHandler((source, error) -> { + failedThread.set(source); + reported.set(error); + }, (source, error) -> delegated.set(error))); + + thread.start(); + thread.join(); + + assertSame(thread, failedThread.get()); + assertSame(failure, reported.get()); + assertNull(delegated.get()); + } + + @Test + public void fatalErrorIsDelegated() throws Exception { + AssertionError failure = new AssertionError("fatal worker failure"); + AtomicReference reported = new AtomicReference<>(); + AtomicReference delegatedThread = new AtomicReference<>(); + AtomicReference delegated = new AtomicReference<>(); + Thread thread = new Thread(() -> { + throw failure; + }); + thread.setUncaughtExceptionHandler(new PreCacheThreadExceptionHandler((source, error) -> reported.set(error), (source, error) -> { + delegatedThread.set(source); + delegated.set(error); + })); + + thread.start(); + thread.join(); + + assertNull(reported.get()); + assertSame(thread, delegatedThread.get()); + assertSame(failure, delegated.get()); + } + + @Test + public void recoveryFailureIsDelegatedWithOriginalFailure() throws Exception { + IndexOutOfBoundsException original = new IndexOutOfBoundsException("empty HLS group"); + IllegalStateException recoveryFailure = new IllegalStateException("recovery failed"); + AtomicReference delegated = new AtomicReference<>(); + Thread thread = new Thread(() -> { + throw original; + }); + thread.setUncaughtExceptionHandler(new PreCacheThreadExceptionHandler((source, error) -> { + throw recoveryFailure; + }, (source, error) -> delegated.set(error))); + + thread.start(); + thread.join(); + + assertSame(recoveryFailure, delegated.get()); + assertSame(original, recoveryFailure.getSuppressed()[0]); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheWorkerRecoveryTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheWorkerRecoveryTest.java new file mode 100644 index 0000000000..e17a3462bb --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheWorkerRecoveryTest.java @@ -0,0 +1,161 @@ +package com.fongmi.android.tv.player.exo; + +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PreCacheWorkerRecoveryTest { + + @Test + public void queuedFailureDoesNotPreventReleaseAndExecutorShutdown() { + RuntimeException queuedFailure = new IllegalStateException("stale worker message"); + AtomicBoolean released = new AtomicBoolean(); + FakeQueue queue = new FakeQueue(released::set); + queue.enqueue(() -> { + throw queuedFailure; + }); + ThreadPoolExecutor executor = newExecutor(); + List failures = new ArrayList<>(); + + PreCacheWorkerRecovery.Result result = PreCache.recoverWorkerResources(queue, executor, (error, releasePhase) -> failures.add(new DrainFailure(error, releasePhase))); + + assertEquals(PreCacheWorkerRecovery.Result.RELEASED, result); + assertTrue(released.get()); + assertTrue(executor.isShutdown()); + assertEquals(2, queue.drainCalls); + assertEquals(List.of(new DrainFailure(queuedFailure, false)), failures); + } + + @Test + public void releaseFailureIsReportedAfterQueueIsDrained() { + RuntimeException releaseFailure = new IllegalStateException("release failed"); + FakeQueue queue = new FakeQueue(value -> { + throw releaseFailure; + }); + ThreadPoolExecutor executor = newExecutor(); + List failures = new ArrayList<>(); + + PreCacheWorkerRecovery.Result result = PreCache.recoverWorkerResources(queue, executor, (error, releasePhase) -> failures.add(new DrainFailure(error, releasePhase))); + + assertEquals(PreCacheWorkerRecovery.Result.RELEASE_FAILED, result); + assertTrue(executor.isShutdown()); + assertEquals(2, queue.drainCalls); + assertEquals(List.of(new DrainFailure(releaseFailure, true)), failures); + } + + @Test + public void rejectedQueueDrainsReleaseQueuedByConcurrentStop() { + AtomicBoolean released = new AtomicBoolean(); + FakeQueue queue = new FakeQueue(released::set); + queue.enqueueRelease(); + queue.accepting = false; + queue.quit = true; + ThreadPoolExecutor executor = newExecutor(); + + PreCacheWorkerRecovery.Result result = PreCache.recoverWorkerResources(queue, executor, (error, releasePhase) -> {}); + + assertEquals(PreCacheWorkerRecovery.Result.QUEUE_REJECTED, result); + assertTrue(executor.isShutdown()); + assertTrue(released.get()); + assertTrue(queue.quit); + assertEquals(1, queue.drainCalls); + } + + @Test + public void synchronousReleaseEnqueueFailureStillQuitsAndShutsDown() { + RuntimeException releaseFailure = new IllegalStateException("release enqueue failed"); + FakeQueue queue = new FakeQueue(value -> {}); + queue.enqueueFailure = releaseFailure; + ThreadPoolExecutor executor = newExecutor(); + List failures = new ArrayList<>(); + + PreCacheWorkerRecovery.Result result = PreCache.recoverWorkerResources(queue, executor, (error, releasePhase) -> failures.add(new DrainFailure(error, releasePhase))); + + assertEquals(PreCacheWorkerRecovery.Result.RELEASE_FAILED, result); + assertTrue(queue.quit); + assertEquals(0, queue.drainCalls); + assertTrue(executor.isShutdown()); + assertEquals(List.of(new DrainFailure(releaseFailure, true)), failures); + } + + @Test + public void missingCompletionMarkerCannotReportReleaseSuccess() { + FakeQueue queue = new FakeQueue(value -> {}); + queue.dropActionsOnDrain = true; + ThreadPoolExecutor executor = newExecutor(); + + PreCacheWorkerRecovery.Result result = PreCache.recoverWorkerResources(queue, executor, (error, releasePhase) -> {}); + + assertEquals(PreCacheWorkerRecovery.Result.RELEASE_FAILED, result); + assertTrue(executor.isShutdown()); + } + + private static ThreadPoolExecutor newExecutor() { + return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); + } + + private record DrainFailure(RuntimeException error, boolean releasePhase) { + } + + private interface ReleaseAction { + + void run(boolean value); + } + + private static final class FakeQueue implements PreCacheWorkerRecovery.Queue { + + private final ArrayDeque actions = new ArrayDeque<>(); + private final ReleaseAction releaseAction; + private boolean accepting = true; + private boolean dropActionsOnDrain; + private boolean quit; + private int drainCalls; + private RuntimeException enqueueFailure; + + private FakeQueue(ReleaseAction releaseAction) { + this.releaseAction = releaseAction; + } + + private void enqueue(Runnable action) { + actions.add(action); + } + + @Override + public boolean post(Runnable action) { + if (!accepting) return false; + actions.add(action); + return true; + } + + @Override + public void enqueueRelease() { + if (enqueueFailure != null) throw enqueueFailure; + if (accepting) actions.add(() -> releaseAction.run(true)); + } + + @Override + public void quitSafely() { + quit = true; + accepting = false; + } + + @Override + public void drain() { + drainCalls++; + if (dropActionsOnDrain) { + actions.clear(); + return; + } + while (!actions.isEmpty()) actions.removeFirst().run(); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheWorkerRecoveryWiringTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheWorkerRecoveryWiringTest.java new file mode 100644 index 0000000000..1150bf6ff1 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCacheWorkerRecoveryWiringTest.java @@ -0,0 +1,93 @@ +package com.fongmi.android.tv.player.exo; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PreCacheWorkerRecoveryWiringTest { + + @Test + public void workerFailureReleasesResourcesBeforePostingStateCleanup() throws Exception { + String method = methodBody(readPreCache(), "private void onWorkerRuntimeFailure", "private void disablePreCacheAfterWorkerFailure"); + int release = method.indexOf("releaseFailedWorkerResources(failedThread, failedHelper, failedExecutor, error)"); + int stateCleanup = method.indexOf("target.post(() -> disablePreCacheAfterWorkerFailure"); + + assertTrue("Worker resources must be released on the failed looper", release >= 0); + assertTrue("Resource release must finish before application-thread state cleanup", stateCleanup > release); + } + + @Test + public void androidAdapterQueuesHelperReleaseAndDrainsFailedLooper() throws Exception { + String method = methodBody(readPreCache(), "private PreCacheWorkerRecovery.Result releaseFailedWorkerResources", "private void disablePreCacheAfterWorkerFailure"); + + assertTrue(method.contains("failedHelper.release(false);")); + assertTrue(method.contains("target.quitSafely();")); + assertTrue(method.contains("Looper.loop();")); + assertTrue(method.contains("recoverWorkerResources(queue, failedExecutor")); + } + + @Test + public void androidAdapterDrainsReleaseQueuedByConcurrentStopAfterHelperWasCleared() throws Exception { + String method = methodBody(readPreCache(), "private PreCacheWorkerRecovery.Result releaseFailedWorkerResources", "private void disablePreCacheAfterWorkerFailure"); + String preconditions = method.substring(0, method.indexOf("PreCacheWorkerRecovery.Queue queue")); + + assertFalse("A cleared helper may already have queued its release", preconditions.contains("failedHelper == null")); + assertTrue("Only enqueue a second release when the helper is still available", method.contains("if (failedHelper != null) failedHelper.release(false);")); + } + + @Test + public void workerFailureUsesResourcesOwnedByTheFailedWorker() throws Exception { + String source = readPreCache(); + String getWorker = methodBody(source, "private HandlerThread getWorker", "private void onWorkerRuntimeFailure"); + String onFailure = methodBody(source, "private void onWorkerRuntimeFailure", "private PreCacheWorkerRecovery.Result releaseFailedWorkerResources"); + + assertTrue("Each worker needs an isolated resource context", getWorker.contains("new WorkerResources(created)")); + assertTrue("The exception handler must capture its worker context", getWorker.contains("onWorkerRuntimeFailure(resources, thread, error)")); + assertTrue("Recovery must use the failed worker's helper", onFailure.contains("failedResources.helper")); + assertTrue("Recovery must use the failed worker's executor", onFailure.contains("failedResources.executor")); + assertFalse("Recovery must not snapshot a replacement session's helper", onFailure.contains("PreCacheHelper failedHelper = helper")); + assertFalse("Recovery must not snapshot a replacement session's executor", onFailure.contains("ThreadPoolExecutor failedExecutor = executor")); + } + + @Test + public void workerFailureIsLoggedEvenAfterApplicationHandlerWasCleared() throws Exception { + String method = methodBody(readPreCache(), "private void onWorkerRuntimeFailure", "private PreCacheWorkerRecovery.Result releaseFailedWorkerResources"); + int log = method.indexOf("logWorkerFailure(\"worker-failure\", error, recovery)"); + int handler = method.indexOf("Handler target = handler"); + + assertTrue("Worker failure must be logged before checking application state", log >= 0 && log < handler); + } + + @Test + public void workerContextRejectsResourcesBoundAfterFailureBegins() throws Exception { + String source = readPreCache(); + String createHelper = methodBody(source, "private PreCacheHelper createHelper", "private String errorDetails"); + String onFailure = methodBody(source, "private void onWorkerRuntimeFailure", "private PreCacheWorkerRecovery.Result releaseFailedWorkerResources"); + String resources = source.substring(source.indexOf("private static final class WorkerResources"), source.indexOf("private enum BufferGate")); + + assertTrue("Failure must freeze the worker resource snapshot", onFailure.contains("failedResources.markFailed();")); + assertTrue("Late helper binding must abort the new pre-cache session", createHelper.contains("if (!resources.bindHelper(created))")); + assertTrue("Worker resource binding must reject failed contexts", resources.contains("if (failed) return false;")); + } + + private static String readPreCache() throws IOException { + Path root = Path.of("").toAbsolutePath(); + Path source = root.resolve(Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "player", "exo", "PreCache.java")); + if (!Files.exists(source)) source = root.resolve(Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "player", "exo", "PreCache.java")); + return Files.readString(source, StandardCharsets.UTF_8).replace("\r\n", "\n"); + } + + private static String methodBody(String source, String startToken, String endToken) { + int start = source.indexOf(startToken); + int end = source.indexOf(endToken, start); + assertTrue("Missing source token: " + startToken, start >= 0); + assertTrue("Missing source token after " + startToken + ": " + endToken, end > start); + return source.substring(start, end); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/PreloadLifecycleTrackerTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/PreloadLifecycleTrackerTest.java index acca52e865..8985f2f614 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/PreloadLifecycleTrackerTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/PreloadLifecycleTrackerTest.java @@ -68,6 +68,20 @@ public class PreloadLifecycleTrackerTest { assertEquals(8, completed.generation()); } + @Test + public void internalWorkerFailureIsRecordedOnlyOnce() { + PreloadLifecycleTracker tracker = new PreloadLifecycleTracker(); + tracker.beginSession(); + tracker.startTask(9, 5_000, 10_000); + + PreloadLifecycleTracker.TaskEvent failed = tracker.endTask(PreloadLifecycleTracker.TaskEvent.Outcome.INTERNAL_ERROR); + + assertEquals(PreloadLifecycleTracker.TaskEvent.Type.END, failed.type()); + assertEquals(PreloadLifecycleTracker.TaskEvent.Outcome.INTERNAL_ERROR, failed.outcome()); + assertEquals("internal-error", failed.outcome().label()); + assertNull(tracker.endTask(PreloadLifecycleTracker.TaskEvent.Outcome.CANCELLED)); + } + @Test public void sessionsAreIndependentAndEndClearsActiveTask() { PreloadLifecycleTracker tracker = new PreloadLifecycleTracker(); diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/TrackUtilTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/TrackUtilTest.java index d4e5b97e7b..27f6f8757e 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/TrackUtilTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/TrackUtilTest.java @@ -2,7 +2,6 @@ package com.fongmi.android.tv.player.exo; import androidx.media3.common.C; import androidx.media3.common.Format; -import androidx.media3.common.MimeTypes; import androidx.media3.common.TrackGroup; import androidx.media3.common.Tracks; @@ -33,108 +32,113 @@ public class TrackUtilTest { assertNull(TrackUtil.onlySelectedFormat(List.of(low, high))); } - /** - * 自适应选轨会把整组候选都标成 selected(视轨列表因此整列高亮)。播放器报告的 - * 正在解码格式必须把范围收敛回快照里的那一条,且返回的必须是快照内的实例—— - * TrackDialog 按引用比对,equals 会把同规格的两条轨道一起点亮。 - */ @Test - public void uniqueActiveFormatCollapsesAdaptiveCandidatesToTheDecodingRendition() { - Format low = rendition("v-low", 426, 240, 550_000); - Format mid = rendition("v-mid", 854, 480, 1_570_000); - Format high = rendition("v-high", 1280, 720, 2_970_000); - Tracks tracks = adaptiveVideo(low, mid, high); - Format decoding = rendition("v-high", 1280, 720, 2_970_000); + public void uniqueActiveFormat_narrowsAdaptiveSelectionToTheDecodedTrack() { + Format low = variant("1", "video/avc", 256, 144, 290_000); + Format high = variant("2", "video/avc", 1920, 1080, 5_420_000); + Tracks tracks = videoTracks(new Format[]{low, high}, new boolean[]{true, true}); + // 解码格式与清单里的 variant 是两个不同实例,字段也不完全一致,必须走 sameRendition 而不是 equals。 + Format decoded = new Format.Builder().setSampleMimeType("video/avc").setWidth(1920).setHeight(1080).setAverageBitrate(5_420_000).build(); - Format active = TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, decoding); - - assertSame("must return the snapshot instance, not the player's copy", high, active); + assertSame(high, TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, decoded)); } @Test - public void uniqueActiveFormatLeavesAnUnambiguousSelectionAlone() { - Format low = rendition("v-low", 426, 240, 550_000); - Format high = rendition("v-high", 1280, 720, 2_970_000); - Tracks tracks = new Tracks(List.of(new Tracks.Group( - new TrackGroup(low, high), true, - new int[]{C.FORMAT_HANDLED, C.FORMAT_HANDLED}, - new boolean[]{false, true}))); + public void uniqueActiveFormat_doesNotConfuseSameResolutionCodecVariants() { + Format avc = variant("3", "video/avc", 1920, 1080, Format.NO_VALUE); + Format hevc = variant("4", "video/hevc", 1920, 1080, Format.NO_VALUE); + Tracks tracks = videoTracks(new Format[]{avc, hevc}, new boolean[]{true, true}); + Format decodedHevc = new Format.Builder().setSampleMimeType("video/hevc").setWidth(1920).setHeight(1080).build(); - assertNull("a single selected candidate needs no narrowing", - TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, rendition("v-high", 1280, 720, 2_970_000))); - } - - /** 匹配不上时返回 null,让调用方退回播放器原始标记,而不是让列表一项都不高亮。 */ - @Test - public void uniqueActiveFormatFallsBackWhenTheDecodingRenditionIsNotInTheCandidates() { - Tracks tracks = adaptiveVideo( - rendition("v-low", 426, 240, 550_000), - rendition("v-mid", 854, 480, 1_570_000)); - - assertNull(TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, rendition("v-4k", 3840, 2160, 15_000_000))); + assertSame(hevc, TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, decodedHevc)); } @Test - public void uniqueActiveFormatIgnoresOtherTrackTypes() { - Format video = rendition("v-high", 1280, 720, 2_970_000); - Tracks tracks = new Tracks(List.of(new Tracks.Group( - new TrackGroup(video), true, - new int[]{C.FORMAT_HANDLED}, - new boolean[]{true}))); + public void uniqueActiveFormat_staysUnknownWhenNothingDistinguishesSameResolutionTracks() { + Format first = variant("5", null, 1920, 1080, Format.NO_VALUE); + Format second = variant("6", null, 1920, 1080, Format.NO_VALUE); + Tracks tracks = videoTracks(new Format[]{first, second}, new boolean[]{true, true}); + Format decoded = new Format.Builder().setWidth(1920).setHeight(1080).build(); - assertNull(TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_AUDIO, rendition("v-high", 1280, 720, 2_970_000))); + assertNull(TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, decoded)); } @Test - public void sameRenditionMatchesOnWidthHeightAndBitrate() { - assertTrue(TrackUtil.sameRendition( - rendition("v-high", 1280, 720, 2_970_000), - rendition("other-id", 1280, 720, 2_970_000))); + public void uniqueActiveFormat_keepsPlayerFlagsWhenOnlyOneTrackIsSelected() { + Format low = video("video/avc", 256, 144); + Format high = video("video/avc", 1920, 1080); + Tracks tracks = videoTracks(new Format[]{low, high}, new boolean[]{false, true}); + + assertNull(TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, high)); } @Test - public void sameRenditionMatchesOnTrackIdEvenWhenTheMetadataDisagrees() { - assertTrue(TrackUtil.sameRendition( - rendition("v-high", 1280, 720, 2_970_000), - rendition("v-high", 1920, 1080, 4_000_000))); + public void uniqueActiveFormat_keepsPlayerFlagsWhenDecodedTrackIsUnknown() { + Format low = variant("1", "video/avc", 256, 144, 290_000); + Format high = variant("2", "video/avc", 1920, 1080, 5_420_000); + Tracks tracks = videoTracks(new Format[]{low, high}, new boolean[]{true, true}); + + assertNull("播放器还没报出解码格式时必须退回原始选中标记", + TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, null)); } @Test - public void sameRenditionRejectsTheSameResolutionWithADifferentCodec() { - assertFalse("同一分辨率可能同时提供 H.264 与 HEVC 两条轨道,编码能比就必须不一致", - TrackUtil.sameRendition( - rendition("v-avc", 1280, 720, 2_970_000, MimeTypes.VIDEO_H264), - rendition("v-hevc", 1280, 720, 2_970_000, MimeTypes.VIDEO_H265))); + public void uniqueActiveFormat_staysUnknownWhenDecodedResolutionMatchesNoTrack() { + Format low = variant("1", "video/avc", 256, 144, 290_000); + Format high = variant("2", "video/avc", 1920, 1080, 5_420_000); + Tracks tracks = videoTracks(new Format[]{low, high}, new boolean[]{true, true}); + Format decoded = new Format.Builder().setSampleMimeType("video/avc").setWidth(1280).setHeight(720).setAverageBitrate(2_970_000).build(); + + assertNull(TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, decoded)); } @Test - public void sameRenditionRejectsADifferentBitrateAtTheSameResolution() { - assertFalse(TrackUtil.sameRendition( - rendition("v-high", 1280, 720, 2_970_000), - rendition("v-low", 1280, 720, 1_570_000))); + public void sameRendition_matchesTheManifestVariantBehindTheDecodedFormat() { + Format manifest = variant("2", "video/avc", 1920, 1080, 5_420_000); + Format decoded = new Format.Builder().setSampleMimeType("video/avc").setWidth(1920).setHeight(1080).build(); + + assertTrue(TrackUtil.sameRendition(manifest, decoded)); } - private static Tracks adaptiveVideo(Format... formats) { - boolean[] selected = new boolean[formats.length]; + @Test + public void sameRendition_rejectsADifferentCodecAtTheSameResolution() { + Format avc = variant("3", "video/avc", 1920, 1080, Format.NO_VALUE); + Format decodedHevc = new Format.Builder().setSampleMimeType("video/hevc").setWidth(1920).setHeight(1080).build(); + + assertFalse("同分辨率的 H.264 与 HEVC 是两条轨道,不能互相当成同一条", + TrackUtil.sameRendition(avc, decodedHevc)); + } + + @Test + public void sameRendition_rejectsADifferentCodecEvenWhenBitratesAgree() { + Format avc = variant("3", "video/avc", 1920, 1080, 5_420_000); + Format decodedHevc = new Format.Builder().setSampleMimeType("video/hevc").setWidth(1920).setHeight(1080).setAverageBitrate(5_420_000).build(); + + // 码率相同也不能靠码率判定同一条,编码不同就是两条轨道。 + assertFalse(TrackUtil.sameRendition(avc, decodedHevc)); + } + + @Test + public void sameRendition_rejectsWhenBothBitratesAreUnknown() { + Format manifest = variant("5", null, 1920, 1080, Format.NO_VALUE); + Format decoded = new Format.Builder().setWidth(1920).setHeight(1080).build(); + + assertFalse(TrackUtil.sameRendition(manifest, decoded)); + } + + private static Tracks videoTracks(Format[] formats, boolean[] selected) { int[] support = new int[formats.length]; - for (int i = 0; i < formats.length; i++) { - selected[i] = true; - support[i] = C.FORMAT_HANDLED; - } - return new Tracks(List.of(new Tracks.Group(new TrackGroup(formats), true, support, selected))); + for (int i = 0; i < support.length; i++) support[i] = C.FORMAT_HANDLED; + return new Tracks(List.of(new Tracks.Group(new TrackGroup("video", formats), true, support, selected))); } - private static Format rendition(String id, int width, int height, int bitrate) { - return rendition(id, width, height, bitrate, MimeTypes.VIDEO_H264); - } - - private static Format rendition(String id, int width, int height, int bitrate, String mimeType) { + private static Format variant(String id, String mimeType, int width, int height, int averageBitrate) { return new Format.Builder() .setId(id) .setSampleMimeType(mimeType) .setWidth(width) .setHeight(height) - .setAverageBitrate(bitrate) + .setAverageBitrate(averageBitrate) .build(); } diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ass/AssInputTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ass/AssInputTest.java deleted file mode 100644 index 7588740837..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ass/AssInputTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import org.junit.Test; -import java.nio.ByteBuffer; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import static org.junit.Assert.*; - -public class AssInputTest { - private static final String SCRIPT = "[Script Info]\nTitle: 中文字幕,逗号不截断\n[Events]\n" - + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,中文测试字幕,保留逗号和组合字符 é\n"; - - @Test public void boundedCopyRespectsPositionLimitAndSliceOffset() { - byte[] bytes = {99, 98, 10, 20, 30, 97}; - ByteBuffer slice = ByteBuffer.wrap(bytes, 1, 5).slice(); - slice.position(1); slice.limit(4); - assertArrayEquals(new byte[]{10, 20, 30}, AssInput.copy(slice.asReadOnlyBuffer())); - assertEquals(1, slice.position()); - } - - @Test public void mergedExternalIdsAreRecognizedWithoutAcceptingContainerIds() { - assertTrue(AssInput.isExternalId(AssInput.EXTERNAL_ID_PREFIX + "0")); - assertTrue(AssInput.isExternalId("1:" + AssInput.EXTERNAL_ID_PREFIX + "0")); - assertTrue(AssInput.isExternalId("0:2:" + AssInput.EXTERNAL_ID_PREFIX + "12")); - assertFalse(AssInput.isExternalId(null)); - assertFalse(AssInput.isExternalId("1:2")); - assertFalse(AssInput.isExternalId("other:" + AssInput.EXTERNAL_ID_PREFIX + "0")); - assertFalse(AssInput.isExternalId("1::" + AssInput.EXTERNAL_ID_PREFIX + "0")); - assertFalse(AssInput.isExternalId(AssInput.EXTERNAL_ID_PREFIX)); - } - - @Test public void utf16IsDecodedBeforeTerminalNulHandling() { - byte[] text = (SCRIPT + "\0\0").getBytes(StandardCharsets.UTF_16LE); - byte[] bytes = new byte[text.length + 2]; - bytes[0] = (byte) 0xff; bytes[1] = (byte) 0xfe; - System.arraycopy(text, 0, bytes, 2, text.length); - assertEquals(SCRIPT, new String(AssInput.normalize(bytes), StandardCharsets.UTF_8)); - } - - @Test public void legacyChineseEncodingKeepsWholeDialogue() { - // The existing Media3 detector needs representative text; short legacy - // samples are ambiguous (the former fixture also detects as KOI8-R). - String chinese = "[Script Info]\nTitle: 字幕\n[Events]\n" - + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - + "Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,," - + "这是用于检测字幕字符集的中文文本,这是用于检测字幕字符集的中文文本。正文,逗号保留。\n"; - assertEquals(chinese, new String(AssInput.normalize(chinese.getBytes(Charset.forName("GB18030"))), StandardCharsets.UTF_8)); - } - - @Test public void subtitleDelayAndRendererOffsetAreSubtractedOnceWithFloorRounding() { - assertEquals(1499, AssInput.timeMs(1_002_499_999L, 1_000_000_000L, 1_000_000L)); - assertEquals(-1, AssInput.timeMs(999_999, 1_000_000, 0)); - assertEquals(2500, AssInput.timeMs(2_000_000, 0, -500_000)); - assertThrows(ArithmeticException.class, () -> AssInput.timeMs(Long.MIN_VALUE, 1, 0)); - } - - @Test public void oversizedAndContainerInputsAreRejectedBeforeNative() { - assertThrows(IllegalArgumentException.class, () -> AssInput.copy(ByteBuffer.allocate(AssInput.MAX_INPUT_BYTES + 1))); - assertThrows(IllegalArgumentException.class, () -> AssInput.normalize("Dialogue: 0,0,Default,text".getBytes(StandardCharsets.UTF_8))); - assertThrows(IllegalArgumentException.class, () -> AssInput.normalize((SCRIPT + "[Fonts]\nfontname:test").getBytes(StandardCharsets.UTF_8))); - assertThrows(IllegalArgumentException.class, () -> AssInput.normalize((SCRIPT + "\0more").getBytes(StandardCharsets.UTF_8))); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ass/AssPacketInputTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ass/AssPacketInputTest.java deleted file mode 100644 index afb849d2d5..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ass/AssPacketInputTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.fongmi.android.tv.player.exo.ass; - -import org.junit.Test; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.List; -import static org.junit.Assert.*; - -public class AssPacketInputTest { - private static final long OFFSET = 1_000_000_000_000L; - private static ByteBuffer sample(int order, String duration, String text) { - return ByteBuffer.wrap(("Dialogue: 0:00:00:00," + duration + "," + order - + ",2,OP_CN,,0,0,0,," + text).getBytes(StandardCharsets.UTF_8)); - } - - @Test public void identifiesOnlyThePublishedMatroskaSampleContract() { - byte[] header = "[Script Info]\n[Events]\n".getBytes(StandardCharsets.UTF_8); - assertTrue(AssPacketInput.matches(List.of(AssPacketInput.FORMAT.clone(), header))); - assertFalse(AssPacketInput.matches(List.of(header))); - assertFalse(AssPacketInput.matches(List.of(header, header))); - assertFalse(AssPacketInput.matches(List.of(AssPacketInput.FORMAT, new byte[0]))); - assertFalse(AssPacketInput.matches(List.of(AssPacketInput.FORMAT, header, header))); - } - - @Test public void preservesOriginalReadOrderLayerUtf8OverridesAndTextCommas() { - AssPacketInput input = new AssPacketInput(); - String text = "{\\fnWEOYTLVC\\k20\\blur2}中文,Text,保留"; - ByteBuffer sample = sample(42, "0:00:01:25", text); - assertTrue(input.add(sample.asReadOnlyBuffer(), OFFSET + 1_234_999, OFFSET)); - assertEquals(0, sample.position()); - AssPacketInput.Packet packet = input.after(0).get(0); - assertEquals(1234, packet.startMs()); - assertEquals(1250, packet.durationMs()); - assertEquals("42,2,OP_CN,,0,0,0,," + text, new String(packet.data(), StandardCharsets.UTF_8)); - assertEquals(packet.data().length, input.bytes()); - } - - @Test public void retainsOrderedPacketsAcrossSeekAndDeduplicatesOriginalReadOrder() { - AssPacketInput input = new AssPacketInput(); - assertTrue(input.add(sample(42, "0:00:10:00", "long"), OFFSET, OFFSET)); - assertTrue(input.add(sample(7, "0:00:01:00", "overlap"), OFFSET + 2_000_000, OFFSET)); - List beforeSeek = input.after(0); - assertFalse(input.add(sample(42, "0:00:10:00", "long"), OFFSET, OFFSET)); - assertEquals(2, input.size()); - assertTrue(input.after(2).isEmpty()); - assertTrue(input.add(sample(8, "0:00:01:00", "new"), OFFSET + 3_000_000, OFFSET)); - assertEquals(2, beforeSeek.size()); - assertEquals(1, input.after(2).size()); - assertEquals(3, input.after(0).size()); - assertEquals(0, new AssPacketInput().size()); - } - - @Test public void handlesRendererOffsetWithoutApplyingSubtitleDelayToPackets() { - AssPacketInput input = new AssPacketInput(); - input.add(sample(0, "1:02:03.45", "negative preroll"), OFFSET - 1, OFFSET); - assertEquals(-1, input.after(0).get(0).startMs()); - assertEquals(3_723_450, input.after(0).get(0).durationMs()); - assertEquals(500, AssInput.timeMs(OFFSET + 1_000_000, OFFSET, 500_000)); - assertThrows(ArithmeticException.class, - () -> new AssPacketInput().add(sample(1, "0:00:01:00", "x"), Long.MIN_VALUE, 1)); - } - - @Test public void rejectsMalformedPrefixDurationOrderAndOversizedPackets() { - assertThrows(IllegalArgumentException.class, () -> new AssPacketInput().add( - ByteBuffer.wrap("Dialogue: invalid".getBytes(StandardCharsets.UTF_8)), 0, 0)); - assertThrows(IllegalArgumentException.class, () -> new AssPacketInput().add(sample(0, "0:60:00:00", "x"), 0, 0)); - assertThrows(IllegalArgumentException.class, () -> new AssPacketInput().add(sample(-1, "0:00:01:00", "x"), 0, 0)); - assertThrows(IllegalArgumentException.class, () -> new AssPacketInput().add( - ByteBuffer.allocate(AssInput.MAX_INPUT_BYTES + 1), 0, 0)); - } - - @Test public void capsDistinctEventsButStillAcceptsDuplicatePrerollAtTheLimit() { - AssPacketInput input = new AssPacketInput(); - for (int i = 0; i < AssInput.MAX_EVENTS; i++) input.add(sample(i, "0:00:01:00", "x"), i * 1000L, 0); - assertFalse(input.add(sample(0, "0:00:01:00", "x"), 0, 0)); - assertThrows(IllegalArgumentException.class, - () -> input.add(sample(AssInput.MAX_EVENTS, "0:00:01:00", "x"), 0, 0)); - assertEquals(AssInput.MAX_EVENTS, input.size()); - } - - @Test public void capsRetainedBytesBeforeAddingTheNextPacket() { - AssPacketInput input = new AssPacketInput(); - String text = "x".repeat(AssInput.MAX_INPUT_BYTES - 100); - input.add(sample(0, "0:00:01:00", text), 0, 0); - input.add(sample(1, "0:00:01:00", text), 0, 0); - int bytes = input.bytes(); - assertThrows(IllegalArgumentException.class, () -> input.add(sample(2, "0:00:01:00", text), 0, 0)); - assertEquals(bytes, input.bytes()); - assertEquals(2, input.size()); - } - - @Test public void codecPrivateAllowsLegacyMissingEventsButNeverPreloadsDialogue() { - byte[] header = "[Script Info]\nScriptType: v4.00+\n[V4+ Styles]\n".getBytes(StandardCharsets.UTF_8); - assertArrayEquals(header, AssInput.normalizeHeader(header)); - assertThrows(IllegalArgumentException.class, () -> AssInput.normalize(header)); - byte[] full = (new String(header, StandardCharsets.UTF_8) - + "[Events]\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,x\n").getBytes(StandardCharsets.UTF_8); - assertThrows(IllegalArgumentException.class, () -> AssInput.normalizeHeader(full)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/extractor/MpdSanitizerMatcherMethodContractTest.java b/app/src/test/java/com/fongmi/android/tv/player/extractor/MpdSanitizerMatcherMethodContractTest.java deleted file mode 100644 index c61e881f29..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/extractor/MpdSanitizerMatcherMethodContractTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.fongmi.android.tv.player.extractor; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Android 7-13 (API < 34) contract for MPD sanitizing. - * - *

{@code Matcher#appendReplacement/appendTail(StringBuilder, String)} only exists since API 34, - * and {@code desugar_jdk_libs_nio} does not back-port that overload, so those calls threw - * {@code NoSuchMethodError} on API 24-33 and {@code Source.fetch()} silently fell back to the raw - * URL. The whole pass must therefore keep using the API 1 {@code StringBuffer} overloads. - */ -public class MpdSanitizerMatcherMethodContractTest { - - private static final Pattern APPEND_REPLACEMENT = Pattern.compile("\\.appendReplacement\\s*\\(\\s*([A-Za-z_$][\\w$]*)\\s*,"); - private static final Pattern APPEND_TAIL = Pattern.compile("\\.appendTail\\s*\\(\\s*([A-Za-z_$][\\w$]*)\\s*\\)"); - - @Test - public void everyMatcherAppendUsesTheApi1StringBufferOverload() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/player/extractor/MpdSanitizer.java"); - List targets = appendTargets(source); - assertEquals("the sanitizer must keep all five replacement calls", 5, occurrences(source, ".appendReplacement(")); - assertEquals("the sanitizer must keep all five tail calls", 5, occurrences(source, ".appendTail(")); - assertTrue("the sanitizer must keep its matcher appends", targets.size() >= 2); - - StringBuilder stringBuilderTyped = new StringBuilder(); - for (String variable : targets) { - Matcher declaration = Pattern - .compile("\\b(StringBuilder|StringBuffer)\\s+" + Pattern.quote(variable) + "\\b") - .matcher(source); - assertTrue("every matcher target must be declared:" + variable, declaration.find()); - if ("StringBuilder".equals(declaration.group(1))) stringBuilderTyped.append(variable).append(' '); - } - assertFalse("no matcher append may target a StringBuilder (API 34 overload): " + stringBuilderTyped, - stringBuilderTyped.length() > 0); - assertTrue("the source must document why the StringBuffer overload is required", - source.contains("StringBuilder 重载需要 API 34")); - } - - @Test - public void theApi1StringBufferOverloadStillPerformsTheReplacement() { - StringBuffer buffer = new StringBuffer(); - Matcher matcher = Pattern.compile("([0-9]+)").matcher("a1b2c"); - while (matcher.find()) matcher.appendReplacement(buffer, "#"); - matcher.appendTail(buffer); - assertEquals("the API 1 StringBuffer overload must still rewrite the matches", "a#b#c", buffer.toString()); - } - - private static int occurrences(String source, String needle) { - int count = 0; - int from = source.indexOf(needle); - while (from >= 0) { - count++; - from = source.indexOf(needle, from + needle.length()); - } - return count; - } - - private static List appendTargets(String source) { - List targets = new ArrayList<>(); - for (Pattern pattern : new Pattern[]{APPEND_REPLACEMENT, APPEND_TAIL}) { - Matcher matcher = pattern.matcher(source); - while (matcher.find()) { - if (!targets.contains(matcher.group(1))) targets.add(matcher.group(1)); - } - } - return targets; - } - - private static String read(String name) throws Exception { - Path path = Path.of(name); - if (!Files.exists(path)) path = Path.of("..").resolve(name).normalize(); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvAutoOutputPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvAutoOutputPolicyTest.java index 8ede786c3d..6c9938df50 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvAutoOutputPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvAutoOutputPolicyTest.java @@ -8,24 +8,6 @@ import org.junit.Test; public class MpvAutoOutputPolicyTest { - @Test - public void failedDirectStaysOnGpuAcrossSameItemEvaluations() { - MpvAutoOutputPolicy.Decision tv = MpvAutoOutputPolicy.evaluate(1920, 1080, true, true, false, false); - for (int rebuild = 0; rebuild < 3; rebuild++) { - MpvAutoOutputPolicy.Decision safe = MpvAutoOutputPolicy.afterSurfaceFailure(tv, true); - assertFalse(safe.eligible()); - assertEquals("surface-direct-failed-for-item", safe.reason()); - assertEquals(MpvAutoOutputPolicy.Transition.KEEP_GPU, - MpvAutoOutputPolicy.transition(safe.eligible(), false)); - assertEquals(MpvAutoOutputPolicy.Transition.LEAVE_SURFACE_DIRECT, - MpvAutoOutputPolicy.transition(safe.eligible(), true)); - } - // New items/explicit settings changes clear the failure in PlayerManager. - assertTrue(MpvAutoOutputPolicy.afterSurfaceFailure(tv, false).eligible()); - MpvAutoOutputPolicy.Decision mobile = MpvAutoOutputPolicy.evaluate(1920, 1080, true, false, false, false); - assertEquals(mobile, MpvAutoOutputPolicy.afterSurfaceFailure(mobile, false)); - } - @Test public void acceptsTvHardwareDecodeAtAnyResolution() { assertTrue(MpvAutoOutputPolicy.evaluate(3840, 1632, true, true, false, false).eligible()); @@ -72,38 +54,6 @@ public class MpvAutoOutputPolicyTest { MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, 7, false).eligible()); } - @Test - public void usesHevcHdr10ForUnsupportedProfile8Only() { - MpvAutoOutputPolicy.Decision decision = MpvAutoOutputPolicy.evaluate( - 3840, 2160, true, true, false, false, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, 8, false, - MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED); - assertTrue(decision.eligible()); - assertEquals("dv8-hdr10-base-layer", decision.reason()); - } - - @Test - public void keepsProfile8SoftwareWhenHevcCapabilityIsUnknownOrUnsupported() { - assertFalse(MpvAutoOutputPolicy.evaluate( - 3840, 2160, true, true, false, false, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, 8, false, - MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN).eligible()); - assertFalse(MpvAutoOutputPolicy.evaluate( - 3840, 2160, true, true, false, false, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, 8, false, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED).eligible()); - } - - @Test - public void nativeProfile8AlwaysWinsOverHdr10Fallback() { - MpvAutoOutputPolicy.Decision decision = MpvAutoOutputPolicy.evaluate( - 3840, 2160, true, true, false, false, - MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED, 8, false, - MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED); - assertTrue(decision.eligible()); - assertEquals("dolby-vision-hw-supported", decision.reason()); - } - @Test public void evaluatesFourKBeforeTracksAreComplete() { assertTrue(MpvAutoOutputPolicy.canEvaluateWithoutTracks(3840, 1606)); @@ -115,26 +65,6 @@ public class MpvAutoOutputPolicyTest { assertTrue(MpvAutoOutputPolicy.canEvaluateWithoutTracks(3840, 2160)); } - @Test - public void revealsSuccessfulDirectFrameBeforeTrackMetadataCompletes() { - assertTrue(MpvAutoOutputPolicy.canRevealDirectFrame( - true, false, true, true, 3840, 2160)); - } - - @Test - public void keepsShutterForUnprovenOrNonDirectOutput() { - assertFalse(MpvAutoOutputPolicy.canRevealDirectFrame( - true, false, false, true, 3840, 2160)); - assertFalse(MpvAutoOutputPolicy.canRevealDirectFrame( - true, false, true, false, 3840, 2160)); - assertFalse(MpvAutoOutputPolicy.canRevealDirectFrame( - true, false, true, true, 0, 2160)); - assertFalse(MpvAutoOutputPolicy.canRevealDirectFrame( - true, true, true, true, 3840, 2160)); - assertFalse(MpvAutoOutputPolicy.canRevealDirectFrame( - false, false, true, true, 3840, 2160)); - } - @Test public void subtitlesUseDirectOutputOverlay() { assertTrue(MpvAutoOutputPolicy.evaluate(3840, 2160, true, true, false, false).eligible()); diff --git a/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvAutoRenderPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvAutoRenderPolicyTest.java deleted file mode 100644 index 4c91c1e107..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvAutoRenderPolicyTest.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.fongmi.android.tv.player.mpv; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -public class MpvAutoRenderPolicyTest { - - @Test - public void enablesVulkanForUnsupportedNativeDv5() { - assertDecision(MpvAutoRenderPolicy.Action.ENABLE_VULKAN, - "dv5-gpu-mapping-vulkan", true, true, 5, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - true, true, false, false); - } - - @Test - public void preservesManualRenderOverride() { - assertDecision(MpvAutoRenderPolicy.Action.KEEP, "render-overridden", - false, true, 5, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - true, true, false, false); - } - - @Test - public void preservesNativeOrUnknownDolbyVisionRouting() { - assertDecision(MpvAutoRenderPolicy.Action.KEEP, "native-dv5-supported", - true, true, 5, - MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED, - true, true, false, false); - assertDecision(MpvAutoRenderPolicy.Action.KEEP, "native-dv5-unknown", - true, true, 5, - MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN, - true, true, false, false); - } - - @Test - public void preservesNonDv5AndSoftwareDecode() { - assertDecision(MpvAutoRenderPolicy.Action.KEEP, "not-dv5", - true, true, 7, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - true, true, false, false); - assertDecision(MpvAutoRenderPolicy.Action.KEEP, "software-decode", - true, false, 5, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - true, true, false, false); - } - - @Test - public void requiresBothVulkanCapabilities() { - assertDecision(MpvAutoRenderPolicy.Action.KEEP, "vulkan-unavailable", - true, true, 5, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - false, true, false, false); - assertDecision(MpvAutoRenderPolicy.Action.KEEP, "vulkan-unavailable", - true, true, 5, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - true, false, false, false); - } - - @Test - public void doesNotRebuildAnActiveOrFailedItem() { - assertDecision(MpvAutoRenderPolicy.Action.KEEP, "already-vulkan", - true, true, 5, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - true, true, true, false); - assertDecision(MpvAutoRenderPolicy.Action.KEEP, "item-fallback", - true, true, 5, - MpvAutoOutputPolicy.DolbyVisionSupport.UNSUPPORTED, - true, true, false, true); - } - - @Test - public void manualFelUsesVulkanWithoutRequiringANativeDolbyDecoder() { - for (MpvAutoOutputPolicy.DolbyVisionSupport support - : MpvAutoOutputPolicy.DolbyVisionSupport.values()) { - MpvAutoRenderPolicy.Decision decision = MpvAutoRenderPolicy.evaluate( - true, true, 7, support, true, true, false, false, true); - assertEquals(MpvAutoRenderPolicy.Action.ENABLE_VULKAN, decision.action()); - assertEquals("dv7-fel-reconstruction-vulkan", decision.reason()); - } - } - - @Test - public void felStillHonorsManualRenderAndOneShotFallback() { - assertEquals(MpvAutoRenderPolicy.Action.KEEP, MpvAutoRenderPolicy.evaluate( - false, true, 7, MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN, - true, true, false, false, true).action()); - assertEquals(MpvAutoRenderPolicy.Action.KEEP, MpvAutoRenderPolicy.evaluate( - true, true, 7, MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN, - true, true, false, true, true).action()); - assertEquals(MpvAutoRenderPolicy.Action.KEEP, MpvAutoRenderPolicy.evaluate( - true, true, 7, MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN, - true, false, false, false, true).action()); - assertEquals(MpvAutoRenderPolicy.Action.KEEP, MpvAutoRenderPolicy.evaluate( - true, true, -1, MpvAutoOutputPolicy.DolbyVisionSupport.UNKNOWN, - true, true, false, false, true).action()); - } - - private static void assertDecision(MpvAutoRenderPolicy.Action action, - String reason, - boolean renderAutomatic, - boolean hardDecode, - int profile, - MpvAutoOutputPolicy.DolbyVisionSupport support, - boolean nativeVulkan, - boolean deviceVulkan, - boolean currentlyVulkan, - boolean disabledForItem) { - MpvAutoRenderPolicy.Decision decision = MpvAutoRenderPolicy.evaluate( - renderAutomatic, hardDecode, profile, support, - nativeVulkan, deviceVulkan, currentlyVulkan, disabledForItem); - assertEquals(action, decision.action()); - assertEquals(reason, decision.reason()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvConfigStoreTest.java b/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvConfigStoreTest.java index 3aba51c453..3bf8b705ef 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvConfigStoreTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvConfigStoreTest.java @@ -2,227 +2,15 @@ package com.fongmi.android.tv.player.mpv; import org.junit.Test; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; import java.util.List; -import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeTrue; public class MpvConfigStoreTest { - @Test - public void newAndLegacyScriptsDefaultToEnabledIndependentlyOfButton() { - assertTrue(new MpvConfigStore.CustomButton().scriptEnabled); - assertTrue(new MpvConfigStore.ConfigProfile().scriptEnabled); - for (String extra : Arrays.asList("", ",\"scriptEnabled\":null")) { - List buttons = MpvConfigStore.parseCustomButtonsJson( - "[{\"id\":\"old\",\"title\":\"Old script\",\"enabled\":false,\"trigger\":\"startup\"" + extra + "}]"); - assertEquals(1, buttons.size()); - assertTrue(buttons.get(0).scriptEnabled); - assertFalse(buttons.get(0).enabled); - assertEquals("startup", buttons.get(0).trigger); - } - } - - @Test - public void scriptEnabledRoundTripsWithoutChangingButtonTimingOrSource() { - for (boolean buttonEnabled : Arrays.asList(false, true)) { - for (String trigger : Arrays.asList("click", "long", "startup")) { - MpvConfigStore.CustomButton original = scriptButton("saved", trigger, buttonEnabled); - original.content = "short_source()"; - original.longPressContent = "long_source()"; - original.onStartup = "startup_source()"; - original.scriptEnabled = false; - String json = MpvConfigStore.serializeCustomButtons(Collections.singletonList(original)); - MpvConfigStore.CustomButton restored = MpvConfigStore.parseCustomButtonsJson(json).get(0); - assertTrue(json.contains("\"scriptEnabled\":false")); - assertFalse(restored.scriptEnabled); - assertEquals(buttonEnabled, restored.enabled); - assertEquals(trigger, restored.trigger); - assertEquals(original.script, restored.script); - assertEquals(original.content, restored.content); - assertEquals(original.longPressContent, restored.longPressContent); - assertEquals(original.onStartup, restored.onStartup); - restored.scriptEnabled = true; - MpvConfigStore.CustomButton reenabled = MpvConfigStore.parseCustomButtonsJson( - MpvConfigStore.serializeCustomButtons(Collections.singletonList(restored))).get(0); - assertTrue(reenabled.scriptEnabled); - assertEquals(buttonEnabled, reenabled.enabled); - assertEquals(trigger, reenabled.trigger); - } - } - } - - @Test - public void disabledScriptNeverLoadsForAnyTriggerOrButtonVisibility() { - for (String trigger : Arrays.asList("click", "long", "startup")) { - for (boolean buttonEnabled : Arrays.asList(false, true)) { - MpvConfigStore.CustomButton button = scriptButton("disabled", trigger, buttonEnabled); - button.scriptEnabled = false; - assertEquals("", MpvConfigStore.buildCustomButtonScript(Collections.singletonList(button), script -> { - throw new AssertionError("Disabled script must not even be read"); - })); - assertFalse(button.isButtonVisible()); - button.scriptEnabled = true; - String enabledLua = MpvConfigStore.buildCustomButtonScript(Collections.singletonList(button), script -> "restored_action()"); - assertTrue(enabledLua.contains("restored_action()")); - assertEquals(trigger, button.trigger); - assertEquals(buttonEnabled, button.isButtonVisible()); - } - } - } - - @Test - public void scriptWithoutButtonAlwaysUsesStartup() { - for (String trigger : Arrays.asList(null, "", "click", "long", "startup", "unknown")) { - assertEquals("startup", MpvConfigStore.normalizeScriptTrigger(false, trigger)); - } - } - - @Test - public void enabledScriptPreservesSavedTriggerAndDefaultsToClick() { - assertEquals("click", MpvConfigStore.normalizeScriptTrigger(true, null)); - assertEquals("click", MpvConfigStore.normalizeScriptTrigger(true, "unknown")); - for (String trigger : Arrays.asList("click", "long", "startup")) { - assertEquals(trigger, MpvConfigStore.normalizeScriptTrigger(true, trigger)); - } - } - - @Test - public void startupAndClickShareOneHandlerAndOneCopyOfScriptContent() { - MpvConfigStore.CustomButton button = scriptButton("startup", "startup", true); - button.onStartup = "stale_metadata()"; - String lua = MpvConfigStore.buildCustomButtonScript(Collections.singletonList(button), script -> "toggle_script()"); - - assertTrue(lua.contains("buttons[\"startup\"].short = function()\ntoggle_script()\nend\n")); - assertTrue(lua.contains("if run(buttons[\"startup\"].short) then webhtv_toggle_button_state(\"startup\") end\n")); - assertEquals(lua.indexOf("toggle_script()"), lua.lastIndexOf("toggle_script()")); - assertFalse(lua.contains("stale_metadata")); - assertFalse(lua.contains(".long =")); - } - - @Test - public void disabledScriptRunsOnceWithoutExposingButtonHandler() { - String lua = MpvConfigStore.buildCustomButtonScript( - Collections.singletonList(scriptButton("hidden", "long", false)), script -> "hidden_startup()"); - - assertTrue(lua.contains("run(function()\nhidden_startup()\nend)")); - assertFalse(lua.contains("buttons[\"hidden\"]")); - } - - @Test - public void clickAndLongScriptsDoNotRunOnStartup() { - List buttons = Arrays.asList( - scriptButton("click", "click", true), scriptButton("hold", "long", true)); - String lua = MpvConfigStore.buildCustomButtonScript(buttons, script -> "user_action()"); - - assertTrue(lua.contains("buttons[\"click\"].short = function()")); - assertTrue(lua.contains("buttons[\"hold\"].long = function()")); - assertFalse(lua.contains("buttons[\"click\"].long")); - assertFalse(lua.contains("buttons[\"hold\"].short")); - assertFalse(lua.contains("run(buttons[")); - assertFalse(lua.contains("run(function()")); - } - - @Test - public void missingScriptAndInvalidButtonDoNotDiscardOtherHandlers() { - List buttons = Arrays.asList(null, - scriptButton("bad\"]", "startup", true), scriptButton("missing", "startup", true), - scriptButton("good", "click", true)); - String lua = MpvConfigStore.buildCustomButtonScript(buttons, script -> { - if ("missing.lua".equals(script)) throw new IOException("missing script"); - return "good_action()"; - }); - - assertFalse(lua.contains("bad")); - assertFalse(lua.contains("missing")); - assertTrue(lua.contains("buttons[\"good\"].short")); - assertEquals("", MpvConfigStore.buildCustomButtonScript(Collections.emptyList(), script -> "")); - } - - @Test - public void generatedLuaRunsStartupAndTogglesWithoutDuplicatingState() throws Exception { - String interpreter = System.getenv("WEBHTV_TEST_LUA"); - assumeTrue("Set WEBHTV_TEST_LUA to run the generated bridge with a real Lua interpreter", - interpreter != null && !interpreter.isEmpty()); - List buttons = new ArrayList<>(); - buttons.add(scriptButton("startup", "startup", true)); - buttons.add(scriptButton("hidden", "click", false)); - buttons.add(scriptButton("click", "click", true)); - buttons.add(scriptButton("hold", "long", true)); - buttons.add(scriptButton("broken", "startup", true)); - buttons.add(scriptButton("returns", "startup", true)); - MpvConfigStore.CustomButton legacy = scriptButton("legacy", "", true); - legacy.script = ""; - legacy.onStartup = "local legacy_state = 7"; - legacy.content = "legacy_count = legacy_state; legacy_state = legacy_state + 1"; - legacy.longPressContent = "legacy_long = legacy_state"; - buttons.add(legacy); - MpvConfigStore.CustomButton disabledLegacy = scriptButton("disabled_legacy", "", false); - disabledLegacy.script = ""; - disabledLegacy.onStartup = "disabled_legacy_ran = true"; - buttons.add(disabledLegacy); - for (String trigger : Arrays.asList("click", "long", "startup")) { - MpvConfigStore.CustomButton disabledScript = scriptButton("disabled_script_" + trigger, trigger, true); - disabledScript.scriptEnabled = false; - buttons.add(disabledScript); - } - String lua = MpvConfigStore.buildCustomButtonScript(buttons, script -> switch (script) { - case "startup.lua" -> "startup_count = (startup_count or 0) + 1\n" - + "toggle_state = not toggle_state\n" - + "if toggle_state then live_timers = live_timers + 1 else live_timers = live_timers - 1 end"; - case "hidden.lua" -> "hidden_count = (hidden_count or 0) + 1"; - case "click.lua" -> "click_count = (click_count or 0) + 1"; - case "hold.lua" -> "long_count = (long_count or 0) + 1"; - case "broken.lua" -> "error('expected startup error')"; - case "returns.lua" -> "return_count = (return_count or 0) + 1; return"; - default -> throw new IOException("unexpected script " + script); - }); - Path generated = Files.createTempFile("webhtv-custom-buttons-", ".lua"); - Process process = null; - try { - Files.writeString(generated, lua, StandardCharsets.UTF_8); - Path fixture = Path.of(getClass().getResource("/mpv/custom_button_runtime_test.lua").toURI()); - process = new ProcessBuilder(interpreter, fixture.toString(), generated.toString()) - .redirectErrorStream(true).start(); - assertTrue("Lua runtime test timed out", process.waitFor(10, TimeUnit.SECONDS)); - String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - assertEquals(output, 0, process.exitValue()); - assertTrue(output, output.contains("PASS: startup/click/long/hidden/legacy/error isolation")); - } finally { - if (process != null && process.isAlive()) process.destroyForcibly(); - Files.deleteIfExists(generated); - } - } - - private static MpvConfigStore.CustomButton scriptButton(String id, String trigger, boolean enabled) { - MpvConfigStore.CustomButton button = new MpvConfigStore.CustomButton(); - button.id = id; - button.title = id; - button.script = id + ".lua"; - button.trigger = trigger; - button.enabled = enabled; - return button; - } - - @Test - public void readsTheLastExplicitAImageReaderBackend() { - String config = "android-vulkan-aimagereader-backend=auto\n" - + "# android-vulkan-aimagereader-backend=fragment\n" - + "--android-vulkan-aimagereader-backend=direct # low power\n"; - assertEquals("direct", MpvConfigStore.findOptionValue( - config, "android-vulkan-aimagereader-backend")); - } - @Test public void defaultConfig_allowsNativeAssScaling() { String config = MpvConfigStore.defaultConfig(); diff --git a/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvDirectAudioPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvDirectAudioPolicyTest.java index 797c2d246e..b85b89e408 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvDirectAudioPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvDirectAudioPolicyTest.java @@ -75,18 +75,6 @@ public class MpvDirectAudioPolicyTest { assertEquals("same-language-stereo", selection.reason()); } - @Test - public void keepsMultichannelTrackWhenPcmModeIsSelected() { - MpvDirectAudioPolicy.Selection selection = MpvDirectAudioPolicy.select(List.of( - track("1", "eng", "truehd", "7.1", 8), - track("2", "en-US", "aac", "Stereo", 2)), - "1", "ac3,eac3", true); - - assertFalse(selection.changed()); - assertEquals("1", selection.id()); - assertEquals("current-track-multichannel-pcm", selection.reason()); - } - private static MpvDirectAudioPolicy.Candidate track(String id, String language, String codec, String title, int channels) { diff --git a/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvFelOutputPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvFelOutputPolicyTest.java deleted file mode 100644 index c0b6085234..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/mpv/MpvFelOutputPolicyTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.fongmi.android.tv.player.mpv; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; - -public class MpvFelOutputPolicyTest { - - @Test - public void nativeDv7DoesNotStealAnExplicitFelRequest() { - MpvAutoOutputPolicy.Decision nativeDecision = MpvAutoOutputPolicy.evaluate( - 3840, 2160, true, true, false, false, - MpvAutoOutputPolicy.DolbyVisionSupport.SUPPORTED, 7); - assertTrue(nativeDecision.eligible()); - MpvAutoOutputPolicy.Decision fel = - MpvAutoOutputPolicy.forFelReconstruction(nativeDecision, true); - assertFalse(fel.eligible()); - assertEquals("dv7-fel-reconstruction", fel.reason()); - assertEquals(MpvAutoOutputPolicy.Transition.LEAVE_SURFACE_DIRECT, - MpvAutoOutputPolicy.transition(fel.eligible(), true)); - assertEquals(MpvAutoOutputPolicy.Transition.KEEP_GPU, - MpvAutoOutputPolicy.transition(fel.eligible(), false)); - } - - @Test - public void everyExistingDecisionIsReturnedUnchangedWithoutFel() { - for (boolean tv : new boolean[]{false, true}) { - for (boolean hardware : new boolean[]{false, true}) { - for (int profile : new int[]{-1, 5, 7, 8}) { - for (MpvAutoOutputPolicy.DolbyVisionSupport support - : MpvAutoOutputPolicy.DolbyVisionSupport.values()) { - MpvAutoOutputPolicy.Decision decision = MpvAutoOutputPolicy.evaluate( - 3840, 2160, hardware, tv, false, false, support, profile); - assertSame(decision, - MpvAutoOutputPolicy.forFelReconstruction(decision, false)); - } - } - } - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/player/mpv/PlaybackRecoveryPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/mpv/PlaybackRecoveryPolicyTest.java deleted file mode 100644 index 7ec399e1d8..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/player/mpv/PlaybackRecoveryPolicyTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.fongmi.android.tv.player.mpv; - -import org.junit.Test; -import static org.junit.Assert.*; - -public class PlaybackRecoveryPolicyTest { - private final PlaybackRecoveryPolicy.Target target = - new PlaybackRecoveryPolicy.Target(100, 10123, 900, "com.example.player"); - - @Test public void onlyPersistentForegroundStallsPromptOnce() { - assertFalse(PlaybackRecoveryPolicy.shouldPrompt(8099, 100, true, false)); - assertTrue(PlaybackRecoveryPolicy.shouldPrompt(8100, 100, true, false)); - assertFalse(PlaybackRecoveryPolicy.shouldPrompt(20000, 100, false, false)); - assertFalse(PlaybackRecoveryPolicy.shouldPrompt(20000, 100, true, true)); - assertFalse(PlaybackRecoveryPolicy.shouldPrompt(20000, 0, true, false)); - assertFalse(PlaybackRecoveryPolicy.shouldPrompt(50, 100, true, false)); - } - - @Test public void exactOwnMainProcessMayBeEnded() { - assertTrue(PlaybackRecoveryPolicy.mayTerminate(target, target, 200, 10123, "com.example.player")); - } - - @Test public void reusedPidOtherUidOrOtherProcessCanNeverBeEnded() { - assertFalse(PlaybackRecoveryPolicy.mayTerminate(target, - new PlaybackRecoveryPolicy.Target(100, 10123, 901, "com.example.player"), 200, 10123, "com.example.player")); - assertFalse(PlaybackRecoveryPolicy.mayTerminate(target, target, 200, 9999, "com.example.player")); - assertFalse(PlaybackRecoveryPolicy.mayTerminate(target, target, 200, 10123, "other.package")); - assertFalse(PlaybackRecoveryPolicy.mayTerminate(target, target, 100, 10123, "com.example.player")); - assertFalse(PlaybackRecoveryPolicy.mayTerminate(target, null, 200, 10123, "com.example.player")); - assertFalse(PlaybackRecoveryPolicy.mayTerminate(null, target, 200, 10123, "com.example.player")); - } - - @Test public void unknownProcessGenerationIsNeverAccepted() { - PlaybackRecoveryPolicy.Target unknown = new PlaybackRecoveryPolicy.Target(100, 10123, -1, "com.example.player"); - assertFalse(PlaybackRecoveryPolicy.mayTerminate(unknown, unknown, 200, 10123, "com.example.player")); - } - - @Test public void procStatParserUsesField22NotCommWhitespaceOrParentheses() { - String tail = " S" + " 0".repeat(18) + " 123456 999 888"; - assertEquals(123456, PlaybackRecoveryPolicy.processStartTicks("100 (player (main))" + tail)); - assertEquals(-1, PlaybackRecoveryPolicy.processStartTicks("100 broken")); - assertEquals(-1, PlaybackRecoveryPolicy.processStartTicks("100 (p) S 1")); - assertEquals(-1, PlaybackRecoveryPolicy.processStartTicks("100 (p)" + tail.replace("123456", "bad"))); - } - - @Test public void procStatusUsesRealUidWithTabsOrSpaces() { - assertEquals(10123, PlaybackRecoveryPolicy.processUid("Name:\tplayer\nUid:\t10123\t10123\t10123\t10123\nGid:\t99")); - assertEquals(10123, PlaybackRecoveryPolicy.processUid("Uid: 10123 10124 10125 10126\n")); - assertEquals(0, PlaybackRecoveryPolicy.processUid("Uid:\t0\t0\t0\t0\n")); - } - - @Test public void missingOrTruncatedUidIsNotAProcessIdentity() { - assertEquals(-1, PlaybackRecoveryPolicy.processUid(null)); - assertEquals(-1, PlaybackRecoveryPolicy.processUid("")); - assertEquals(-1, PlaybackRecoveryPolicy.processUid("UidOther: 10123 10123 10123 10123\n")); - assertEquals(-1, PlaybackRecoveryPolicy.processUid("Uid: 10123 10123 10123\n")); - assertEquals(-1, PlaybackRecoveryPolicy.processUid("Uid:\n")); - } - - @Test public void invalidUidNumbersFailClosed() { - assertEquals(-1, PlaybackRecoveryPolicy.processUid("Uid: -1 10123 10123 10123")); - assertEquals(-1, PlaybackRecoveryPolicy.processUid("Uid: +10123 10123 10123 10123")); - assertEquals(-1, PlaybackRecoveryPolicy.processUid("Uid: 4294967295 10123 10123 10123")); - assertEquals(-1, PlaybackRecoveryPolicy.processUid("Uid: 10123 bad 10123 10123")); - assertEquals(-1, PlaybackRecoveryPolicy.processUid("Uid: 10123 10123 10123 10123 extra")); - } - - @Test public void duplicateUidFieldsFailClosed() { - assertEquals(-1, PlaybackRecoveryPolicy.processUid("Uid: 10123 10123 10123 10123\nUid: 10123 10123 10123 10123")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/ExamplePluginManifestTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/ExamplePluginManifestTest.java deleted file mode 100644 index efe598844e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/ExamplePluginManifestTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; - -/** - * 示例清单与 SDK README 的一致性测试。 - * - *

示例插件的价值在于"照着它能写出能跑的东西"。如果清单与 README 描述的字段漂移, - * 示例就会静默失效——这类坏掉很难靠人工发现,所以用测试钉住。 - */ -public class ExamplePluginManifestTest { - - private static final String EXAMPLE_MANIFEST = "apps/plugin/example/manifest.json"; - private static final String README = "assets/plugin/README.md"; - - @Test - public void exampleManifestParsesWithCurrentSchema() throws IOException { - String json = readAsset("plugin/example/manifest.json"); - assertNotNull("example manifest must stay parseable", PluginManifest.parse(json)); - } - - @Test - public void exampleManifestDeclaresFloatingSlotAndEvents() throws IOException { - PluginManifest manifest = PluginManifest.parse(readAsset("plugin/example/manifest.json")); - assertNotNull(manifest); - assertTrue("example should keep at least one event", !manifest.getEvents().isEmpty()); - assertTrue("example should keep at least one ui slot", !manifest.getUiSlots().isEmpty()); - assertTrue("rationale: floating is the phone entry point", - manifest.getPermissions().contains(PluginCapabilityRegistry.PERMISSION_UI_FLOATING)); - } - - @Test - public void readmeDocumentsEveryKnownPermission() throws IOException { - String readme = readAsset("plugin/README.md"); - for (String permission : PluginCapabilityRegistry.knownPermissions()) { - assertTrue("README must document permission: " + permission, readme.contains(permission)); - } - } - - @Test - public void readmeDocumentsEveryBusEvent() throws IOException { - String readme = readAsset("plugin/README.md"); - for (String event : new String[]{ - PluginEventBus.EVENT_APP_FOREGROUND, PluginEventBus.EVENT_APP_BACKGROUND, - PluginEventBus.EVENT_ACTIVITY_CREATED, PluginEventBus.EVENT_ACTIVITY_DESTROYED, - PluginEventBus.EVENT_UI_CONTENT_SHOWN}) { - assertTrue("README must document event: " + event, readme.contains(event)); - } - } - - /** Gradle 单测工作目录是模块根(app/),但也兼容仓库根,故两处都试。 */ - private static String readAsset(String path) throws IOException { - File file = new File("src/main/assets/" + path); - if (!file.exists()) file = new File("app/src/main/assets/" + path); - if (!file.exists()) throw new IOException("asset not found: " + path + " (cwd=" + new File(".").getAbsolutePath() + ")"); - return Files.readString(file.toPath(), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginAuditLogTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginAuditLogTest.java deleted file mode 100644 index 945cd32ec7..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginAuditLogTest.java +++ /dev/null @@ -1,498 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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.Before; -import org.junit.Test; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -/** - * 审计日志测试。重点是脱敏:**query 绝不能进日志**,因为 D2 放弃了域名白名单, - * 这份记录是唯一能追到"谁把数据发去了哪"的证据链。 - */ -public class PluginAuditLogTest { - - /** 内存后端:JVM 单测没有 {@code Init.context()},真实文件后端在这里不可用。 */ - private static final class MemorySink implements PluginAuditStore.Sink { - private String text = ""; - - @Override - public String read() { - return text; - } - - @Override - public void write(String text) { - this.text = text == null ? "" : text; - } - } - - /** - * 记录"每次写入发生在哪条线程、写了什么"的计数后端。 - * - *

主线程改造要防的正是"记账线程自己写了文件",所以线程名和内容必须一起记下来: - * 只数次数证明不了写盘没发生在调用方线程上。

- */ - private static final class CountingSink implements PluginAuditStore.Sink { - private final List texts = Collections.synchronizedList(new ArrayList<>()); - private final List threads = Collections.synchronizedList(new ArrayList<>()); - - @Override - public String read() { - return texts.isEmpty() ? "" : texts.get(texts.size() - 1); - } - - @Override - public void write(String text) { - texts.add(text == null ? "" : text); - threads.add(Thread.currentThread().getName()); - } - - String last() { - return texts.isEmpty() ? "" : texts.get(texts.size() - 1); - } - - int writes() { - return texts.size(); - } - } - - /** - * 可阻塞的计数后端:把"写入器正在写"变成可确定观察的状态。 - * - *

核心断言是"记账调用自己没碰文件"。只靠时间窗去猜必然会 flaky——写入器完全可能在 - * 断言执行之前就写完了。这里把写入器钉在 write() 里,计数就不可能在中途变化。

- */ - private static final class BlockingSink implements PluginAuditStore.Sink { - private final CountDownLatch entered = new CountDownLatch(1); - private final CountDownLatch proceed = new CountDownLatch(1); - private final List texts = Collections.synchronizedList(new ArrayList<>()); - private final List threads = Collections.synchronizedList(new ArrayList<>()); - /** 已"写完"的次数(真正走完 write()),用来与"进到 write() 里被钉住"区分开。 */ - private final int[] completed = new int[1]; - - @Override - public String read() { - return texts.isEmpty() ? "" : texts.get(texts.size() - 1); - } - - @Override - public void write(String text) { - texts.add(text == null ? "" : text); - threads.add(Thread.currentThread().getName()); - entered.countDown(); - try { - proceed.await(5, TimeUnit.SECONDS); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } - // 只有真正写完才算一次完成的写入;"已经进来但还卡着"必须与"写完了"区分开, - // 否则断言分不清"调用方自己写了"和"写入器正在写"。 - synchronized (completed) { - completed[0]++; - } - } - - int completedWrites() { - synchronized (completed) { - return completed[0]; - } - } - - boolean awaitEntered() throws InterruptedException { - return entered.await(5, TimeUnit.SECONDS); - } - - void proceed() { - proceed.countDown(); - } - - int writes() { - return texts.size(); - } - - String last() { - return texts.isEmpty() ? "" : texts.get(texts.size() - 1); - } - - String lastThread() { - return threads.isEmpty() ? "" : threads.get(threads.size() - 1); - } - } - - private PluginAuditLog log; - - @Before - public void setUp() { - log = PluginAuditLog.get(); - // 每条用例都从"真实主线程判定"和"空日志"开始,避免上一条用例留下的强制异步开关。 - PluginAuditLog.forceAsyncWriterForTest(null); - log.clear(); - } - - @Test - public void recordsRequestWithMaskedUrl() { - log.recordRequest("douban.comments", "1.0.0", PluginEventBus.EVENT_UI_CONTENT_SHOWN, - "net.request", "https://m.douban.com/rexxar/api/v2/movie/123/comments?start=0&token=SECRET#frag", - 200, 123L, "ok"); - - List entries = log.entries(); - assertEquals(1, entries.size()); - PluginAuditLog.Entry entry = entries.get(0); - assertEquals("douban.comments", entry.pluginId()); - assertEquals("1.0.0", entry.pluginVersion()); - assertEquals("m.douban.com", entry.host()); - assertEquals("/rexxar/api/v2/movie/123/comments", entry.path()); - assertEquals(200, entry.status()); - assertEquals("ok", entry.outcome()); - assertEquals("request", entry.kind()); - assertFalse(entry.path().contains("SECRET")); - } - - @Test - public void queryIsNeverRetained() { - PluginAuditLog.Target target = PluginAuditLog.target("https://example.com/a/b?api_key=SECRET&x=1"); - assertEquals("example.com", target.host()); - assertEquals("/a/b", target.path()); - assertFalse(target.path().contains("SECRET")); - assertFalse(target.path().contains("api_key")); - } - - @Test - public void malformedUrlsDoNotThrow() { - assertEquals("", PluginAuditLog.target(null).host()); - assertEquals("", PluginAuditLog.target("").host()); - assertEquals("", PluginAuditLog.target(" ").host()); - // 无 scheme、无 path - assertEquals("example.com", PluginAuditLog.target("example.com").host()); - assertEquals("/", PluginAuditLog.target("example.com").path()); - // 只有 scheme - assertEquals("", PluginAuditLog.target("https://").host()); - } - - @Test - public void uuidSizedPathsAreTruncatedNotDropped() { - String longPath = "/" + "a".repeat(1000); - PluginAuditLog.Target target = PluginAuditLog.target("https://example.com" + longPath); - assertEquals("example.com", target.host()); - assertTrue(target.path().length() <= 256); - assertTrue(target.path().startsWith("/aaa")); - } - - @Test - public void entriesAreBoundedAndDropOldest() { - for (int i = 0; i < PluginAuditLog.MAX_ENTRIES + 25; i++) { - log.recordRequest("p", "1", null, "net.request", "https://example.com/" + i, 200, 1L, "ok"); - } - assertEquals(PluginAuditLog.MAX_ENTRIES, log.size()); - // 最旧的已被丢弃:第一条应该是 index=25 - assertTrue(log.entries().get(0).path().endsWith("/25")); - } - - @Test - public void entriesForIsolatesOnePlugin() { - log.recordRequest("a", "1", null, "net.request", "https://a.com/x", 200, 1L, "ok"); - log.recordRequest("b", "1", null, "net.request", "https://b.com/y", 200, 1L, "ok"); - log.recordFailure("a", "1", null, "net.request", "blocked", "missing permission"); - - assertEquals(2, log.entriesFor("a").size()); - assertEquals(1, log.entriesFor("b").size()); - assertTrue(log.entriesFor("nobody").isEmpty()); - assertTrue(log.entriesFor(null).isEmpty()); - } - - @Test - public void failureRecordsCapabilityNotUrl() { - log.recordFailure("p", "2", PluginEventBus.EVENT_UI_CONTENT_SHOWN, "net.request", "blocked", - "permission not granted"); - - PluginAuditLog.Entry entry = log.entries().get(0); - assertEquals("failure", entry.kind()); - assertEquals("net.request", entry.method()); - assertEquals("blocked", entry.outcome()); - assertEquals("", entry.host()); - } - - /** - * 进程重启后必须还能读到上次的记录——崩溃现场正是最需要证据的时刻。 - * - *

{@link PluginAuditLog#reloadForTest()} 表达"重新打开日志":单例是 {@code static final}, - * 测试造不出第二个实例,所以用丢弃内存副本 + 重新装载来模拟同一件事。

- */ - @Test - public void entriesSurviveReload() { - MemorySink sink = new MemorySink(); - PluginAuditLog.storeForTest(PluginAuditStore.useSinkForTest(sink)); - try { - log.recordRequest("persist.probe", "2.0.0", null, "net.request", - "https://example.com/a?token=SECRET", 200, 42L, "ok"); - - PluginAuditLog.reloadForTest(); - - List restored = log.entries(); - assertEquals(1, restored.size()); - PluginAuditLog.Entry entry = restored.get(0); - assertEquals("persist.probe", entry.pluginId()); - assertEquals("2.0.0", entry.pluginVersion()); - assertEquals("example.com", entry.host()); - assertEquals("/a", entry.path()); - assertEquals(200, entry.status()); - assertEquals(42L, entry.durationMs()); - assertEquals("ok", entry.outcome()); - assertEquals("request", entry.kind()); - // 可空字段必须原样还原为 null,而不是变成字符串 "null"。 - assertNull(entry.event()); - assertEquals("net.request", entry.method()); - assertFalse(sink.read().contains("SECRET")); - } finally { - PluginAuditLog.storeForTest(null); - PluginAuditStore.useSinkForTest(null); - } - } - - /** - * 装载时必须跳过坏行并裁到环形容量:落盘文件可能被截断、被别的版本写过、或比内存容量更大。 - * - *

坏行让整份日志报废是最糟的结果——日志被截断恰恰发生在崩溃现场,而那正是唯一需要它的时刻。

- */ - @Test - public void reloadSkipsBrokenLinesAndTrimsToCapacity() { - StringBuilder persisted = new StringBuilder(); - persisted.append("not json at all").append('\n'); - persisted.append("{\"kind\":\"request\"}").append('\n'); // 缺字段但可解析,kind 在 - persisted.append("[1,2,3]").append('\n'); - int total = PluginAuditLog.MAX_ENTRIES + 40; - for (int i = 0; i < total; i++) { - if (persisted.length() > 0) persisted.append('\n'); - persisted.append("{\"kind\":\"request\",\"pluginId\":\"p\",\"pluginVersion\":\"1\",") - .append("\"host\":\"e.com\",\"path\":\"/").append(i).append("\",") - .append("\"status\":200,\"durationMs\":1,\"outcome\":\"ok\"}"); - } - - MemorySink sink = new MemorySink(); - sink.write(persisted.toString()); - PluginAuditLog.storeForTest(PluginAuditStore.useSinkForTest(sink)); - try { - PluginAuditLog.reloadForTest(); - - List restored = log.entries(); - assertEquals(PluginAuditLog.MAX_ENTRIES, restored.size()); - // 最新一条在末尾,最旧的已被裁掉。 - assertEquals("/" + (total - 1), restored.get(restored.size() - 1).path()); - assertEquals("/" + (total - PluginAuditLog.MAX_ENTRIES), restored.get(0).path()); - for (PluginAuditLog.Entry entry : restored) assertEquals("p", entry.pluginId()); - } finally { - PluginAuditLog.storeForTest(null); - PluginAuditStore.useSinkForTest(null); - } - } - - /** 用户清空之后必须连落盘一起清;否则下次启动会把刚删掉的记录原样读回来。 */ - @Test - public void clearAlsoDropsPersistedCopy() { - MemorySink sink = new MemorySink(); - PluginAuditLog.storeForTest(PluginAuditStore.useSinkForTest(sink)); - try { - log.recordRequest("p", "1", null, "net.request", "https://e.com/x", 200, 1L, "ok"); - assertFalse(sink.read().isEmpty()); - - log.clear(); - PluginAuditLog.reloadForTest(); - - assertEquals(0, log.size()); - assertTrue(sink.read().isEmpty()); - } finally { - PluginAuditLog.storeForTest(null); - PluginAuditStore.useSinkForTest(null); - } - } - - /** - * 落盘失败不能影响记账本身,更不能把异常冒到网络请求的返回路径上 - * ({@code PluginNetBridge.request} 只 catch {@code IOException|RuntimeException})。 - */ - @Test - public void writeFailureKeepsMemoryEntry() { - PluginAuditLog.storeForTest(PluginAuditStore.useSinkForTest(new PluginAuditStore.Sink() { - @Override - public String read() { - return ""; - } - - @Override - public void write(String text) { - throw new IllegalStateException("disk full"); - } - })); - try { - log.recordRequest("p", "1", null, "net.request", "https://e.com/x", 200, 1L, "ok"); - - assertEquals(1, log.size()); - assertEquals("e.com", log.entries().get(0).host()); - } finally { - PluginAuditLog.storeForTest(null); - PluginAuditStore.useSinkForTest(null); - } - } - - /** - * 主线程记账不许碰文件:调用方返回之后 Sink 一次都不该被写,只有写入器线程才写。 - * - *

这是整个改造的目的本身——事件派发在主线程,插件一抛错就会走到这里;同步重写整份 - * 文件就是 StrictMode 磁盘违规加掉帧。

- */ - @Test - public void mainThreadDelegatesFileWorkToTheWriter() throws Exception { - BlockingSink sink = new BlockingSink(); - PluginAuditLog.storeForTest(PluginAuditStore.useSinkForTest(sink)); - try { - PluginAuditLog.forceAsyncWriterForTest(true); - int baseline = sink.completedWrites(); - - log.recordRequest("p", "1", null, "net.request", "https://e.com/x", 200, 1L, "ok"); - - // 记账调用已经返回。此刻没有任何一次写入"完成"过:若调用方自己同步写了文件, - // 它会被 Sink 的 proceed 钉住 5 秒,然后以 completed=baseline+1 返回,这里立刻红。 - assertEquals(1, log.size()); - assertEquals("记账调用本身不许写文件", baseline, sink.completedWrites()); - assertTrue("写入器应当已经因为待写内容启动", sink.awaitEntered()); - assertEquals("写入必须发生在别的线程上", "plugin-audit-writer", sink.lastThread()); - - sink.proceed(); - PluginAuditLog.flushForTest(); - - assertEquals(baseline + 1, sink.completedWrites()); - assertTrue(sink.last().contains("/x")); - } finally { - PluginAuditLog.forceAsyncWriterForTest(null); - PluginAuditLog.storeForTest(null); - PluginAuditStore.useSinkForTest(null); - } - } - - /** - * 异步连发之后磁盘必须收敛到最后那一版,且过程里不许出现"旧覆盖新"。 - * - *

没有版本号时这是真会发生的:主线程排队的旧快照与网络线程同步写的新快照交错, - * 磁盘最终可能停在少了最后几条的旧内容上——而那几条往往正是刚发生的失败。

- */ - @Test - public void asyncWritesNeverLetAnOlderSnapshotWin() { - CountingSink sink = new CountingSink(); - PluginAuditLog.storeForTest(PluginAuditStore.useSinkForTest(sink)); - try { - log.clear(); - PluginAuditLog.forceAsyncWriterForTest(true); - - int rounds = 5; - for (int i = 0; i < rounds; i++) { - log.recordRequest("p", "1", null, "net.request", "https://e.com/" + i, 200, 1L, "ok"); - } - PluginAuditLog.flushForTest(); - - String last = sink.last(); - assertTrue(last.contains("/" + (rounds - 1))); - assertEquals("最后一版必须包含全部 " + rounds + " 条", rounds, - last.split("\n").length); - - // 顺序性:每次写入的行数只能单调不减,出现回退就说明旧快照盖了新快照。 - List texts = new ArrayList<>(sink.texts); - for (int i = 1; i < texts.size(); i++) { - assertTrue("第 " + i + " 次写入比上一次少了记录,旧快照覆盖了新快照", - texts.get(i).split("\n").length >= texts.get(i - 1).split("\n").length); - } - } finally { - PluginAuditLog.forceAsyncWriterForTest(null); - PluginAuditLog.storeForTest(null); - PluginAuditStore.useSinkForTest(null); - } - } - - /** - * 待写期间用户点了清空:落盘最终必须是空串,不许被那份过期快照写回来。 - * - *

这是版本号带来的直接收益——清空会拿到更大的版本号,任何更旧的排队内容自动失效。

- */ - @Test - public void clearWhileWriteIsPendingStillEndsEmpty() throws Exception { - BlockingSink sink = new BlockingSink(); - PluginAuditLog.storeForTest(PluginAuditStore.useSinkForTest(sink)); - try { - PluginAuditLog.forceAsyncWriterForTest(true); - - // 先让写入器卡在"写那份有记录的旧快照"里,再让用户清空: - // 这正是旧快照可能盖掉新状态的窗口。 - log.recordRequest("p", "1", null, "net.request", "https://e.com/x", 200, 1L, "ok"); - assertTrue(sink.awaitEntered()); - log.clear(); - assertEquals(0, log.size()); - - sink.proceed(); - PluginAuditLog.flushForTest(); - - assertEquals("用户清空后落盘必须收敛到空串", "", sink.last()); - assertEquals(0, log.size()); - - PluginAuditLog.reloadForTest(); - assertEquals("清空之后重装载不许把旧记录读回来", 0, log.size()); - } finally { - PluginAuditLog.forceAsyncWriterForTest(null); - PluginAuditLog.storeForTest(null); - PluginAuditStore.useSinkForTest(null); - } - } - - @Test - public void clearEmptiesLog() { - log.recordRequest("p", "1", null, "net.request", "https://e.com", 200, 1L, "ok"); - assertEquals(1, log.size()); - log.clear(); - assertEquals(0, log.size()); - assertTrue(log.entries().isEmpty()); - } - - /** bodySize 必须真的落盘并原样回读:它是"插件发出去多少字节"的唯一证据,只在内存里对不算数。 */ - @Test - public void bodySizeSurvivesReload() { - MemorySink sink = new MemorySink(); - PluginAuditLog.storeForTest(PluginAuditStore.useSinkForTest(sink)); - try { - log.recordRequest("body.probe", "3.0.0", null, "net.request", - "https://example.com/upload", 200, 7L, "ok", 12L); - PluginAuditLog.reloadForTest(); - List restored = log.entries(); - assertEquals(1, restored.size()); - assertEquals(12L, restored.get(0).bodySize()); - } finally { - PluginAuditLog.storeForTest(null); - PluginAuditStore.useSinkForTest(null); - } - } - - /** 加 bodySize 之前的旧快照里没有这个键:必须照读并回落 0,不许整行报废。 */ - @Test - public void legacySnapshotWithoutBodySizeFallsBackToZero() { - MemorySink sink = new MemorySink(); - sink.write("{\"kind\":\"request\",\"pluginId\":\"legacy\",\"pluginVersion\":\"1\"," - + "\"host\":\"e.com\",\"path\":\"/x\",\"status\":200,\"durationMs\":5," - + "\"outcome\":\"ok\"}"); - PluginAuditLog.storeForTest(PluginAuditStore.useSinkForTest(sink)); - try { - PluginAuditLog.reloadForTest(); - List restored = log.entries(); - assertEquals(1, restored.size()); - assertEquals(0L, restored.get(0).bodySize()); - } finally { - PluginAuditLog.storeForTest(null); - PluginAuditStore.useSinkForTest(null); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginAuditStoreTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginAuditStoreTest.java deleted file mode 100644 index 56f573c9e2..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginAuditStoreTest.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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 com.google.gson.JsonObject; - -import org.junit.Test; - -/** - * 落盘层测试。重点不是"能写能读",而是三条会让审计失去证据价值的行为: - * 坏行不能让整份日志报废、超限只能按整行裁、清空必须真的读回为空。 - */ -public class PluginAuditStoreTest { - - /** 内存后端:JVM 单测没有 {@code Init.context()},真实文件后端在这里根本不可用。 */ - private static final class MemorySink implements PluginAuditStore.Sink { - private String text = ""; - - @Override - public String read() { - return text; - } - - @Override - public void write(String text) { - this.text = text == null ? "" : text; - } - } - - @Test - public void writeIsReadableBack() { - MemorySink sink = new MemorySink(); - PluginAuditStore store = PluginAuditStore.useSinkForTest(sink); - try { - store.write("{\"kind\":\"request\"}\n{\"kind\":\"failure\"}"); - assertEquals("{\"kind\":\"request\"}\n{\"kind\":\"failure\"}", store.read()); - } finally { - PluginAuditStore.useSinkForTest(null); - } - } - - @Test - public void clearedStoreReadsBackEmpty() { - MemorySink sink = new MemorySink(); - PluginAuditStore store = PluginAuditStore.useSinkForTest(sink); - try { - store.write("{\"kind\":\"request\"}"); - store.write(""); - assertEquals("", store.read()); - assertTrue(sink.read().isEmpty()); - } finally { - PluginAuditStore.useSinkForTest(null); - } - } - - @Test - public void nullNeverReachesTheSink() { - MemorySink sink = new MemorySink(); - PluginAuditStore store = PluginAuditStore.useSinkForTest(sink); - try { - store.write(null); - assertEquals("", store.read()); - } finally { - PluginAuditStore.useSinkForTest(null); - } - } - - @Test - public void brokenLinesAreRejectedNotThrown() { - assertNull(PluginAuditStore.object("not json at all")); - assertNull(PluginAuditStore.object("")); - assertNull(PluginAuditStore.object("[1,2,3]")); - assertNull(PluginAuditStore.object("{\"kind\":\"request\"")); - JsonObject object = PluginAuditStore.object("{\"kind\":\"request\"}"); - assertTrue(object != null && "request".equals(object.get("kind").getAsString())); - } - - @Test - public void tailKeepsWholeLinesOnly() { - String text = "line1\nline2\nline3"; - // 行数上限优先:只留最后一行。 - assertEquals("line3", PluginAuditStore.tail(text, Long.MAX_VALUE, 1)); - // 字节上限切在第二行中间时,整行丢弃而不是留半条。 - assertEquals("line3", PluginAuditStore.tail(text, 6L, 100)); - assertEquals("line3", PluginAuditStore.tail(text, 11L, 100)); - assertEquals("line2\nline3", PluginAuditStore.tail(text, 12L, 100)); - // 未超限时原样返回。 - assertEquals(text, PluginAuditStore.tail(text, Long.MAX_VALUE, 100)); - assertEquals("", PluginAuditStore.tail(null, 100L, 10)); - assertEquals("", PluginAuditStore.tail("", 100L, 10)); - assertEquals("", PluginAuditStore.tail(text, 0L, 10)); - assertEquals("", PluginAuditStore.tail(text, 100L, 0)); - } - - @Test - public void tailNeverKeepsMoreThanMaxLines() { - StringBuilder builder = new StringBuilder(); - for (int i = 0; i < 600; i++) { - if (i > 0) builder.append('\n'); - builder.append("{\"kind\":\"request\",\"path\":\"/").append(i).append("\"}"); - } - String text = PluginAuditStore.tail(builder.toString(), 512L * 1024L, PluginAuditLog.MAX_ENTRIES); - String[] lines = text.split("\n"); - assertTrue(lines.length <= PluginAuditLog.MAX_ENTRIES); - assertEquals("{\"kind\":\"request\",\"path\":\"/599\"}", lines[lines.length - 1]); - assertFalse(text.contains("/0\"")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginBootstrapTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginBootstrapTest.java deleted file mode 100644 index 4a9e878017..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginBootstrapTest.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; - -/** - * 启动接线测试。 - * - *

契约只有一条但很硬:**装载链路的任何失败都不能抛给调用方**。调用点是 - * {@code App.onCreate} 投递的后台任务与设置页的"重新装载"按钮,前者抛异常等于启动崩溃 - * (后台线程的异常连现场都留不下),后者等于界面直接挂掉。 - * - *

因此这里刻意在**没有原生库**的 JVM 环境里跑完整链路:QuickJS 的 {@code so} 加载必然失败, - * 而这正是"最坏情况"——{@link PluginBootstrap#reloadAll()} 必须把它变成一次安静的 - * {@code false} 而不是异常。 - */ -public class PluginBootstrapTest { - - private File workDir; - private File pluginsRoot; - private MemoryStore store; - - @Before - public void setUp() throws Exception { - PluginController.reset(); - PluginEventBus.get().clear(); - PluginAuditLog.get().clear(); - workDir = Files.createTempDirectory("plugin-bootstrap").toFile(); - pluginsRoot = new File(workDir, "files/plugins"); - store = new MemoryStore(); - PluginSourceConfig.useStoreForTest(store); - PluginPackageImporter.overrideRootForTest(pluginsRoot); - } - - @After - public void tearDown() { - PluginPackageImporter.overrideRootForTest(null); - PluginSourceConfig.useStoreForTest(null); - PluginController.reset(); - PluginEventBus.get().clear(); - PluginAuditLog.get().clear(); - delete(workDir); - } - - @Test - public void installRegistersJsRuntimeAndIsIdempotent() { - PluginBootstrap.install(); - String first = PluginController.get().diagnostics(); - PluginBootstrap.install(); - String second = PluginController.get().diagnostics(); - - assertTrue("js runtime must be registered: " + first, first.contains("js=ready")); - assertEquals("install must be idempotent", first, second); - } - - @Test - public void reloadAllWithNoPluginsReturnsFalseQuietly() { - assertFalse(PluginBootstrap.reloadAll()); - assertEquals(0, PluginController.get().host().size()); - } - - /** - * 有启用插件但装载必然失败时(清单合法、入口脚本缺失,且 JVM 里没有 QuickJS 原生库), - * 必须是"quiet false"而不是异常。这两条失败路径任取其一都足以证明包装层生效。 - */ - @Test - public void reloadAllSwallowsRuntimeFailure() throws Exception { - File pkg = new File(workDir, "pkg"); - assertTrue(pkg.mkdirs()); - Files.writeString(new File(pkg, "manifest.json").toPath(), manifestJson("demo.boot"), StandardCharsets.UTF_8); - // 故意不写 entry 指向的 hello.js:装载期必然失败。 - PluginSourceConfig.get().put(new PluginSourceConfig.Entry("demo.boot", "boot", "1", "js", - pkg.getAbsolutePath(), true, 1L)); - assertEquals(1, PluginSourceConfig.get().toManifests().size()); - - boolean loaded = PluginBootstrap.reloadAll(); - - assertFalse("a failed engine must not be reported as loaded", loaded); - // 失败必须留审计:否则"插件没生效"完全无从诊断。 - assertTrue("failed init must be audited", PluginAuditLog.get().size() > 0); - } - - @Test - public void installAndReloadQuietlyNeverThrows() { - PluginBootstrap.installAndReloadQuietly(); - PluginBootstrap.installAndReloadQuietly(); - // 能跑到这里就说明没有异常;顺带确认单例仍然可用。 - assertEquals(0, PluginController.get().host().size()); - } - - private static String manifestJson(String id) { - return "{\"protocol\":\"webhtv-plugin\",\"version\":1,\"id\":\"" + id - + "\",\"name\":\"boot\",\"sdk\":1,\"runtime\":{\"api\":\"js\",\"entry\":\"hello.js\"}," - + "\"events\":[\"ui.contentShown\"],\"permissions\":[\"plugin.events\"]}"; - } - - private static void delete(File file) { - if (file == null || !file.exists()) return; - File[] children = file.listFiles(); - if (children != null) for (File child : children) delete(child); - file.delete(); - } - - /** 内存版存储:单测里没有 Context,{@code Prefers} 不可用。 */ - static final class MemoryStore implements PluginSourceConfig.Store { - - String json = ""; - - @Override - public String read() { - return json; - } - - @Override - public void write(String value) { - json = value; - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginBundleIndexTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginBundleIndexTest.java deleted file mode 100644 index 0732417fdb..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginBundleIndexTest.java +++ /dev/null @@ -1,401 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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.After; -import org.junit.Before; -import org.junit.Test; - -import java.io.File; -import java.io.FileOutputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -/** - * 插件包索引测试(外加 {@code importBundle} 的端到端用例)。 - * - *

所有输入都是本测试现造的 zip,不依赖任何真实下载内容:索引的契约是关于"包里有什么", - * 而真实 GitHub 包的形态({@code repo-main/} 包装层、{@code __MACOSX/} 残留、多插件共存) - * 在这里用几行就能精确复现,比抓一个真包更可读也更稳定。 - * - *

{@code importBundle} 的用例也放在本文件:本次改动的文件预算只允许两个新测试文件, - * 而"整批导入"与"索引"共用同一批夹具(zip 构造 + 插件目录覆盖),合在一处比拆开更省样板。 - * - *

三类断言贯穿全文:坏输入只降级不抛异常、越界条目名一律不写盘、 - * 临时解压目录用完即删。 - */ -public class PluginBundleIndexTest { - - private File workDir; - private File pluginsRoot; - - @Before - public void setUp() throws Exception { - workDir = Files.createTempDirectory("plugin-bundle").toFile(); - pluginsRoot = new File(workDir, "files/plugins"); - PluginSourceConfig.useStoreForTest(new PluginPackageImporterTest.MemoryStore()); - PluginPackageImporter.overrideRootForTest(pluginsRoot); - } - - @After - public void tearDown() { - PluginPackageImporter.overrideRootForTest(null); - PluginSourceConfig.useStoreForTest(null); - delete(workDir); - } - - // ---- 索引:发现 ---- - - @Test - public void findsPluginAtZipRoot() throws Exception { - File zip = zip("root.zip", "manifest.json", manifest("demo.root", "hello.js", "js"), - "hello.js", "webhtv.log('hi');"); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertNull(result.error()); - assertTrue(result.isSuccess()); - assertEquals(1, result.candidates().size()); - PluginBundleIndex.Candidate candidate = result.candidates().get(0); - assertEquals("根目录插件必须报空串 entryPath,而不是 \"/\" 或 \".\"", "", candidate.entryPath()); - assertEquals("demo.root", candidate.id()); - assertEquals("js", candidate.runtimeApi()); - } - - @Test - public void findsPluginInSubdirectory() throws Exception { - File zip = zip("sub.zip", "douban/manifest.json", manifest("demo.douban", "hello.js", "js"), - "douban/hello.js", "1;"); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertEquals(1, result.candidates().size()); - assertEquals("douban", result.candidates().get(0).entryPath()); - } - - @Test - public void findsPluginInsideCodeloadWrapper() throws Exception { - // GitHub 源码包的典型形态:整棵树被包在 repo-main/ 之下。 - File zip = zip("codeload.zip", - "repo-main/README.md", "# readme", - "repo-main/douban/manifest.json", manifest("demo.douban", "hello.js", "js"), - "repo-main/douban/hello.js", "1;"); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertEquals(1, result.candidates().size()); - assertEquals("包装层不得被剥掉:entryPath 必须指向包内真实位置", - "repo-main/douban", result.candidates().get(0).entryPath()); - assertEquals("demo.douban", result.candidates().get(0).id()); - } - - @Test - public void sortsCandidatesByEntryPath() throws Exception { - File zip = zip("three.zip", - "c/manifest.json", manifest("demo.c", "hello.js", "js"), - "a/manifest.json", manifest("demo.a", "hello.js", "js"), - "b/manifest.json", manifest("demo.b", "hello.js", "js")); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - List paths = new ArrayList<>(); - for (PluginBundleIndex.Candidate candidate : result.candidates()) paths.add(candidate.entryPath()); - assertEquals(Arrays.asList("a", "b", "c"), paths); - } - - @Test - public void recordsManifestMetadata() throws Exception { - File zip = zip("meta.zip", "p/manifest.json", - manifest("demo.meta", "豆瓣", "2.3.4", "hello.js", "js", - "[\"" + PluginCapabilityRegistry.PERMISSION_EVENTS + "\",\"" - + PluginCapabilityRegistry.PERMISSION_NET_REQUEST + "\"]"), - "p/hello.js", "1;"); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertEquals(1, result.candidates().size()); - PluginBundleIndex.Candidate candidate = result.candidates().get(0); - assertEquals("豆瓣", candidate.name()); - assertEquals("2.3.4", candidate.version()); - // 权限按能力注册表过滤后原样透出给界面,界面据此展示"这个插件要什么"。 - assertTrue(candidate.permissions().contains(PluginCapabilityRegistry.PERMISSION_EVENTS)); - assertTrue(candidate.permissions().contains(PluginCapabilityRegistry.PERMISSION_NET_REQUEST)); - } - - // ---- 索引:逐个跳过,而不是整体失败 ---- - - @Test - public void keepsValidCandidateBesideUnparseableManifest() throws Exception { - File zip = zip("mixed.zip", - "broken/manifest.json", "{ not json at all", - "good/manifest.json", manifest("demo.good", "hello.js", "js")); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertNull("一份坏清单不该让整包变成失败", result.error()); - assertEquals(1, result.candidates().size()); - assertEquals("demo.good", result.candidates().get(0).id()); - } - - @Test - public void skipsUnsupportedRuntime() throws Exception { - File zip = zip("lua.zip", - "lua/manifest.json", manifest("demo.lua", "hello.js", "lua"), - "js/manifest.json", manifest("demo.js", "hello.js", "js")); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertEquals("只应留下 js 插件", 1, result.candidates().size()); - assertEquals("demo.js", result.candidates().get(0).id()); - } - - @Test - public void deduplicatesById() throws Exception { - File zip = zip("dup.zip", - "b/manifest.json", manifest("demo.dup", "hello.js", "js"), - "a/manifest.json", manifest("demo.dup", "hello.js", "js")); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertEquals("同一个 id 只能给出一个可导入候选", 1, result.candidates().size()); - assertEquals("保留排序在前的那个(a < b)", "a", result.candidates().get(0).entryPath()); - } - - @Test - public void skipsTraversalEntries() throws Exception { - File zip = zip("evil.zip", - "../evil/manifest.json", manifest("demo.evil", "hello.js", "js"), - "ok/../../evil2/manifest.json", manifest("demo.evil2", "hello.js", "js"), - "good/manifest.json", manifest("demo.good", "hello.js", "js")); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertEquals(1, result.candidates().size()); - assertEquals("demo.good", result.candidates().get(0).id()); - assertFalse(result.candidates().get(0).entryPath().contains("..")); - } - - @Test - public void ignoresGitAndMacosxEntries() throws Exception { - File zip = zip("noise.zip", - ".git/manifest.json", manifest("demo.git", "hello.js", "js"), - "__MACOSX/._x/manifest.json", manifest("demo.mac", "hello.js", "js"), - "good/.hidden/manifest.json", manifest("demo.hidden", "hello.js", "js"), - "good/manifest.json", manifest("demo.good", "hello.js", "js")); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertEquals(1, result.candidates().size()); - assertEquals("demo.good", result.candidates().get(0).id()); - } - - @Test - public void skipsUnsafeEntryNames() throws Exception { - File zip = zip("unsafe.zip", - "/abs/manifest.json", manifest("demo.abs", "hello.js", "js"), - "C:/drive/manifest.json", manifest("demo.drive", "hello.js", "js"), - "win\\path\\manifest.json", manifest("demo.win", "hello.js", "js"), - "good/manifest.json", manifest("demo.good", "hello.js", "js")); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertEquals(1, result.candidates().size()); - assertEquals("demo.good", result.candidates().get(0).id()); - } - - @Test - public void skipsOversizedManifest() throws Exception { - // 50KB 的合法 JSON 后面再补空白,越过 64KB 上限:必须在读取阶段就放弃,而不是解析。 - StringBuilder huge = new StringBuilder(manifest("demo.huge", "hello.js", "js")); - while (huge.length() <= PluginManifest.MAX_MANIFEST_BYTES) huge.append(' '); - File zip = zip("huge.zip", - "huge/manifest.json", huge.toString(), - "good/manifest.json", manifest("demo.good", "hello.js", "js")); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertEquals(1, result.candidates().size()); - assertEquals("demo.good", result.candidates().get(0).id()); - } - - @Test - public void capsCandidateCount() throws Exception { - List entries = new ArrayList<>(); - int total = PluginBundleIndex.MAX_CANDIDATES + 3; - for (int index = 0; index < total; index++) { - String dir = String.format("p%03d", index); - entries.add(dir + "/manifest.json"); - entries.add(manifest("demo." + dir, "hello.js", "js")); - } - File zip = zip("many.zip", entries.toArray(new String[0])); - - PluginBundleIndex.Result result = PluginBundleIndex.inspect(zip); - - assertEquals(PluginBundleIndex.MAX_CANDIDATES, result.candidates().size()); - assertEquals("截断必须发生在排序之后,返回的应是最前面的那些", "p000", - result.candidates().get(0).entryPath()); - } - - // ---- 索引:坏输入只给原因,不抛异常 ---- - - @Test - public void reportsReadableErrorForBadInput() throws Exception { - File notZip = new File(workDir, "not-a-zip.zip"); - Files.writeString(notZip.toPath(), "this is definitely not a zip", StandardCharsets.UTF_8); - File missing = new File(workDir, "missing.zip"); - File directory = new File(workDir, "a-directory.zip"); - assertTrue(directory.mkdirs()); - - PluginBundleIndex.Result broken = PluginBundleIndex.inspect(notZip); - assertFalse(broken.isSuccess()); - assertNotNull(broken.error()); - assertFalse(broken.error(), broken.error().isEmpty()); - assertTrue("失败时不该给出半个候选列表", broken.candidates().isEmpty()); - - assertFalse(PluginBundleIndex.inspect(missing).isSuccess()); - assertFalse(PluginBundleIndex.inspect(directory).isSuccess()); - assertFalse(PluginBundleIndex.inspect(null).isSuccess()); - } - - // ---- importBundle:整批导入 ---- - - @Test - public void importBundleInstallsOnlySelectedCandidates() throws Exception { - File zip = zip("bundle.zip", - "a/manifest.json", manifest("demo.a", "hello.js", "js"), - "a/hello.js", "webhtv.log('a');", - "b/manifest.json", manifest("demo.b", "hello.js", "js"), - "b/hello.js", "webhtv.log('b');"); - PluginBundleIndex.Result indexed = PluginBundleIndex.inspect(zip); - assertEquals(2, indexed.candidates().size()); - - PluginBundleIndex.Candidate selected = null; - for (PluginBundleIndex.Candidate candidate : indexed.candidates()) { - if ("demo.a".equals(candidate.id())) selected = candidate; - } - assertNotNull(selected); - - PluginPackageImporter.BundleResult result = PluginPackageImporter.importBundle(zip, List.of(selected)); - - assertNull(result.error()); - assertTrue(result.isSuccess()); - assertEquals(1, result.items().size()); - PluginPackageImporter.ItemResult item = result.items().get(0); - assertNull(item.error()); - assertNotNull(item.entry()); - assertEquals("demo.a", item.entry().id()); - - assertTrue("选中的插件必须真的落到插件根目录", new File(pluginsRoot, "demo.a/manifest.json").isFile()); - assertFalse("未选中的插件一个字节都不该写盘", new File(pluginsRoot, "demo.b").exists()); - assertNoTempLeftover(); - } - - @Test - public void importBundleKeepsGoingAfterOneItemFails() throws Exception { - File zip = zip("partial.zip", - "good/manifest.json", manifest("demo.good", "hello.js", "js"), - "good/hello.js", "1;", - "empty/manifest.json", manifest("demo.empty", "hello.js", "js"), - "empty/hello.js", ""); - PluginBundleIndex.Result indexed = PluginBundleIndex.inspect(zip); - - // 手工构造一个指向不存在目录的候选:模拟"界面传来的选择与包内容已经不一致"。 - List selected = new ArrayList<>(); - selected.add(new PluginBundleIndex.Candidate("missing", "demo.missing", "demo.missing", "1", "js", - java.util.Set.of())); - selected.add(candidateOf(indexed, "demo.empty")); - selected.add(candidateOf(indexed, "demo.good")); - - PluginPackageImporter.BundleResult result = PluginPackageImporter.importBundle(zip, selected); - - assertNull("单项失败不代表整批失败", result.error()); - assertEquals(3, result.items().size()); - assertEquals("demo.missing", result.items().get(0).candidate().id()); - assertNotNull(result.items().get(0).error()); - assertNull(result.items().get(0).entry()); - - assertEquals("demo.empty", result.items().get(1).candidate().id()); - assertNotNull("空入口脚本必须被 importFrom 拒掉", result.items().get(1).error()); - assertNull(result.items().get(1).entry()); - - assertEquals("demo.good", result.items().get(2).candidate().id()); - assertNull(result.items().get(2).error()); - assertNotNull("前面的失败不该影响后面这一项", result.items().get(2).entry()); - assertTrue(new File(pluginsRoot, "demo.good/manifest.json").isFile()); - assertFalse("失败项不得留下半个包", new File(pluginsRoot, "demo.empty").exists()); - assertNoTempLeftover(); - } - - @Test - public void importBundleRejectsBadInputWithoutThrowing() throws Exception { - File zip = zip("ok.zip", "a/manifest.json", manifest("demo.a", "hello.js", "js"), "a/hello.js", "1;"); - File notZip = new File(workDir, "not-a-zip.zip"); - Files.writeString(notZip.toPath(), "nope", StandardCharsets.UTF_8); - List some = PluginBundleIndex.inspect(zip).candidates(); - - assertNotNull(PluginPackageImporter.importBundle(null, some).error()); - assertNotNull(PluginPackageImporter.importBundle(new File(workDir, "missing.zip"), some).error()); - assertNotNull(PluginPackageImporter.importBundle(notZip, some).error()); - assertNotNull(PluginPackageImporter.importBundle(zip, List.of()).error()); - assertNotNull(PluginPackageImporter.importBundle(zip, null).error()); - assertTrue("失败时不该声称有任何一项成功", - PluginPackageImporter.importBundle(notZip, some).items().isEmpty()); - assertNoTempLeftover(); - } - - // ---- helpers ---- - - private static PluginBundleIndex.Candidate candidateOf(PluginBundleIndex.Result indexed, String id) { - for (PluginBundleIndex.Candidate candidate : indexed.candidates()) { - if (id.equals(candidate.id())) return candidate; - } - throw new AssertionError("candidate not indexed: " + id); - } - - private void assertNoTempLeftover() { - File tempRoot = new File(workDir, "files/plugin-bundle-tmp"); - File[] children = tempRoot.listFiles(); - assertTrue("临时解压目录必须清空,实际残留:" + (children == null ? "none" : children.length), - children == null || children.length == 0); - } - - private File zip(String name, String... entries) throws Exception { - File file = new File(workDir, name); - try (ZipOutputStream output = new ZipOutputStream(new FileOutputStream(file))) { - // 逐对传入 path/content:把"条目名"直接暴露在用例里,越界名才能被写成字面量。 - for (int index = 0; index + 1 < entries.length; index += 2) { - output.putNextEntry(new ZipEntry(entries[index])); - output.write(entries[index + 1].getBytes(StandardCharsets.UTF_8)); - output.closeEntry(); - } - } - return file; - } - - private static String manifest(String id, String entry, String api) { - return manifest(id, id, "1.0.0", entry, api, "[\"plugin.events\"]"); - } - - private static String manifest(String id, String name, String version, String entry, String api, - String permissionsJson) { - return "{\"protocol\":\"webhtv-plugin\",\"version\":1,\"id\":\"" + id + "\",\"name\":\"" + name - + "\",\"pluginVersion\":\"" + version + "\",\"sdk\":1,\"runtime\":{\"api\":\"" + api - + "\",\"entry\":\"" + entry + "\",\"timeout\":15},\"permissions\":" + permissionsJson + "}"; - } - - private static void delete(File file) { - if (file == null || !file.exists()) return; - File[] children = file.listFiles(); - if (children != null) for (File child : children) delete(child); - file.delete(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginCapabilityRegistryTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginCapabilityRegistryTest.java deleted file mode 100644 index c027a72ac3..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginCapabilityRegistryTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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.Set; - -/** - * 能力门禁测试。核心断言是:**没有"默认允许"**。 - * - *

这是 D2 决策(net.request 不做域名白名单)的补偿约束:既然不限制目标域名, - * 那么"谁被允许发起请求"这道门就必须是严的,两者不能同时放松。 - */ -public class PluginCapabilityRegistryTest { - - @Test - public void unknownPermissionIsRejected() { - assertFalse(PluginCapabilityRegistry.isKnownPermission("net.unlimited")); - assertFalse(PluginCapabilityRegistry.isKnownPermission(null)); - assertFalse(PluginCapabilityRegistry.allows(Set.of("net.unlimited"), "net.unlimited")); - } - - @Test - public void nullAndEmptyPermissionsNeverAllow() { - assertFalse(PluginCapabilityRegistry.allows(null, PluginCapabilityRegistry.PERMISSION_NET_REQUEST)); - assertFalse(PluginCapabilityRegistry.allows(Set.of(), PluginCapabilityRegistry.PERMISSION_NET_REQUEST)); - assertFalse(PluginCapabilityRegistry.allowsEvent(null, PluginEventBus.EVENT_UI_CONTENT_SHOWN)); - assertFalse(PluginCapabilityRegistry.allowsEvent(Set.of(), PluginEventBus.EVENT_UI_CONTENT_SHOWN)); - assertFalse(PluginCapabilityRegistry.allowsEvent(Set.of(PluginCapabilityRegistry.PERMISSION_EVENTS), null)); - } - - @Test - public void netRequestIsGatedByPermission() { - assertFalse(PluginCapabilityRegistry.allows( - Set.of(PluginCapabilityRegistry.PERMISSION_EVENTS), - PluginCapabilityRegistry.PERMISSION_NET_REQUEST)); - assertTrue(PluginCapabilityRegistry.allows( - Set.of(PluginCapabilityRegistry.PERMISSION_NET_REQUEST), - PluginCapabilityRegistry.PERMISSION_NET_REQUEST)); - } - - @Test - public void uiSlotsAreIndependentlyGated() { - Set floatingOnly = Set.of(PluginCapabilityRegistry.PERMISSION_UI_FLOATING); - assertTrue(PluginCapabilityRegistry.allows(floatingOnly, PluginCapabilityRegistry.PERMISSION_UI_FLOATING)); - assertFalse(PluginCapabilityRegistry.allows(floatingOnly, PluginCapabilityRegistry.PERMISSION_UI_TOOLBAR)); - assertFalse(PluginCapabilityRegistry.allows(floatingOnly, PluginCapabilityRegistry.PERMISSION_UI_PAGE)); - } - - @Test - public void filterPermissionsDropsUnknownAndKeepsOrder() { - Set filtered = PluginCapabilityRegistry.filterPermissions( - Set.of("bogus", PluginCapabilityRegistry.PERMISSION_NET_REQUEST, PluginCapabilityRegistry.PERMISSION_UI_PANEL)); - assertEquals(2, filtered.size()); - assertTrue(filtered.contains(PluginCapabilityRegistry.PERMISSION_NET_REQUEST)); - assertTrue(filtered.contains(PluginCapabilityRegistry.PERMISSION_UI_PANEL)); - } - - @Test - public void eventsAreBoundToPermissions() { - Set permissions = Set.of(PluginCapabilityRegistry.PERMISSION_EVENTS); - assertTrue(PluginCapabilityRegistry.allowsEvent(permissions, PluginEventBus.EVENT_ACTIVITY_CREATED)); - assertTrue(PluginCapabilityRegistry.allowsEvent(permissions, PluginEventBus.EVENT_APP_FOREGROUND)); - assertFalse(PluginCapabilityRegistry.allowsEvent(permissions, "ui.neverDeclared")); - - // 只有 episode 数据权限时,只能收 ui.contentShown - Set dataOnly = Set.of(PluginCapabilityRegistry.PERMISSION_EPISODE_DATA); - assertTrue(PluginCapabilityRegistry.allowsEvent(dataOnly, PluginEventBus.EVENT_UI_CONTENT_SHOWN)); - assertFalse(PluginCapabilityRegistry.allowsEvent(dataOnly, PluginEventBus.EVENT_ACTIVITY_CREATED)); - } - - @Test - public void permissionWithoutEventsAllowsNoEvent() { - Set netOnly = Set.of(PluginCapabilityRegistry.PERMISSION_NET_REQUEST); - assertTrue(PluginCapabilityRegistry.eventsFor(PluginCapabilityRegistry.PERMISSION_NET_REQUEST).isEmpty()); - for (String event : new String[]{PluginEventBus.EVENT_APP_FOREGROUND, PluginEventBus.EVENT_ACTIVITY_CREATED, - PluginEventBus.EVENT_UI_CONTENT_SHOWN}) { - assertFalse(PluginCapabilityRegistry.allowsEvent(netOnly, event)); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginConfigTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginConfigTest.java deleted file mode 100644 index 88b453fc18..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginConfigTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import org.junit.Test; - -import java.util.Set; - -/** - * 配置信封的往返测试。 - * - *

信封存在的唯一理由是 {@link PluginRuntime#init} 只有一个字符串入口, - * 所以这里要保证三件事:拆开后的参数与写进去的一样、权限集合可还原、非法信封不抛异常。 - */ -public class PluginConfigTest { - - private static PluginManifest manifest() { - String json = """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "douban.comments", - "name": "豆瓣评论", - "sdk": 1, - "runtime": { "api": "js", "entry": "douban.js", "timeout": 20 }, - "events": ["ui.contentShown"], - "permissions": ["plugin.events", "net.request"] - } - """; - return PluginManifest.parse(json); - } - - @Test - public void roundTripsParamsPermissionsAndTimeout() { - JsonObject params = JsonParser.parseString("{\"TOKEN\":\"x\"}").getAsJsonObject(); - String config = PluginConfig.create(manifest(), params); - - assertEquals("x", PluginConfig.paramsOf(config).get("TOKEN").getAsString()); - assertEquals(Set.of(PluginCapabilityRegistry.PERMISSION_EVENTS, PluginCapabilityRegistry.PERMISSION_NET_REQUEST), - PluginConfig.permissionsOf(config)); - assertEquals(20, PluginConfig.timeoutOf(config, 15)); - } - - @Test - public void nullParamsBecomeEmptyObject() { - String config = PluginConfig.create(manifest(), null); - assertEquals(0, PluginConfig.paramsOf(config).size()); - } - - @Test - public void nullConfigDegradesToEmptyEverything() { - assertEquals(0, PluginConfig.paramsOf(null).size()); - assertTrue(PluginConfig.permissionsOf(null).isEmpty()); - assertEquals(15, PluginConfig.timeoutOf(null, 15)); - } - - @Test - public void malformedConfigDoesNotThrow() { - assertEquals(0, PluginConfig.paramsOf("{oops").size()); - assertTrue(PluginConfig.permissionsOf("{oops").isEmpty()); - assertEquals(7, PluginConfig.timeoutOf("{oops", 7)); - } - - @Test - public void timeoutOfFallsBackWhenValueIsNotANumber() { - String config = "{\"timeout\":\"abc\"}"; - assertEquals(9, PluginConfig.timeoutOf(config, 9)); - } - - @Test - public void createToleratesNullManifest() { - String config = PluginConfig.create(null, null); - assertNotNull(config); - assertEquals(0, PluginConfig.paramsOf(config).size()); - assertTrue(PluginConfig.permissionsOf(config).isEmpty()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginControllerTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginControllerTest.java deleted file mode 100644 index 30a963df01..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginControllerTest.java +++ /dev/null @@ -1,219 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -/** - * 装配层测试。 - * - *

关键断言是**"没有插件来源时完全静默"**:P2d 没有订阅解析(P6 才做), - * 因此进程启动绝不能有任何自动装载行为,否则用户会看到来路不明的控件。 - */ -public class PluginControllerTest { - - private PluginController controller; - private PluginEventBus bus; - private PluginAuditLog audit; - - private interface ActionHook { - String call(String pluginId, String action, String payload); - } - - private static final class FakeRuntime implements PluginRuntime { - volatile boolean failInit; - volatile ActionHook hook; - int initCount; - int destroyCount; - - @Override - public String init(String pluginId, String entry, String config) { - initCount++; - if (failInit) throw new IllegalStateException("init boom"); - return "ok"; - } - - @Override - public String onEvent(String pluginId, String event, String payload) { - return "ok"; - } - - @Override - public String action(String pluginId, String action, String payload) { - ActionHook current = hook; - return current == null ? "ok" : current.call(pluginId, action, payload); - } - - @Override - public void destroy(String pluginId) { - destroyCount++; - } - } - - private FakeRuntime runtime; - - @Before - public void setUp() { - PluginController.reset(); - bus = PluginEventBus.get(); - bus.clear(); - audit = PluginAuditLog.get(); - audit.clear(); - controller = new PluginController(); - runtime = new FakeRuntime(); - controller.registerRuntime("js", runtime); - } - - @After - public void tearDown() { - PluginController.reset(); - bus.clear(); - audit.clear(); - } - - private PluginManifest manifest(String id) { - return PluginManifest.parse(String.format(java.util.Locale.US, """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "%s", - "name": "%s", - "sdk": 1, - "runtime": { "api": "js", "entry": "assets://plugin/example/hello.js" }, - "events": ["ui.contentShown"], - "permissions": ["plugin.events"] - } - """, id, id)); - } - - @Test - public void emptyReloadMeansNoPluginsAndNoSubscription() { - List loaded = controller.reload(Collections.emptyList(), null); - - assertTrue(loaded.isEmpty()); - assertEquals(0, controller.host().size()); - assertFalse("no plugins must mean no bus subscription", bus.hasSubscribers(PluginEventBus.EVENT_UI_CONTENT_SHOWN)); - } - - @Test - public void reloadWithNullDoesNotThrow() { - assertTrue(controller.reload(null, null).isEmpty()); - } - - @Test - public void reloadLoadsPlugin() { - PluginManifest m = manifest("a"); - assertNotNull(m); - - List loaded = controller.reload(List.of(m), null); - - assertEquals(1, loaded.size()); - assertTrue(controller.host().isLoaded("a")); - assertEquals(1, runtime.initCount); - } - - @Test - public void failedInitIsAuditedAndNotLoaded() { - runtime.failInit = true; - PluginManifest m = manifest("a"); - - assertTrue(controller.reload(List.of(m), null).isEmpty()); - assertFalse(controller.host().isLoaded("a")); - assertEquals(1, audit.entriesFor("a").size()); - } - - @Test - public void reloadReplacesPreviousSet() { - controller.reload(List.of(manifest("a")), null); - controller.reload(List.of(manifest("b")), null); - - assertFalse(controller.host().isLoaded("a")); - assertTrue(controller.host().isLoaded("b")); - assertEquals(1, runtime.destroyCount); - } - - @Test - public void resumedWithoutActivityIsSafe() { - controller.onActivityResumed(null); - assertEquals(0, controller.presenter().attachedCount()); - } - - @Test - public void diagnosticsReportsRuntimesAndPlugins() { - controller.reload(List.of(manifest("a")), null); - String text = controller.diagnostics(); - - assertTrue(text.contains("runtimes=")); - assertTrue(text.contains("plugins=")); - assertTrue("diagnostics must reflect loaded plugin", text.contains("a=ready")); - } - - /** - * 点击链路的核心断言:槽位点击必须真正抵达运行时,且**不能在调用线程上执行**。 - * - *

后者是硬要求:插件动作走 JS 运行时,是带 5 秒超时的阻塞调用 - * ({@code QuickJsPluginRuntime.CALL_TIMEOUT_MS})。若跑在主线程,最长冻住界面 5 秒。 - */ - @Test - public void slotActionReachesRuntimeOffTheCallerThread() throws Exception { - CountDownLatch latch = new CountDownLatch(1); - AtomicReference threadName = new AtomicReference<>(); - AtomicReference gotAction = new AtomicReference<>(); - AtomicReference gotPayload = new AtomicReference<>(); - - runtime.hook = (pluginId, action, payload) -> { - threadName.set(Thread.currentThread().getName()); - gotAction.set(action); - gotPayload.set(payload); - latch.countDown(); - return "ok"; - }; - controller.reload(List.of(manifest("a")), null); - - controller.dispatchSlotAction("a", "comments.show"); - - assertTrue("runtime must be reached", latch.await(3, TimeUnit.SECONDS)); - assertEquals("comments.show", gotAction.get()); - assertEquals("plugin-action", threadName.get()); - assertTrue("payload must carry the host activity", gotPayload.get().contains("activity")); - } - - /** 点击未知插件不能抛给 UI 线程,且必须留审计痕迹。 */ - @Test - public void slotActionForUnknownPluginIsAuditedNotThrown() throws Exception { - CountDownLatch latch = new CountDownLatch(1); - runtime.hook = (pluginId, action, payload) -> { - latch.countDown(); - return null; - }; - controller.reload(List.of(manifest("a")), null); - int before = audit.size(); - - controller.dispatchSlotAction("does.not.exist", "whatever"); - - // 未装载的插件不会到达运行时;不抛异常是唯一硬要求。 - assertFalse("unknown plugin must not reach the runtime", latch.await(300, TimeUnit.MILLISECONDS)); - assertTrue("a failed dispatch must leave an audit trail", audit.size() > before); - } - - @Test - public void resetUnloadsEverything() { - PluginController.get().reload(List.of(manifest("a")), null); - - PluginController.reset(); - - // reset 后新单例应是空状态 - assertEquals(0, PluginController.get().host().size()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginEnvironmentStoreTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginEnvironmentStoreTest.java deleted file mode 100644 index afd19f5039..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginEnvironmentStoreTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import com.google.gson.JsonObject; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -/** - * 插件参数存储测试。 - * - *

这里断言的重点是**坏输入不落盘、也不炸调用方**:用户在管理页手打 JSON,写错是常态。 - * 与字幕源那套 {@code Setting.putSubtitleSourceEnvironment} 的差别就在这里——那边直接 - * {@code getAsJsonObject()},非法 JSON 会一路抛到 UI 线程。 - */ -public class PluginEnvironmentStoreTest { - - private MemoryStore store; - private PluginEnvironmentStore environments; - - @Before - public void setUp() { - store = new MemoryStore(); - environments = PluginEnvironmentStore.useStoreForTest(store); - } - - @After - public void tearDown() { - PluginEnvironmentStore.useStoreForTest(null); - } - - @Test - public void missingPluginYieldsEmptyObject() { - assertEquals(0, environments.getJson("nope").size()); - assertEquals("", environments.get("nope")); - } - - @Test - public void roundTripsPerPlugin() { - assertTrue(environments.put("a", "{\"TOKEN\":\"x\"}")); - assertTrue(environments.put("b", "{\"TOKEN\":\"y\"}")); - assertEquals("x", environments.getJson("a").get("TOKEN").getAsString()); - assertEquals("y", environments.getJson("b").get("TOKEN").getAsString()); - } - - @Test - public void rejectsInvalidJsonAndKeepsPreviousValue() { - environments.put("a", "{\"TOKEN\":\"x\"}"); - assertFalse(environments.put("a", "{not json")); - assertEquals("x", environments.getJson("a").get("TOKEN").getAsString()); - } - - @Test - public void rejectsNonObjectJson() { - assertFalse(environments.put("a", "[1,2,3]")); - assertFalse(environments.put("a", "\"text\"")); - assertEquals(0, environments.getJson("a").size()); - } - - @Test - public void rejectsNullAndEmptyPluginId() { - assertFalse(environments.put(null, "{}")); - assertFalse(environments.put("", "{}")); - } - - @Test - public void emptyStringClearsTheEntry() { - environments.put("a", "{\"TOKEN\":\"x\"}"); - assertTrue(environments.put("a", "")); - assertEquals(0, environments.getJson("a").size()); - assertFalse(store.json.contains("\"a\"")); - } - - @Test - public void oversizedEnvironmentIsRejected() { - String huge = "{\"k\":\"" + "x".repeat(PluginEnvironmentStore.MAX_ENVIRONMENT_BYTES) + "\"}"; - assertFalse(environments.put("a", huge)); - assertEquals(0, environments.getJson("a").size()); - } - - @Test - public void removeClearsOnlyThatPlugin() { - environments.put("a", "{\"T\":\"1\"}"); - environments.put("b", "{\"T\":\"2\"}"); - environments.remove("a"); - assertEquals(0, environments.getJson("a").size()); - assertEquals("2", environments.getJson("b").get("T").getAsString()); - } - - @Test - public void returnedObjectIsACopy() { - environments.put("a", "{\"T\":\"1\"}"); - JsonObject first = environments.getJson("a"); - first.addProperty("T", "mutated"); - assertEquals("1", environments.getJson("a").get("T").getAsString()); - } - - @Test - public void corruptBackingJsonDegradesToEmptyInsteadOfThrowing() { - store.json = "{broken"; - assertEquals(0, environments.getJson("a").size()); - assertTrue(environments.put("a", "{\"T\":\"1\"}")); - assertEquals("1", environments.getJson("a").get("T").getAsString()); - } - - private static final class MemoryStore implements PluginEnvironmentStore.Store { - private String json = ""; - - @Override - public String read() { - return json; - } - - @Override - public void write(String json) { - this.json = json == null ? "" : json; - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginEnvironmentTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginEnvironmentTest.java deleted file mode 100644 index 584da23f6e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginEnvironmentTest.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import org.junit.Test; - -/** - * 参数模板替换的边界测试。 - * - *

核心断言只有两条:**整值占位符**被替换、**缺失变量落成空串而不是保留字面量**。 - * 后者曾经是字幕源上的真实事故——没填 token 时脚本把 {@code ${ASSRT_TOKEN}} 当真实值发了出去。 - */ -public class PluginEnvironmentTest { - - private static JsonObject json(String text) { - return JsonParser.parseString(text).getAsJsonObject(); - } - - @Test - public void replacesWholeValuePlaceholder() { - JsonObject template = json("{\"token\":\"${DOUBAN_TOKEN}\"}"); - JsonObject environment = json("{\"DOUBAN_TOKEN\":\"abc123\"}"); - assertEquals("abc123", PluginEnvironment.resolve(template, environment).get("token").getAsString()); - } - - @Test - public void missingVariableBecomesEmptyStringNotLiteral() { - JsonObject template = json("{\"token\":\"${DOUBAN_TOKEN}\"}"); - JsonObject resolved = PluginEnvironment.resolve(template, new JsonObject()); - assertEquals("", resolved.get("token").getAsString()); - } - - @Test - public void nestedObjectsAndArraysAreResolved() { - JsonObject template = json("{\"headers\":{\"Cookie\":\"${COOKIE}\"},\"list\":[\"${A}\",\"literal\"]}"); - JsonObject environment = json("{\"COOKIE\":\"c=1\",\"A\":\"a\"}"); - JsonObject resolved = PluginEnvironment.resolve(template, environment); - assertEquals("c=1", resolved.getAsJsonObject("headers").get("Cookie").getAsString()); - assertEquals("a", resolved.getAsJsonArray("list").get(0).getAsString()); - assertEquals("literal", resolved.getAsJsonArray("list").get(1).getAsString()); - } - - @Test - public void partialPlaceholderIsLeftUntouched() { - // 半截占位符(字符串内插)刻意不替换:替换了用户就看不见自己漏填了。 - JsonObject template = json("{\"auth\":\"Bearer ${TOKEN}\"}"); - JsonObject resolved = PluginEnvironment.resolve(template, json("{\"TOKEN\":\"t\"}")); - assertEquals("Bearer ${TOKEN}", resolved.get("auth").getAsString()); - } - - @Test - public void nonStringValuesSurviveUntouched() { - JsonObject template = json("{\"limit\":20,\"flag\":true}"); - JsonObject resolved = PluginEnvironment.resolve(template, new JsonObject()); - assertEquals(20, resolved.get("limit").getAsInt()); - assertTrue(resolved.get("flag").getAsBoolean()); - } - - @Test - public void nullTemplateYieldsEmptyObject() { - assertEquals(0, PluginEnvironment.resolve(null, json("{\"A\":\"a\"}")).size()); - } - - @Test - public void templateIsNotMutated() { - JsonObject template = json("{\"token\":\"${T}\"}"); - PluginEnvironment.resolve(template, json("{\"T\":\"v\"}")); - assertEquals("${T}", template.get("token").getAsString()); - } - - @Test - public void placeholderNameRejectsMalformed() { - assertNull(PluginEnvironment.placeholderName(null)); - assertNull(PluginEnvironment.placeholderName("${a}")); - assertNull(PluginEnvironment.placeholderName("${A")); - assertNull(PluginEnvironment.placeholderName("${}")); - assertNull(PluginEnvironment.placeholderName("${A-B}")); - assertEquals("A_1", PluginEnvironment.placeholderName("${A_1}")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginEpisodeContextTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginEpisodeContextTest.java deleted file mode 100644 index ea10bab21f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginEpisodeContextTest.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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 com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import org.junit.Before; -import org.junit.Test; - -/** - * 剧集上下文单槽交接的边界测试。 - * - *

核心断言只有一条:名字不匹配一律读不到。这条守的是用户眼里的 - * "播放页显示了详情页那部剧的评论"——在日志里完全没有痕迹的错。 - */ -public class PluginEpisodeContextTest { - - private static final String SENTINEL = "reset.sentinel"; - - private static JsonObject json(String text) { - return JsonParser.parseString(text).getAsJsonObject(); - } - - /** - * 该类刻意没有测试钩子(生产类不为测试开口子),所以用"发布哨兵名再按名清除" - * 把唯一的那个快照槽恢复成空;单槽因此每个用例都从已知状态起步。 - */ - @Before - public void resetSnapshot() { - PluginEpisodeContext.publish(SENTINEL, json("{}")); - PluginEpisodeContext.clear(SENTINEL); - assertNull("前置条件:快照必须是空的", PluginEpisodeContext.current(SENTINEL)); - } - - @Test - public void sameActivityNameReadsBackEveryField() { - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"让子弹飞\",\"year\":\"2010\",\"episodeNumber\":3}")); - - JsonObject current = PluginEpisodeContext.current("VideoActivity"); - - assertNotNull(current); - assertEquals("让子弹飞", current.get("title").getAsString()); - assertEquals("2010", current.get("year").getAsString()); - assertEquals(3, current.get("episodeNumber").getAsInt()); - } - - @Test - public void differentActivityNameReadsNothing() { - PluginEpisodeContext.publish("TmdbDetailActivity", json("{\"title\":\"详情页的剧\"}")); - - assertNull(PluginEpisodeContext.current("VideoActivity")); - } - - @Test - public void currentReturnsADeepCopySoCallersCannotCorruptTheSnapshot() { - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"原始标题\"}")); - - JsonObject first = PluginEpisodeContext.current("VideoActivity"); - first.addProperty("title", "被调用方改坏"); - - assertEquals("原始标题", PluginEpisodeContext.current("VideoActivity").get("title").getAsString()); - } - - @Test - public void callerMutationAfterPublishDoesNotLeakIntoTheSnapshot() { - JsonObject context = json("{\"title\":\"发布时的值\"}"); - PluginEpisodeContext.publish("VideoActivity", context); - - context.addProperty("title", "发布之后才改的"); - - assertEquals("发布时的值", PluginEpisodeContext.current("VideoActivity").get("title").getAsString()); - } - - @Test - public void publishWithNullNameCreatesNoSnapshot() { - PluginEpisodeContext.publish(null, json("{\"title\":\"x\"}")); - - assertNull(PluginEpisodeContext.current("VideoActivity")); - assertNull(PluginEpisodeContext.current(SENTINEL)); - } - - @Test - public void publishWithEmptyNameCreatesNoSnapshot() { - PluginEpisodeContext.publish("", json("{\"title\":\"x\"}")); - - assertNull(PluginEpisodeContext.current("VideoActivity")); - } - - @Test - public void publishWithNullContextClearsThatName() { - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"x\"}")); - - PluginEpisodeContext.publish("VideoActivity", null); - - assertNull(PluginEpisodeContext.current("VideoActivity")); - } - - @Test - public void clearOfAnotherNameLeavesTheSnapshotAlone() { - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"播放页\"}")); - - PluginEpisodeContext.clear("TmdbDetailActivity"); - - assertNotNull(PluginEpisodeContext.current("VideoActivity")); - } - - @Test - public void clearOfTheMatchingNameDropsTheSnapshot() { - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"播放页\"}")); - - PluginEpisodeContext.clear("VideoActivity"); - - assertNull(PluginEpisodeContext.current("VideoActivity")); - } - - @Test - public void clearWithNullNameIsANoOp() { - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"播放页\"}")); - - PluginEpisodeContext.clear(null); - - assertNotNull(PluginEpisodeContext.current("VideoActivity")); - } - - @Test - public void clearWithEmptyNameIsANoOp() { - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"播放页\"}")); - - PluginEpisodeContext.clear(""); - - assertNotNull(PluginEpisodeContext.current("VideoActivity")); - } - - /** - * 记录一处 javadoc 与实现不一致:publish 的注释说名字为空时"等同清除", - * 但空名字走的是 clear(null/""),而 clear 对空名字是 no-op,所以旧快照会留下。 - * 断言按实现的真实行为写,好让将来有人改这里时看见这个问题。 - */ - @Test - public void publishWithNullNameDoesNotClearAnExistingSnapshot() { - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"留存下来的旧值\"}")); - - PluginEpisodeContext.publish(null, json("{\"title\":\"新值\"}")); - - assertEquals("留存下来的旧值", PluginEpisodeContext.current("VideoActivity").get("title").getAsString()); - } - - @Test - public void secondPublishReplacesTheFirstOneEntirely() { - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"第一次\"}")); - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"第二次\"}")); - - assertEquals("第二次", PluginEpisodeContext.current("VideoActivity").get("title").getAsString()); - } - - @Test - public void publishingANewNameEvictsTheOldOne() { - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"播放页\"}")); - - PluginEpisodeContext.publish("TmdbDetailActivity", json("{\"title\":\"详情页\"}")); - - assertNull("换页之后旧页必须读不到,否则评论会串台", PluginEpisodeContext.current("VideoActivity")); - assertEquals("详情页", PluginEpisodeContext.current("TmdbDetailActivity").get("title").getAsString()); - } - - @Test - public void currentWithNullOrEmptyNameReadsNothing() { - PluginEpisodeContext.publish("VideoActivity", json("{\"title\":\"播放页\"}")); - - assertNull(PluginEpisodeContext.current(null)); - assertNull(PluginEpisodeContext.current("")); - assertTrue("名字为空的读法不该影响已有快照", PluginEpisodeContext.current("VideoActivity").size() > 0); - } -} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginEpisodePayloadTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginEpisodePayloadTest.java deleted file mode 100644 index dc1d6566df..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginEpisodePayloadTest.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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 com.fongmi.android.tv.bean.Episode; -import com.fongmi.android.tv.bean.TmdbItem; -import com.fongmi.android.tv.bean.Vod; -import com.google.gson.JsonObject; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.Set; -import java.util.TreeSet; - -/** - * 剧集载荷组装的边界测试。 - * - *

核心断言有两条:零值省略而不是填 0(插件靠"键是否存在"区分有无评分), - * 空字符串也省略(否则插件会渲染出一行空标题)。 - */ -public class PluginEpisodePayloadTest { - - private static TmdbItem item(int tmdbId, String mediaType, String title, String subtitle, String posterUrl, String originalLanguage, String originCountry, double tmdbRating, double doubanRating) { - return new TmdbItem(tmdbId, mediaType, title, subtitle, "", posterUrl, "", "", 0.0, originalLanguage, originCountry, new ArrayList<>(), "", tmdbRating, doubanRating, ""); - } - - private static Vod vod(String name, String year, String pic) { - Vod vod = new Vod(); - vod.setName(name); - vod.setYear(year); - vod.setPic(pic); - return vod; - } - - private static Episode episode(String name, String url) { - return Episode.create(name, url); - } - - @Test - public void everyFieldAppearsWithTheValueItWasGiven() { - TmdbItem item = item(93405, "tv", "鱿鱼游戏", "Squid Game", "https://img/poster.jpg", "ko", "KR", 7.8, 6.5); - Vod vod = vod("鱿鱼游戏 第一季", "2021", "https://img/vod.jpg"); - - JsonObject payload = PluginEpisodePayload.of(item, vod, episode("第3集", "https://media/e3.m3u8")); - - assertEquals("鱿鱼游戏", payload.get("title").getAsString()); - assertEquals("Squid Game", payload.get("subtitle").getAsString()); - assertEquals("tv", payload.get("mediaType").getAsString()); - assertEquals("2021", payload.get("year").getAsString()); - assertEquals(93405, payload.get("tmdbId").getAsInt()); - assertEquals(7.8, payload.get("tmdbRating").getAsDouble(), 0.0001); - assertEquals(6.5, payload.get("doubanRating").getAsDouble(), 0.0001); - assertEquals("ko", payload.get("originalLanguage").getAsString()); - assertEquals("KR", payload.get("originCountry").getAsString()); - assertEquals("https://img/poster.jpg", payload.get("posterUrl").getAsString()); - assertEquals("第3集", payload.get("episodeName").getAsString()); - assertEquals(3, payload.get("episodeNumber").getAsInt()); - } - - @Test - public void allThreeInputsNullYieldsNull() { - assertNull(PluginEpisodePayload.of(null, null, null)); - } - - @Test - public void zeroRatingsAreOmittedInsteadOfWrittenAsZero() { - TmdbItem item = item(1, "movie", "无评分的片", "", "", "", "", 0.0, 0.0); - - JsonObject payload = PluginEpisodePayload.of(item, null, null); - - assertFalse("评分 0 必须整键消失,否则插件会渲染出 0 分", payload.has("tmdbRating")); - assertFalse(payload.has("doubanRating")); - assertEquals("无评分的片", payload.get("title").getAsString()); - } - - @Test - public void tmdbIdIsOmittedWhenNotPositive() { - TmdbItem item = item(0, "movie", "没有 tmdb id", "", "", "", "", 0.0, 0.0); - - JsonObject payload = PluginEpisodePayload.of(item, null, null); - - assertFalse(payload.has("tmdbId")); - } - - @Test - public void declaredMediaTypeWins() { - JsonObject tv = PluginEpisodePayload.of(item(1, "tv", "剧集", "", "", "", "", 0.0, 0.0), null, null); - JsonObject movie = PluginEpisodePayload.of(item(2, "movie", "电影", "", "", "", "", 0.0, 0.0), null, null); - - assertEquals("tv", tv.get("mediaType").getAsString()); - assertEquals("movie", movie.get("mediaType").getAsString()); - } - - /** - * mediaType 缺失时按约定兜底为 "movie"。 - * - *

正常链路里上游不会给出空的 mediaType,这条只是防御:真漏了就给一个确定值, - * 而不是让插件拿到空串去自己猜。 - */ - @Test - public void emptyDeclaredMediaTypeFallsBackToMovieNeverTv() { - JsonObject payload = PluginEpisodePayload.of(item(1, "", "剧集但没有类型", "", "", "", "", 0.0, 0.0), null, null); - - assertEquals("movie", payload.get("mediaType").getAsString()); - } - - @Test - public void titleAndPosterPreferTheTmdbItemOverTheVod() { - TmdbItem item = item(1, "movie", "TMDB 标题", "", "https://img/tmdb.jpg", "", "", 0.0, 0.0); - Vod vod = vod("站点标题", "2010", "https://img/site.jpg"); - - JsonObject payload = PluginEpisodePayload.of(item, vod, null); - - assertEquals("TMDB 标题", payload.get("title").getAsString()); - assertEquals("https://img/tmdb.jpg", payload.get("posterUrl").getAsString()); - } - - @Test - public void titleAndPosterFallBackToTheVodWhenTheItemHasNone() { - TmdbItem item = item(1, "movie", "", "", "", "", "", 0.0, 0.0); - Vod vod = vod("站点标题", "2010", "https://img/site.jpg"); - - JsonObject payload = PluginEpisodePayload.of(item, vod, null); - - assertEquals("站点标题", payload.get("title").getAsString()); - assertEquals("https://img/site.jpg", payload.get("posterUrl").getAsString()); - } - - @Test - public void emptyStringsAreOmittedRatherThanWrittenAsEmptyValues() { - TmdbItem item = item(1, "movie", "有标题", "", "", "", "", 0.0, 0.0); - Vod vod = vod("", "", ""); - - JsonObject payload = PluginEpisodePayload.of(item, vod, null); - - assertFalse("空标题不该写出 subtitle 键", payload.has("subtitle")); - assertFalse(payload.has("year")); - assertFalse(payload.has("originalLanguage")); - assertFalse(payload.has("originCountry")); - assertFalse(payload.has("episodeName")); - assertEquals("只该留下 title / mediaType / tmdbId 三个键", new TreeSet<>(Set.of("title", "mediaType", "tmdbId")), new TreeSet<>(payload.keySet())); - assertEquals("movie", payload.get("mediaType").getAsString()); - } - - @Test - public void episodeWithUnparsableNumberKeepsTheNameAndDropsTheNumber() { - Episode episode = episode("特别篇", "https://media/special.m3u8"); - - JsonObject payload = PluginEpisodePayload.of(null, null, episode); - - assertEquals("特别篇", payload.get("episodeName").getAsString()); - assertFalse("集数解析不出来时不能写 0", payload.has("episodeNumber")); - } - - @Test - public void episodeAloneIsEnoughToProduceAPayload() { - JsonObject payload = PluginEpisodePayload.of(null, null, episode("第12集", "https://media/e12.m3u8")); - - assertTrue(payload != null && payload.size() > 0); - assertEquals(12, payload.get("episodeNumber").getAsInt()); - } - - @Test - public void oversizedPayloadIsDroppedWholeInsteadOfTruncated() { - String huge = "x".repeat(PluginEpisodePayload.MAX_PAYLOAD_BYTES + 1); - TmdbItem item = item(1, "movie", huge, "", "", "", "", 0.0, 0.0); - - assertNull(PluginEpisodePayload.of(item, null, null)); - } - - @Test - public void payloadJustUnderTheLimitIsStillAccepted() { - String title = "x".repeat(PluginEpisodePayload.MAX_PAYLOAD_BYTES - 64); - TmdbItem item = item(1, "movie", title, "", "", "", "", 0.0, 0.0); - - JsonObject payload = PluginEpisodePayload.of(item, null, null); - - assertTrue("上限以内必须放行", payload != null && payload.get("title").getAsString().length() == title.length()); - } -} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginEventBusTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginEventBusTest.java deleted file mode 100644 index 6690c73ddd..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginEventBusTest.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * P1 验收测试:证明事件总线在正确时机派发、按身份过滤、且无订阅者时零行为。 - * - *

本测试不触碰 Android 类型(总线是纯 JVM),因此可在 {@code app/src/test} 直接跑。 - */ -public class PluginEventBusTest { - - private PluginEventBus bus; - - @Before - public void setUp() { - bus = PluginEventBus.get(); - bus.clear(); - } - - @After - public void tearDown() { - bus.clear(); - } - - @Test - public void emitWithoutSubscribersIsNoOp() { - assertEquals(0, bus.emit(PluginEventBus.EVENT_ACTIVITY_CREATED, Map.of(PluginEventBus.KEY_ACTIVITY, "VideoActivity"))); - assertFalse(bus.hasSubscribers(PluginEventBus.EVENT_ACTIVITY_CREATED)); - } - - @Test - public void activityIdentityIsCarriedInPayload() { - List> received = Collections.synchronizedList(new ArrayList<>()); - bus.subscribe(PluginEventBus.EVENT_ACTIVITY_CREATED, (event, payload) -> received.add(payload)); - - int delivered = bus.emitActivity(PluginEventBus.EVENT_ACTIVITY_CREATED, "TmdbDetailActivity"); - - assertEquals(1, delivered); - assertEquals(1, received.size()); - assertEquals("TmdbDetailActivity", received.get(0).get(PluginEventBus.KEY_ACTIVITY)); - } - - @Test - public void subscriberOnlyReceivesItsOwnEvent() { - AtomicInteger created = new AtomicInteger(); - AtomicInteger destroyed = new AtomicInteger(); - bus.subscribe(PluginEventBus.EVENT_ACTIVITY_CREATED, (event, payload) -> created.incrementAndGet()); - bus.subscribe(PluginEventBus.EVENT_ACTIVITY_DESTROYED, (event, payload) -> destroyed.incrementAndGet()); - - bus.emitActivity(PluginEventBus.EVENT_ACTIVITY_CREATED, "VideoActivity"); - - assertEquals(1, created.get()); - assertEquals(0, destroyed.get()); - } - - @Test - public void throwingSubscriberDoesNotBreakHostOrOtherSubscribers() { - AtomicInteger healthy = new AtomicInteger(); - bus.subscribe(PluginEventBus.EVENT_UI_CONTENT_SHOWN, (event, payload) -> { - throw new IllegalStateException("plugin boom"); - }); - bus.subscribe(PluginEventBus.EVENT_UI_CONTENT_SHOWN, (event, payload) -> healthy.incrementAndGet()); - - int delivered = bus.emitActivity(PluginEventBus.EVENT_UI_CONTENT_SHOWN, "HomeActivity"); - - assertEquals(1, delivered); - assertEquals(1, healthy.get()); - } - - @Test - public void unsubscribeStopsDelivery() { - AtomicInteger count = new AtomicInteger(); - PluginEventBus.Listener listener = (event, payload) -> count.incrementAndGet(); - bus.subscribe(PluginEventBus.EVENT_APP_FOREGROUND, listener); - - bus.emit(PluginEventBus.EVENT_APP_FOREGROUND, null); - bus.unsubscribe(PluginEventBus.EVENT_APP_FOREGROUND, listener); - bus.emit(PluginEventBus.EVENT_APP_FOREGROUND, null); - - assertEquals(1, count.get()); - assertFalse(bus.hasSubscribers(PluginEventBus.EVENT_APP_FOREGROUND)); - } - - @Test - public void subscribeAllReceivesEveryEvent() { - List events = Collections.synchronizedList(new ArrayList<>()); - bus.subscribeAll((event, payload) -> events.add(event)); - - bus.emitActivity(PluginEventBus.EVENT_ACTIVITY_CREATED, "A"); - bus.emit(PluginEventBus.EVENT_APP_BACKGROUND, null); - - assertEquals(2, events.size()); - assertTrue(events.contains(PluginEventBus.EVENT_ACTIVITY_CREATED)); - assertTrue(events.contains(PluginEventBus.EVENT_APP_BACKGROUND)); - } - - @Test - public void blankSubscriptionIsRejected() { - PluginEventBus.Listener listener = (event, payload) -> { - }; - assertFalse(bus.subscribe("", listener)); - assertFalse(bus.subscribe(null, listener)); - assertFalse(bus.subscribe(PluginEventBus.EVENT_APP_FOREGROUND, null)); - assertFalse(bus.subscribeAll(null)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginFullscreenWiringTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginFullscreenWiringTest.java deleted file mode 100644 index 0d1fca374c..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginFullscreenWiringTest.java +++ /dev/null @@ -1,214 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Set; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 全屏/沉浸态接线的守卫(纯 JVM,读源码文本 + 真实能力门禁调用)。 - * - *

为什么钉文本而不是跑行为。三条接线都发生在真实 Activity 的生命周期回调里: - * PiP 回调要系统 PiP 栈、{@code setFullscreen} 要真实窗口、ROW chip 的焦点态要 Android - * {@code GradientDrawable} 与资源({@code app/src/test} 没有 Robolectric)。真机用例只能证明 - * "点了会变",证明不了"变了会重算"——而这里要守的恰恰是"判据翻转后有没有人重算"。 - * - *

三个缺陷都是静默的:PiP 下入口按旧形态留着、事件报"全屏"而入口可见、电视上选中了看不出来。 - * 用户不会看到崩溃,只会觉得插件坏了。 - */ -public class PluginFullscreenWiringTest { - - private static final String VIDEO_ACTIVITY = "VideoActivity.java"; - private static final String DETAIL_ACTIVITY = "TmdbDetailActivity.java"; - - private static final String PIP_CALLBACK_HEAD = - "void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, @NonNull Configuration newConfig)"; - private static final String PIP_ASSIGN = "inPip = isInPictureInPictureMode;"; - /** 创建期那处必须问平台,因此带调用的括号,与回调里的形参赋值是两条不同的串。 */ - private static final String PIP_INIT_ASSIGN = "inPip = isInPictureInPictureMode();"; - - @Test - public void videoActivityRecomputesPluginEntrancesOnPipChange() throws Exception { - String activity = videoActivity(); - String pip = methodBody(activity, PIP_CALLBACK_HEAD); - - assertTrue("VideoActivity 的 PiP 回调必须重算插件入口与事件", pip.contains("notifyPluginFullscreen();")); - assertTrue("重算必须同时刷新渲染", methodBody(activity, "private void notifyPluginFullscreen()") - .contains("refreshRender(")); - } - - /** - * 判据必须读回调告知的状态快照,而不是在读判据时现场问平台。 - * - *

这不是时序问题。平台在派发 {@code onPictureInPictureModeChanged} 之前就已写好 - * 画中画状态,回调里问平台也是新值。收益在于"这一次形态变更只有一个快照":重算入口与广播 - * 事件必须看到同一份形态,现场各问一次就可能落在不同时点(本页 PiP 回调还要先翻转内嵌 - * 布局)。断的是写法而非行为,作 OEM 防御,不要当行为回归锁看。 - */ - @Test - public void immersiveJudgementReadsTheCallbackStateInsteadOfThePlatformApi() throws Exception { - String video = videoActivity(); - String videoImmersive = methodBody(video, "public boolean isImmersive()"); - assertTrue("播放页判据必须读 inPip 字段", videoImmersive.contains("!inPip")); - assertFalse("判据不许在读的时候问平台", videoImmersive.contains("isInPictureInPictureMode()")); - assertTrue("回调第一句必须用形参刷新该字段", - afterSuper(methodBody(video, PIP_CALLBACK_HEAD)).startsWith(PIP_ASSIGN)); - - String detail = detailActivity(); - String detailImmersive = methodBody(detail, "public boolean isImmersive()"); - assertTrue("详情页判据必须读 inPip 字段", detailImmersive.contains("!inPip")); - assertFalse("判据不许在读的时候问平台", detailImmersive.contains("isInPictureInPictureMode()")); - assertTrue("回调第一句必须用形参刷新该字段", - afterSuper(methodBody(detail, PIP_CALLBACK_HEAD)).startsWith(PIP_ASSIGN)); - // 布局判据是另一件事,不许被顺手删掉。 - assertTrue("内嵌全屏布局判据必须保留", detailImmersive.contains("!inlinePiPLayout")); - } - - /** - * 判据的初始值必须取自平台真实状态,而不是字段默认值。 - * - *

进程在画中画里被回收后重建时,系统不会再走一次 {@code onPictureInPictureModeChanged}; - * 字段停在默认 false,`isImmersive()` 就会把仍在 PiP 的这一页报成沉浸态,入口在错误的形态 - * 下整批隐藏。因此初始化必须落在页面创建期,且与回调里的赋值是两处、不是同一行。 - */ - @Test - public void pipStateIsInitialisedAtPageCreationNotOnlyInTheCallback() throws Exception { - for (String source : new String[]{videoActivity(), detailActivity()}) { - String initView = methodBody(source, "protected void initView(Bundle savedInstanceState)"); - int initAssign = initView.indexOf(PIP_INIT_ASSIGN); - assertTrue("创建期必须用平台真实状态初始化 inPip", initAssign >= 0); - - // 顺序断言:创建期那处必须排在回调定义之前,光靠"回调里有赋"过不了这一关。 - int initInSource = source.indexOf(PIP_INIT_ASSIGN); - int callbackDef = source.indexOf(PIP_CALLBACK_HEAD); - assertTrue("回调定义必须存在", callbackDef >= 0); - assertTrue("初始化必须排在回调定义之前,说明它属于创建期而不是写在回调里", - initInSource >= 0 && initInSource < callbackDef); - // 两处各一次:创建期取平台状态,回调取形参。只剩一处说明又退化成单点写入。 - assertEquals("创建期初始化只能一处", 1, countOf(source, PIP_INIT_ASSIGN)); - assertEquals("回调首句赋值只能一处", 1, countOf(source, PIP_ASSIGN)); - } - } - - @Test - public void detailActivityRecomputesOncePerPipPathAfterTheLayoutFlip() throws Exception { - String pip = methodBody(detailActivity(), PIP_CALLBACK_HEAD); - - // 三条路径各自带一次重算即可;不锁总数量——将来在回调里加一次合法重算不该让这条误红。 - assertTrue("回调里必须有重算", pip.contains("refreshPluginEntrances();")); - - // 非内嵌形态:没有布局翻转,重算就在自己的 return 之前。 - int nonInline = pip.indexOf("if (!isInlinePlayerMode()) {"); - assertTrue("非内嵌早退分支必须还在", nonInline >= 0); - int nonInlineReturn = pip.indexOf("return;", nonInline); - int nonInlineRefresh = pip.indexOf("refreshPluginEntrances();", nonInline); - assertTrue("非内嵌路径必须重算", nonInlineRefresh > nonInline && nonInlineRefresh < nonInlineReturn); - - // 进 PiP:isImmersive() 读 inlinePiPLayout,必须等它翻转之后再算。 - int enterLayout = pip.indexOf("enterInlinePiPLayout();"); - int enterRefresh = pip.indexOf("refreshPluginEntrances();", enterLayout); - int enterReturn = pip.indexOf("return;", enterLayout); - assertTrue("进入 PiP 分支必须重算", enterRefresh > enterLayout); - assertTrue("进入 PiP 的重算必须在 enterInlinePiPLayout() 之后、分支 return 之前", - enterRefresh > enterLayout && enterRefresh < enterReturn); - - // 出 PiP:同理,exitInlinePiPLayout() 之后。 - int exitLayout = pip.indexOf("exitInlinePiPLayout();"); - int exitRefresh = pip.indexOf("refreshPluginEntrances();", exitLayout); - assertTrue("退出 PiP 的重算必须在 exitInlinePiPLayout() 之后", exitRefresh > exitLayout); - } - - @Test - public void fullscreenEventReportsImmersiveInsteadOfTheRawField() throws Exception { - String videoEvent = methodBody(videoActivity(), "private void notifyPluginFullscreen()"); - assertTrue("播放页事件值必须取 isImmersive()", videoEvent.contains("playbackFullscreen(this, isImmersive())")); - assertFalse("不许再发裸 fullscreen 字段", - videoEvent.contains("playbackFullscreen(this, fullscreen)") - || videoEvent.contains("playbackFullscreen(this, this.fullscreen)")); - - String detailEvent = methodBody(detailActivity(), "private void refreshPluginEntrances()"); - assertTrue("详情页事件值必须取 isImmersive()", detailEvent.contains("playbackFullscreen(this, isImmersive())")); - assertFalse("不许再发裸 inlineFullscreen 字段", - detailEvent.contains("playbackFullscreen(this, inlineFullscreen)")); - // 电视端拦截是既有契约,不许在改口径时顺手删掉。 - assertTrue("详情页事件仍必须在电视端被拦截", detailEvent.contains("if (Util.isLeanback()) return;")); - } - - @Test - public void playbackFullscreenStaysUnderTheEventsPermission() { - // 真实 API 调用而不是文本匹配:这条口径决定"只申请了剧集数据的插件收不到全屏事件"。 - assertTrue(PluginCapabilityRegistry.allowsEvent( - Set.of(PluginCapabilityRegistry.PERMISSION_EVENTS), - PluginEventBus.EVENT_PLAYBACK_FULLSCREEN)); - assertFalse(PluginCapabilityRegistry.allowsEvent( - Set.of(PluginCapabilityRegistry.PERMISSION_EPISODE_DATA), - PluginEventBus.EVENT_PLAYBACK_FULLSCREEN)); - } - - @Test - public void rowChipBackgroundFollowsFocus() throws Exception { - String factory = read("src", "main", "java", "com", "fongmi", "android", "tv", "plugin", "PluginChipFactory.java"); - - assertTrue("ROW chip 必须挂焦点态背景切换", - factory.contains("chip.setOnFocusChangeListener((view, focused) -> view.setBackground(rowBackground(context, focused)))")); - assertTrue("初始背景必须是未聚焦态", factory.contains("chip.setBackground(rowBackground(context, false))")); - assertFalse("不许留着不区分焦点态的无参调用", factory.contains("rowBackground(context)")); - - // 这里不调 rowBackground(...) 取实例比 drawable:android.jar 在纯 JVM 单测里是 stub, - // GradientDrawable 一碰就抛 "Stub!",因此两条分支的差异只能钉在源码字面上。 - String rowBackground = methodBody(factory, - "static GradientDrawable rowBackground(Context context, boolean focused)"); - assertTrue("未聚焦底色必须仍是旧实现的 0xB3101820", rowBackground.contains(": 0xB3101820")); - assertTrue("聚焦态必须换底色,否则电视上依然看不出选中", rowBackground.contains("focused ? 0xE0")); - assertTrue("未聚焦描边必须仍是 1dp,聚焦才加粗", rowBackground.contains("focused ? 2 : 1")); - assertFalse("圆角不许跟着焦点变,未聚焦形态必须逐像素等同", - rowBackground.contains("focused ? 8") || rowBackground.contains("focused ? dp")); - } - - private static String videoActivity() throws Exception { - return read("src", "mobile", "java", "com", "fongmi", "android", "tv", "ui", "activity", VIDEO_ACTIVITY); - } - - private static String detailActivity() throws Exception { - return read("src", "main", "java", "com", "fongmi", "android", "tv", "ui", "activity", DETAIL_ACTIVITY); - } - - /** 取 {@code super.xxx(...);} 之后的正文:用来钉"回调第一句就刷新判据"。 */ - private static String afterSuper(String body) { - int superCall = body.indexOf("super."); - if (superCall < 0) return body.trim(); - int end = body.indexOf(';', superCall); - return body.substring(end + 1).trim(); - } - - private static int countOf(String source, String needle) { - int count = 0; - for (int i = source.indexOf(needle); i >= 0; i = source.indexOf(needle, i + needle.length())) count++; - return count; - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("Missing method: " + signature, start >= 0); - int open = source.indexOf('{', start); - assertTrue("Missing method body: " + signature, open >= 0); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char value = source.charAt(i); - if (value == '{') depth++; - if (value == '}' && --depth == 0) return source.substring(open + 1, i); - } - throw new AssertionError("Unclosed method: " + signature); - } - - private static String read(String... parts) throws Exception { - Path root = Files.exists(Path.of("src", "main")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(Path.of("", parts)), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginHostTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginHostTest.java deleted file mode 100644 index 2fe0ca205d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginHostTest.java +++ /dev/null @@ -1,337 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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.After; -import org.junit.Before; -import org.junit.Test; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * 插件宿主的行为测试:权限门禁、故障隔离、零插件零开销。 - * - *

用假运行时({@link FakeRuntime})而非 QuickJS——后者要加载原生库,普通 JVM 跑不了。 - * 这也正是 {@link PluginRuntime} 保持纯 JVM 的原因。 - */ -public class PluginHostTest { - - private PluginEventBus bus; - private PluginAuditLog audit; - private PluginRuntimeFactory factory; - private PluginHost host; - private FakeRuntime runtime; - - @Before - public void setUp() { - bus = PluginEventBus.get(); - bus.clear(); - audit = PluginAuditLog.get(); - audit.clear(); - runtime = new FakeRuntime(); - factory = new PluginRuntimeFactory().register("js", runtime); - host = new PluginHost(factory, bus, audit); - } - - @After - public void tearDown() { - host.unloadAll(); - bus.clear(); - audit.clear(); - } - - private static String manifestJson(String id, String permissions, String events) { - return """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "%s", - "name": "%s", - "sdk": 1, - "runtime": { "api": "js", "entry": "p.js" }, - "events": %s, - "permissions": %s - } - """.formatted(id, id, events, permissions); - } - - private PluginManifest manifest(String id, String permissions, String events) { - PluginManifest parsed = PluginManifest.parse(manifestJson(id, permissions, events)); - assertNotNull("manifest should parse: " + id, parsed); - return parsed; - } - - // ---------- 装载 ---------- - - @Test - public void loadsPluginAndRegistersWithBus() { - PluginManifest m = manifest("a", "[\"plugin.events\"]", "[\"ui.contentShown\"]"); - List loaded = host.load(List.of(m), null); - - assertEquals(1, loaded.size()); - assertTrue(host.isLoaded("a")); - assertTrue(bus.hasSubscribers(PluginEventBus.EVENT_UI_CONTENT_SHOWN)); - } - - @Test - public void failedInitIsNotLoadedAndIsAudited() { - runtime.failInit = true; - PluginManifest m = manifest("a", "[\"plugin.events\"]", "[\"ui.contentShown\"]"); - List loaded = host.load(List.of(m), null); - - assertTrue(loaded.isEmpty()); - assertFalse(host.isLoaded("a")); - assertEquals(1, audit.entriesFor("a").size()); - assertEquals("blocked", audit.entriesFor("a").get(0).outcome()); - } - - @Test - public void unsupportedRuntimeFailsClosedAndIsAudited() { - // 清单声明 py,但只注册了 js - String json = manifestJson("p", "[\"plugin.events\"]", "[\"ui.contentShown\"]") - .replace("\"api\": \"js\"", "\"api\": \"py\""); - PluginManifest m = PluginManifest.parse(json); - assertNotNull(m); - - List loaded = host.load(List.of(m), null); - - assertTrue("unsupported runtime must not load", loaded.isEmpty()); - assertEquals(1, audit.entriesFor("p").size()); - assertTrue(audit.entriesFor("p").get(0).path().contains("py")); - } - - @Test - public void noPluginsMeansNoBusSubscription() { - host.load(List.of(), null); - assertFalse(bus.hasSubscribers(PluginEventBus.EVENT_UI_CONTENT_SHOWN)); - assertEquals(0, host.size()); - } - - // ---------- 权限门禁(安全边界 1)---------- - - @Test - public void eventNotDeclaredIsNeverDelivered() { - PluginManifest m = manifest("a", "[\"plugin.events\"]", "[\"ui.contentShown\"]"); - host.load(List.of(m), null); - runtime.events.clear(); - - bus.emitActivity(PluginEventBus.EVENT_ACTIVITY_CREATED, "VideoActivity"); - - assertTrue("undeclared event must not reach plugin", runtime.events.isEmpty()); - } - - @Test - public void eventDeclaredButUnauthorizedIsNeverDelivered() { - // 声明了 activity.created,但只给了 plugin.data.episode 权限(不覆盖该事件) - PluginManifest m = manifest("a", "[\"plugin.data.episode\"]", "[\"activity.created\"]"); - host.load(List.of(m), null); - runtime.events.clear(); - - bus.emitActivity(PluginEventBus.EVENT_ACTIVITY_CREATED, "VideoActivity"); - - assertTrue("event without covering permission must not reach plugin", runtime.events.isEmpty()); - } - - @Test - public void declaredAndAuthorizedEventIsDelivered() { - PluginManifest m = manifest("a", "[\"plugin.events\"]", "[\"ui.contentShown\"]"); - host.load(List.of(m), null); - runtime.events.clear(); - - bus.emitActivity(PluginEventBus.EVENT_UI_CONTENT_SHOWN, "TmdbDetailActivity"); - - assertEquals(1, runtime.events.size()); - assertTrue(runtime.events.get(0).contains("ui.contentShown")); - assertTrue("payload must carry activity identity", runtime.events.get(0).contains("TmdbDetailActivity")); - } - - @Test - public void actionWithoutSlotPermissionIsBlockedAndAudited() { - // 清单里没有 ui 槽位,因此 action 无法映射到槽位权限;这里构造有槽位但权限被剥的情况 - String json = """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "a", - "name": "a", - "sdk": 1, - "runtime": { "api": "js", "entry": "p.js" }, - "events": ["ui.contentShown"], - "ui": [ { "slot": "floating", "label": "X", "action": "open" } ], - "permissions": ["plugin.events", "ui.floating"] - } - """; - PluginManifest m = PluginManifest.parse(json); - assertNotNull(m); - host.load(List.of(m), null); - - String result = host.action("a", "open", "{}"); - - assertEquals("ok", result); - assertEquals(1, runtime.actions.size()); - } - - // ---------- 故障隔离(安全边界 2)---------- - - @Test - public void throwingPluginIsQuarantinedNotPropagated() { - PluginManifest m = manifest("a", "[\"plugin.events\"]", "[\"ui.contentShown\"]"); - host.load(List.of(m), null); - runtime.failEvent = true; - - // 关键断言:这次调用不能抛异常出来 - bus.emitActivity(PluginEventBus.EVENT_UI_CONTENT_SHOWN, "HomeActivity"); - - assertTrue(host.failedPlugins().contains("a")); - assertEquals(1, audit.entriesFor("a").size()); - assertEquals("error", audit.entriesFor("a").get(0).outcome()); - } - - @Test - public void quarantinedPluginStopsReceivingEvents() { - PluginManifest m = manifest("a", "[\"plugin.events\"]", "[\"ui.contentShown\"]"); - host.load(List.of(m), null); - runtime.failEvent = true; - bus.emitActivity(PluginEventBus.EVENT_UI_CONTENT_SHOWN, "HomeActivity"); - runtime.events.clear(); - - // 第二次事件:已被隔离,不应再尝试 - bus.emitActivity(PluginEventBus.EVENT_UI_CONTENT_SHOWN, "HomeActivity"); - - assertTrue("quarantined plugin must not be retried", runtime.events.isEmpty()); - assertEquals(1, audit.entriesFor("a").size()); - } - - @Test - public void oneBadPluginDoesNotAffectAnother() { - PluginManifest bad = manifest("bad", "[\"plugin.events\"]", "[\"ui.contentShown\"]"); - PluginManifest good = manifest("good", "[\"plugin.events\"]", "[\"ui.contentShown\"]"); - - // 让第一个插件的事件派发抛错,第二个正常 - runtime.failEventFor = Set.of("bad"); - host.load(List.of(bad, good), null); - runtime.events.clear(); - - bus.emitActivity(PluginEventBus.EVENT_UI_CONTENT_SHOWN, "HomeActivity"); - - assertTrue(host.failedPlugins().contains("bad")); - assertFalse(host.failedPlugins().contains("good")); - assertEquals(1, runtime.events.size()); - assertTrue(runtime.events.get(0).contains("good")); - } - - @Test - public void actionOnQuarantinedPluginReturnsNull() { - PluginManifest m = manifest("a", "[\"plugin.events\"]", "[\"ui.contentShown\"]"); - host.load(List.of(m), null); - runtime.failEvent = true; - bus.emitActivity(PluginEventBus.EVENT_UI_CONTENT_SHOWN, "HomeActivity"); - - assertNull(host.action("a", "anything", "{}")); - } - - // ---------- 生命周期 ---------- - - @Test - public void unloadAllDestroysRuntimesAndDetaches() { - PluginManifest m = manifest("a", "[\"plugin.events\"]", "[\"ui.contentShown\"]"); - host.load(List.of(m), null); - - host.unloadAll(); - - assertEquals(1, runtime.destroyed.size()); - assertEquals(0, host.size()); - assertFalse(bus.hasSubscribers(PluginEventBus.EVENT_UI_CONTENT_SHOWN)); - } - - @Test - public void reloadReplacesPreviousPlugins() { - host.load(List.of(manifest("a", "[\"plugin.events\"]", "[\"ui.contentShown\"]")), null); - host.load(List.of(manifest("b", "[\"plugin.events\"]", "[\"ui.contentShown\"]")), null); - - assertFalse(host.isLoaded("a")); - assertTrue(host.isLoaded("b")); - assertEquals(1, runtime.destroyed.size()); - } - - @Test - public void subscribersOfReflectsPermissionAndDeclaration() { - PluginManifest m = manifest("a", "[\"plugin.events\"]", "[\"ui.contentShown\"]"); - host.load(List.of(m), null); - - assertEquals(List.of("a"), host.subscribersOf(PluginEventBus.EVENT_UI_CONTENT_SHOWN)); - assertTrue(host.subscribersOf(PluginEventBus.EVENT_ACTIVITY_CREATED).isEmpty()); - assertTrue(host.subscribersOf(null).isEmpty()); - } - - @Test - public void uiSlotsExcludeQuarantinedPlugins() { - String json = """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "a", - "name": "a", - "sdk": 1, - "runtime": { "api": "js", "entry": "p.js" }, - "events": ["ui.contentShown"], - "ui": [ { "slot": "floating", "label": "X", "action": "open" } ], - "permissions": ["plugin.events", "ui.floating"] - } - """; - PluginManifest m = PluginManifest.parse(json); - host.load(List.of(m), null); - assertEquals(1, host.uiSlots().size()); - - runtime.failEvent = true; - bus.emitActivity(PluginEventBus.EVENT_UI_CONTENT_SHOWN, "HomeActivity"); - - assertTrue("quarantined plugin must not contribute UI", host.uiSlots().isEmpty()); - } - - // ---------- 假运行时 ---------- - - static final class FakeRuntime implements PluginRuntime { - final List events = Collections.synchronizedList(new ArrayList<>()); - final List actions = Collections.synchronizedList(new ArrayList<>()); - final List destroyed = Collections.synchronizedList(new ArrayList<>()); - final AtomicInteger inits = new AtomicInteger(); - volatile boolean failInit; - volatile boolean failEvent; - volatile Set failEventFor = Set.of(); - - @Override - public String init(String pluginId, String entry, String config) { - inits.incrementAndGet(); - if (failInit) throw new IllegalStateException("init boom"); - return "ok"; - } - - @Override - public String onEvent(String pluginId, String event, String payload) { - if (failEvent || failEventFor.contains(pluginId)) throw new IllegalStateException("event boom"); - events.add(pluginId + "|" + event + "|" + payload); - return "ok"; - } - - @Override - public String action(String pluginId, String action, String payload) { - actions.add(pluginId + "|" + action); - return "ok"; - } - - @Override - public void destroy(String pluginId) { - destroyed.add(pluginId); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginHttpCacheTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginHttpCacheTest.java deleted file mode 100644 index faf577e7ad..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginHttpCacheTest.java +++ /dev/null @@ -1,254 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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 com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import mockwebserver3.MockResponse; -import mockwebserver3.MockWebServer; - -/** - * 宿主侧 HTTP 缓存测试。 - * - *

全部用真实 HTTP 服务器({@code MockWebServer})加请求计数:"命中缓存"这件事唯一的证据就是 - * 服务端没收到第二次请求,断言返回体是证明不了的。 - * - *

缓存根目录通过 {@link PluginHttpCache#overrideRootForTest(File)} 注入临时目录,因为 - * {@code Path.cache()} 依赖 {@code Init.context()},纯 JVM 单测里拿不到。 - */ -public class PluginHttpCacheTest { - - private MockWebServer server; - private File cache; - - @Before - public void setUp() throws IOException { - server = new MockWebServer(); - server.start(); - cache = cacheDir("plugin-http-cache"); - PluginHttpCache.overrideRootForTest(cache); - PluginAuditLog.get().clear(); - } - - @After - public void tearDown() throws Exception { - if (server != null) server.close(); - PluginHttpCache.overrideRootForTest(null); - PluginAuditLog.get().clear(); - delete(cache); - } - - /** AC1:不传 ttl 与传 0 都必须与今天的六参调用逐字节相同——每次都真的发出去。 */ - @Test - public void omittedAndZeroTtlKeepBehavingLikeTodaysCall() throws Exception { - server.enqueue(new MockResponse.Builder().code(200).body("first").build()); - server.enqueue(new MockResponse.Builder().code(200).body("second").build()); - String url = server.url("/subject").toString(); - - assertEquals("first", body(PluginNetBridge.request("p", "GET", url, null, null, 15))); - assertEquals("second", body(PluginNetBridge.request("p", "GET", url, null, null, 15, 0))); - - assertEquals(2, server.getRequestCount()); - assertNull("ttl 为 0 时一个缓存文件都不该存在", onlyFile(cache)); - } - - /** AC1:ttl 的解析必须容忍脚本传来的字符串、小数与越界值。 */ - @Test - public void ttlParsingAcceptsNumbersAndRejectsWhatIsNotOne() { - assertEquals(0, PluginHttpCache.ttlSeconds(null)); - assertEquals(0, PluginHttpCache.ttlSeconds("")); - assertEquals(0, PluginHttpCache.ttlSeconds(" ")); - assertEquals(0, PluginHttpCache.ttlSeconds("abc")); - assertEquals(0, PluginHttpCache.ttlSeconds("0")); - assertEquals(0, PluginHttpCache.ttlSeconds("-60")); - assertEquals(1, PluginHttpCache.ttlSeconds("0.5")); - assertEquals(120, PluginHttpCache.ttlSeconds("120")); - assertEquals(120, PluginHttpCache.ttlSeconds("120.9")); - assertEquals(PluginHttpCache.MAX_TTL_SECONDS, PluginHttpCache.ttlSeconds("999999999")); - } - - /** AC2 + AC8:命中时服务端收不到请求,审计记 outcome=cache 且状态码如实是 200。 */ - @Test - public void cacheHitSkipsTheNetworkAndIsAuditedAsCache() throws Exception { - server.enqueue(new MockResponse.Builder().code(200).body("{\"rating\":8.7}").build()); - String url = server.url("/subject/1").toString(); - - String first = PluginNetBridge.request("p", "GET", url, null, null, 15, 60); - String second = PluginNetBridge.request("p", "GET", url, null, null, 15, 60); - - assertEquals(1, server.getRequestCount()); - assertEquals(200, status(second)); - assertEquals(body(first), body(second)); - PluginAuditLog.Entry hit = lastEntry(); - assertEquals("cache", hit.outcome()); - assertEquals(200, hit.status()); - assertEquals("GET", hit.method()); - } - - /** AC3:过期条目必须重新请求,并用新响应覆盖旧内容。 */ - @Test - public void expiredEntryIsRequestedAgainAndOverwritten() throws Exception { - server.enqueue(new MockResponse.Builder().code(200).body("old").build()); - server.enqueue(new MockResponse.Builder().code(200).body("fresh").build()); - String url = server.url("/subject/2").toString(); - - assertEquals("old", body(PluginNetBridge.request("p", "GET", url, null, null, 15, 60))); - File entry = PluginHttpCache.file("p", PluginHttpCache.key("p", "GET", url, null, null)); - assertTrue("第一次请求必须落盘", entry.isFile()); - assertTrue(entry.setLastModified(System.currentTimeMillis() - 120_000L)); - - assertEquals("fresh", body(PluginNetBridge.request("p", "GET", url, null, null, 15, 60))); - - assertEquals(2, server.getRequestCount()); - assertEquals("fresh", new String(Files.readAllBytes(entry.toPath()), StandardCharsets.UTF_8)); - } - - /** AC4:POST 带 ttl 也不读不写缓存——重放一个写请求会真的改变服务端状态。 */ - @Test - public void postWithTtlNeverTouchesTheCache() throws Exception { - server.enqueue(new MockResponse.Builder().code(200).body("one").build()); - server.enqueue(new MockResponse.Builder().code(200).body("two").build()); - String url = server.url("/interest").toString(); - - assertEquals("one", body(PluginNetBridge.request("p", "POST", url, null, "{}", 15, 600))); - assertEquals("two", body(PluginNetBridge.request("p", "POST", url, null, "{}", 15, 600))); - - assertEquals(2, server.getRequestCount()); - assertNull("POST 不该留下任何缓存文件", onlyFile(cache)); - } - - /** AC5:两个插件请求同一个 URL 必须各自回源,否则 A 的响应会被 B 当成自己的。 */ - @Test - public void differentPluginsNeverShareAnEntry() throws Exception { - server.enqueue(new MockResponse.Builder().code(200).body("from-a").build()); - server.enqueue(new MockResponse.Builder().code(200).body("from-b").build()); - String url = server.url("/subject/3").toString(); - - assertEquals("from-a", body(PluginNetBridge.request("plugin-a", "GET", url, null, null, 15, 600))); - assertEquals("from-b", body(PluginNetBridge.request("plugin-b", "GET", url, null, null, 15, 600))); - - assertEquals(2, server.getRequestCount()); - assertEquals("from-a", body(PluginNetBridge.request("plugin-a", "GET", url, null, null, 15, 600))); - assertEquals("from-b", body(PluginNetBridge.request("plugin-b", "GET", url, null, null, 15, 600))); - assertEquals("两个插件各自的缓存互不串用", 2, server.getRequestCount()); - } - - /** AC6:0 字节、超上限、读不动的文件都只能被静默当成未命中。 */ - @Test - public void corruptEntriesAreTreatedAsMissesAndNeverThrow() throws Exception { - server.enqueue(new MockResponse.Builder().code(200).body("v1").build()); - server.enqueue(new MockResponse.Builder().code(200).body("v2").build()); - server.enqueue(new MockResponse.Builder().code(200).body("v3").build()); - String url = server.url("/subject/4").toString(); - File entry = PluginHttpCache.file("p", PluginHttpCache.key("p", "GET", url, null, null)); - assertNotNull(entry.getParentFile()); - assertTrue(entry.getParentFile().mkdirs() || entry.getParentFile().isDirectory()); - - Files.write(entry.toPath(), new byte[0]); - assertEquals("v1", body(PluginNetBridge.request("p", "GET", url, null, null, 15, 600))); - - Files.write(entry.toPath(), new byte[PluginNetBridge.MAX_RESPONSE_BYTES + 1]); - assertEquals("v2", body(PluginNetBridge.request("p", "GET", url, null, null, 15, 600))); - - // 目录冒充文件:读一定抛 IOException,但异常绝不许冒到脚本。 - assertTrue(entry.delete()); - assertTrue(entry.mkdirs()); - assertEquals("v3", body(PluginNetBridge.request("p", "GET", url, null, null, 15, 600))); - - assertEquals(3, server.getRequestCount()); - } - - /** 只有 200 值得复用:一个 403 存下来会让插件在 TTL 内反复看到同一个失败。 */ - @Test - public void nonOkStatusIsNeverCached() throws Exception { - server.enqueue(new MockResponse.Builder().code(403).body("denied").build()); - server.enqueue(new MockResponse.Builder().code(200).body("granted").build()); - String url = server.url("/subject/5").toString(); - - assertEquals(403, status(PluginNetBridge.request("p", "GET", url, null, null, 15, 600))); - assertNull(onlyFile(cache)); - assertEquals("granted", body(PluginNetBridge.request("p", "GET", url, null, null, 15, 600))); - } - - /** 头集合不变、只是书写顺序不同,不该变成两次网络请求。 */ - @Test - public void headerOrderDoesNotSplitTheEntry() throws Exception { - server.enqueue(new MockResponse.Builder().code(200).body("once").build()); - String url = server.url("/subject/6").toString(); - - assertEquals("once", body(PluginNetBridge.request("p", "GET", url, - "{\"Referer\":\"https://movie.douban.com/\",\"User-Agent\":\"ua\"}", null, 15, 600))); - assertEquals("once", body(PluginNetBridge.request("p", "GET", url, - "{\"user-agent\":\"ua\",\"referer\":\"https://movie.douban.com/\"}", null, 15, 600))); - - assertEquals(1, server.getRequestCount()); - } - - /** 缓存目录名必须能容纳任意清单 id:只做安全化,绝不把 id 当路径用。 */ - @Test - public void pluginIdIsSanitisedBeforeItBecomesADirectoryName() { - File hostile = PluginHttpCache.file("../../etc/passwd", "hash"); - assertEquals(new File(cache, ".._.._etc_passwd"), hostile.getParentFile()); - assertEquals(new File(cache, "_"), PluginHttpCache.file(null, "hash").getParentFile()); - } - - private static int status(String text) { - return parse(text).get("status").getAsInt(); - } - - private static String body(String text) { - return parse(text).get("body").getAsString(); - } - - private static JsonObject parse(String text) { - return JsonParser.parseString(text).getAsJsonObject(); - } - - private static PluginAuditLog.Entry lastEntry() { - return PluginAuditLog.get().entries().get(PluginAuditLog.get().size() - 1); - } - - /** 缓存根目录下的第一个普通文件;用来断言「一个文件都没写」。 */ - private static File onlyFile(File root) { - File[] files = root.listFiles(); - if (files == null || files.length == 0) return null; - for (File file : files) { - if (file.isFile()) return file; - File[] nested = file.listFiles(); - if (nested != null) { - for (File inner : nested) { - if (inner.isFile()) return inner; - } - } - } - return null; - } - - private static File cacheDir(String prefix) throws IOException { - Path base = Path.of(System.getProperty("java.io.tmpdir")).toRealPath(); - return Files.createTempDirectory(base, prefix).toFile(); - } - - private static void delete(File file) { - if (file == null || !file.exists()) return; - File[] children = file.listFiles(); - if (children != null) { - for (File child : children) delete(child); - } - file.delete(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginManifestTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginManifestTest.java deleted file mode 100644 index 0669fe5f1e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginManifestTest.java +++ /dev/null @@ -1,249 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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.Set; - -/** - * 清单解析的边界测试。重点不是"能解析正常清单",而是**畸形清单必须被整体拒绝**: - * 半加载的插件会产生无法解释的行为,比不加载更糟。 - */ -public class PluginManifestTest { - - private static String valid() { - return """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "douban.comments", - "name": "豆瓣评论", - "sdk": 1, - "runtime": { "api": "js", "entry": "douban.js", "timeout": 15 }, - "events": ["ui.contentShown", "activity.created"], - "ui": [ - { "slot": "floating", "label": "豆瓣", "action": "comments.show", "event": "ui.contentShown" } - ], - "permissions": ["plugin.events", "ui.floating"] - } - """; - } - - @Test - public void parsesWellFormedManifest() { - PluginManifest manifest = PluginManifest.parse(valid()); - assertNotNull(manifest); - assertEquals("douban.comments", manifest.getId()); - assertEquals("豆瓣评论", manifest.getName()); - assertEquals("js", manifest.getRuntimeApi()); - assertEquals("douban.js", manifest.getRuntimeEntry()); - assertEquals(15, manifest.getTimeoutSeconds()); - assertEquals(Set.of(PluginCapabilityRegistry.PERMISSION_EVENTS, PluginCapabilityRegistry.PERMISSION_UI_FLOATING), - manifest.getPermissions()); - } - - @Test - public void rejectsWrongProtocol() { - assertNull(PluginManifest.parse(valid().replace("webhtv-plugin", "other-plugin"))); - } - - @Test - public void rejectsWrongSchemaVersion() { - assertNull(PluginManifest.parse(valid().replace("\"version\": 1", "\"version\": 2"))); - } - - @Test - public void rejectsUnsupportedSdk() { - assertNull(PluginManifest.parse(valid().replace("\"sdk\": 1", "\"sdk\": 99"))); - } - - @Test - public void rejectsInvalidIdCharacters() { - assertNull(PluginManifest.parse(valid().replace("douban.comments", "豆瓣 评论"))); - } - - @Test - public void rejectsUnknownRuntime() { - assertNull(PluginManifest.parse(valid().replace("\"api\": \"js\"", "\"api\": \"wasm\""))); - } - - @Test - public void rejectsMissingRuntimeEntry() { - assertNull(PluginManifest.parse(valid().replace("\"entry\": \"douban.js\",", ""))); - } - - @Test - public void rejectsWhenAllPermissionsAreUnknown() { - // 声明了权限但一个都不认识 → 与本宿主不兼容,必须拒绝而不是降级成无权限插件 - String json = valid().replace( - "\"permissions\": [\"plugin.events\", \"ui.floating\"]", - "\"permissions\": [\"unknown.one\", \"unknown.two\"]"); - assertNull(PluginManifest.parse(json)); - } - - @Test - public void dropsUnknownPermissionsButKeepsKnownOnes() { - String json = valid().replace( - "\"permissions\": [\"plugin.events\", \"ui.floating\"]", - "\"permissions\": [\"plugin.events\", \"future.thing\"]"); - PluginManifest manifest = PluginManifest.parse(json); - assertNotNull(manifest); - assertEquals(Set.of(PluginCapabilityRegistry.PERMISSION_EVENTS), manifest.getPermissions()); - } - - @Test - public void eventWithoutCoveringPermissionIsDropped() { - // activity.created 只由 plugin.events 覆盖;清单只给了 plugin.data.episode - String json = valid().replace( - "\"permissions\": [\"plugin.events\", \"ui.floating\"]", - "\"permissions\": [\"plugin.data.episode\"]"); - PluginManifest manifest = PluginManifest.parse(json); - assertNotNull(manifest); - assertFalse(manifest.subscribesTo(PluginEventBus.EVENT_ACTIVITY_CREATED)); - assertTrue(manifest.subscribesTo(PluginEventBus.EVENT_UI_CONTENT_SHOWN)); - } - - @Test - public void uiSlotWithoutPermissionIsDropped() { - // 声明了 floating 槽位但没有 ui.floating 权限 - String json = valid().replace( - "\"permissions\": [\"plugin.events\", \"ui.floating\"]", - "\"permissions\": [\"plugin.events\"]"); - PluginManifest manifest = PluginManifest.parse(json); - assertNotNull(manifest); - assertTrue(manifest.getUiSlots().isEmpty()); - } - - @Test - public void keepsLeanbackOverrideSlot() { - String json = valid().replace( - "\"permissions\": [\"plugin.events\", \"ui.floating\"]", - "\"permissions\": [\"plugin.events\", \"ui.floating\", \"ui.toolbar\"]") - .replace(""" - { "slot": "floating", "label": "豆瓣", "action": "comments.show", "event": "ui.contentShown" }""", - """ - { "slot": "floating", "label": "豆瓣", "action": "comments.show", "event": "ui.contentShown" }, - { "slot": "toolbar", "label": "豆瓣", "action": "comments.show", "anchor": "before:episode-list" }"""); - PluginManifest manifest = PluginManifest.parse(json); - assertNotNull(manifest); - assertEquals(2, manifest.getUiSlots().size()); - assertEquals("toolbar", manifest.getUiSlots().get(1).slot()); - assertEquals("before:episode-list", manifest.getUiSlots().get(1).anchor()); - } - - @Test - public void rejectsOversizedManifest() { - String huge = valid().replace("\"name\": \"豆瓣评论\"", "\"name\": \"" + "x".repeat(70 * 1024) + "\""); - assertNull(PluginManifest.parse(huge)); - } - - @Test - public void rejectsNonObjectAndBlank() { - assertNull(PluginManifest.parse("")); - assertNull(PluginManifest.parse(null)); - assertNull(PluginManifest.parse("[]")); - assertNull(PluginManifest.parse("not json")); - } - - @Test - public void timeoutIsClampedToSafeRange() { - String tooLong = valid().replace("\"timeout\": 15", "\"timeout\": 9999"); - PluginManifest manifest = PluginManifest.parse(tooLong); - assertNotNull(manifest); - assertEquals(15, manifest.getTimeoutSeconds()); - } - - @Test - public void absentParamsYieldEmptyObject() { - PluginManifest manifest = PluginManifest.parse(valid()); - assertNotNull(manifest); - assertNotNull(manifest.getParams()); - assertEquals(0, manifest.getParams().size()); - } - - @Test - public void paramsArePreservedVerbatimForLaterSubstitution() { - String json = valid().replace("\"events\":", - "\"params\": { \"token\": \"${DOUBAN_TOKEN}\", \"limit\": 20, \"headers\": { \"Referer\": \"https://movie.douban.com/\" } },\n \"events\":"); - PluginManifest manifest = PluginManifest.parse(json); - assertNotNull(manifest); - assertEquals("${DOUBAN_TOKEN}", manifest.getParams().get("token").getAsString()); - assertEquals(20, manifest.getParams().get("limit").getAsInt()); - assertEquals("https://movie.douban.com/", - manifest.getParams().getAsJsonObject("headers").get("Referer").getAsString()); - } - - @Test - public void paramsAreNotSharedBetweenReads() { - // getParams() 每次返回深拷贝:调用方会就地替换占位符,共享会让第二次解析读到已污染的值。 - String json = valid().replace("\"events\":", "\"params\": { \"token\": \"x\" },\n \"events\":"); - PluginManifest manifest = PluginManifest.parse(json); - assertNotNull(manifest); - manifest.getParams().addProperty("token", "polluted"); - assertEquals("x", manifest.getParams().get("token").getAsString()); - } - - @Test - public void absentSurfacesMeansEveryPage() { - // 向后兼容的命门:早期清单都没有 surfaces 键,空集必须等于"全页显示"。 - PluginManifest manifest = PluginManifest.parse(valid()); - assertNotNull(manifest); - assertEquals(1, manifest.getUiSlots().size()); - assertTrue(manifest.getUiSlots().get(0).surfaces().isEmpty()); - } - - @Test - public void surfacesAreParsedLowerCaseAndDeduplicated() { - String json = valid().replace( - "\"action\": \"comments.show\", \"event\": \"ui.contentShown\" }", - "\"action\": \"comments.show\", \"event\": \"ui.contentShown\"," - + " \"surfaces\": [\"Detail\", \"PLAYBACK\", \"detail\"] }"); - PluginManifest manifest = PluginManifest.parse(json); - assertNotNull(manifest); - assertEquals(Set.of("detail", "playback"), manifest.getUiSlots().get(0).surfaces()); - } - - @Test - public void nonStringAndOversizedSurfacesAreDropped() { - // 坏元素逐个丢弃,既不抛也不把整份清单判死。 - String json = valid().replace( - "\"action\": \"comments.show\", \"event\": \"ui.contentShown\" }", - "\"action\": \"comments.show\", \"event\": \"ui.contentShown\"," - + " \"surfaces\": [7, {\"a\": 1}, true, \"\", \"" + "x".repeat(17) - + "\", \" \", \"detail\"] }"); - PluginManifest manifest = PluginManifest.parse(json); - assertNotNull(manifest); - assertEquals(Set.of("detail"), manifest.getUiSlots().get(0).surfaces()); - } - - @Test - public void surfacesBeyondTheCapAreNotRead() { - StringBuilder many = new StringBuilder(); - for (int i = 1; i <= 10; i++) { - if (i > 1) many.append(", "); - many.append('"').append("s").append(i).append('"'); - } - String json = valid().replace( - "\"action\": \"comments.show\", \"event\": \"ui.contentShown\" }", - "\"action\": \"comments.show\", \"event\": \"ui.contentShown\"," - + " \"surfaces\": [" + many + "] }"); - PluginManifest manifest = PluginManifest.parse(json); - assertNotNull(manifest); - assertEquals(8, manifest.getUiSlots().get(0).surfaces().size()); - assertFalse(manifest.getUiSlots().get(0).surfaces().contains("s9")); - } - - @Test - public void nonObjectParamsAreIgnoredNotRejected() { - // params 是可选字段:写成数组说明作者写错了,但整个插件不该因此装不上。 - String json = valid().replace("\"events\":", "\"params\": [1, 2],\n \"events\":"); - PluginManifest manifest = PluginManifest.parse(json); - assertNotNull(manifest); - assertEquals(0, manifest.getParams().size()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginManifestVersionAndRootTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginManifestVersionAndRootTest.java deleted file mode 100644 index 7e5b4a0274..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginManifestVersionAndRootTest.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.io.File; -import java.nio.file.Files; - -/** - * 插件身份与包目录归属测试。 - * - *

两个独立主题放在一起,因为它们都是"界面看得见、但解析/导入类各自只占一半"的约定: - * 清单里哪一个是插件版本,以及哪个目录才是本宿主拥有的插件包目录。 - * - *

版本这块的关键是不能让 {@code version}(协议版本)继续兼任插件版本:兼任会让插件版本 - * 恒等于协议版本号,升级与重导入在管理列表里完全看不出差别。回退分支也要测——旧清单不写 - * {@code pluginVersion},界面依赖"版本列永不为空"。 - * - *

目录这块的关键是"失败即拒绝":界面拿它当删除前的自检,宽松的 startsWith 会让 - * {@code plugins-x} 这种兄弟目录通过判定。 - */ -public class PluginManifestVersionAndRootTest { - - private File workDir; - private File pluginsRoot; - - @Before - public void setUp() throws Exception { - workDir = Files.createTempDirectory("plugin-identity").toFile(); - pluginsRoot = new File(workDir, "files/plugins"); - PluginPackageImporter.overrideRootForTest(pluginsRoot); - } - - @After - public void tearDown() { - PluginPackageImporter.overrideRootForTest(null); - delete(workDir); - } - - @Test - public void pluginVersionKeyOverridesProtocolVersion() { - PluginManifest manifest = PluginManifest.parse(valid().replace("\"sdk\": 1,", "\"sdk\": 1, \"pluginVersion\": \"2.3.0\",")); - - assertNotNull(manifest); - assertEquals("2.3.0", manifest.getVersion()); - } - - @Test - public void legacyManifestFallsBackToProtocolVersion() { - PluginManifest manifest = PluginManifest.parse(valid()); - - assertNotNull(manifest); - assertEquals(String.valueOf(PluginManifest.SCHEMA_VERSION), manifest.getVersion()); - } - - @Test - public void blankPluginVersionFallsBackToProtocolVersion() { - PluginManifest manifest = PluginManifest.parse(valid().replace("\"sdk\": 1,", "\"sdk\": 1, \"pluginVersion\": \" \",")); - - assertNotNull(manifest); - assertEquals(String.valueOf(PluginManifest.SCHEMA_VERSION), manifest.getVersion()); - } - - @Test - public void oversizedPluginVersionFallsBackToProtocolVersion() { - // 64 > MAX_VERSION_LENGTH(32):超长的 pluginVersion 视同没写,而不是让清单解析失败。 - String tooLong = "1".repeat(64); - PluginManifest manifest = PluginManifest.parse( - valid().replace("\"sdk\": 1,", "\"sdk\": 1, \"pluginVersion\": \"" + tooLong + "\",")); - - assertNotNull(manifest); - assertEquals(String.valueOf(PluginManifest.SCHEMA_VERSION), manifest.getVersion()); - } - - @Test - public void pluginsRootHonorsTestOverride() throws Exception { - assertEquals(pluginsRoot.getCanonicalPath(), PluginPackageImporter.pluginsRoot().getCanonicalPath()); - } - - @Test - public void isOwnedDirectoryAcceptsChildOfRoot() throws Exception { - File child = new File(pluginsRoot, "demo.hello"); - assertTrue(child.mkdirs()); - - assertTrue(PluginPackageImporter.isOwnedDirectory(child.getAbsolutePath())); - } - - @Test - public void isOwnedDirectoryRejectsRootItselfAndSibling() throws Exception { - assertTrue(pluginsRoot.mkdirs()); - File sibling = new File(workDir, "files/plugins-x"); - assertTrue(sibling.mkdirs()); - - assertFalse(PluginPackageImporter.isOwnedDirectory(pluginsRoot.getAbsolutePath())); - assertFalse(PluginPackageImporter.isOwnedDirectory(sibling.getAbsolutePath())); - } - - @Test - public void isOwnedDirectoryRejectsUnsafeInputs() { - assertFalse(PluginPackageImporter.isOwnedDirectory(null)); - assertFalse(PluginPackageImporter.isOwnedDirectory("")); - assertFalse(PluginPackageImporter.isOwnedDirectory(" ")); - assertFalse(PluginPackageImporter.isOwnedDirectory( - new File(workDir, "elsewhere/demo.hello").getAbsolutePath())); - } - - private static String valid() { - return """ - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "demo.hello", - "name": "演示", - "sdk": 1, - "runtime": { "api": "js", "entry": "hello.js", "timeout": 15 }, - "events": ["ui.contentShown"], - "permissions": ["plugin.events"] - } - """; - } - - private static void delete(File file) { - if (file == null || !file.exists()) return; - File[] children = file.listFiles(); - if (children != null) for (File child : children) delete(child); - file.delete(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginNetBridgeTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginNetBridgeTest.java deleted file mode 100644 index 690ae8b983..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginNetBridgeTest.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.io.IOException; - -import mockwebserver3.MockResponse; -import mockwebserver3.MockWebServer; -import mockwebserver3.RecordedRequest; - -/** - * 插件网络桥测试。 - * - *

用真实 HTTP 服务器({@code MockWebServer})而不是 mock 掉 {@code OkHttp}: - * 桥的职责里有一半是"把 okhttp 的失败翻译成脚本能读的 JSON",mock 掉客户端就测不到这一半。 - * - *

断言的核心不变量是**永不抛异常**——脚本侧没有 try/catch 的习惯,一次未捕获的 Java 异常 - * 会被 {@link PluginHost} 判成运行时就故障,把整个插件隔离掉。 - */ -public class PluginNetBridgeTest { - - private MockWebServer server; - - @Before - public void setUp() throws IOException { - server = new MockWebServer(); - server.start(); - PluginAuditLog.get().clear(); - } - - @After - public void tearDown() throws Exception { - if (server != null) server.close(); - PluginAuditLog.get().clear(); - } - - private static JsonObject parse(String text) { - return JsonParser.parseString(text).getAsJsonObject(); - } - - @Test - public void getReturnsStatusAndBody() throws Exception { - server.enqueue(new MockResponse.Builder().code(200).body("{\"rating\":8.7}").build()); - - JsonObject result = parse(PluginNetBridge.request("p", "GET", server.url("/subject").toString(), null, null, 15)); - - assertEquals(200, result.get("status").getAsInt()); - assertTrue(result.get("body").getAsString().contains("8.7")); - } - - @Test - public void headersAreForwardedFromJsonText() throws Exception { - server.enqueue(new MockResponse.Builder().code(200).body("ok").build()); - - PluginNetBridge.request("p", "GET", server.url("/x").toString(), - "{\"Referer\":\"https://movie.douban.com/\",\"User-Agent\":\"WebHtv\"}", null, 15); - - RecordedRequest request = server.takeRequest(); - assertEquals("https://movie.douban.com/", request.getHeaders().get("Referer")); - assertEquals("WebHtv", request.getHeaders().get("User-Agent")); - } - - @Test - public void postCarriesBodyAndContentType() throws Exception { - server.enqueue(new MockResponse.Builder().code(201).body("created").build()); - - PluginNetBridge.request("p", "POST", server.url("/x").toString(), - "{\"Content-Type\":\"application/json; charset=utf-8\"}", "{\"a\":1}", 15); - - RecordedRequest request = server.takeRequest(); - assertEquals("POST", request.getMethod()); - assertEquals("{\"a\":1}", request.getBody().utf8()); - assertTrue(request.getHeaders().get("Content-Type").startsWith("application/json")); - } - - @Test - public void errorStatusIsReportedNotThrown() throws Exception { - server.enqueue(new MockResponse.Builder().code(403).body("forbidden").build()); - - JsonObject result = parse(PluginNetBridge.request("p", "GET", server.url("/x").toString(), null, null, 15)); - - assertEquals(403, result.get("status").getAsInt()); - assertEquals("forbidden", result.get("body").getAsString()); - } - - @Test - public void unsupportedMethodIsBlocked() { - JsonObject result = parse(PluginNetBridge.request("p", "TRACE", "https://example.com/", null, null, 15)); - assertEquals(0, result.get("status").getAsInt()); - assertTrue(result.get("error").getAsString().contains("unsupported method")); - assertEquals("blocked", lastOutcome()); - } - - @Test - public void nonHttpSchemeIsBlocked() { - JsonObject result = parse(PluginNetBridge.request("p", "GET", "file:///etc/passwd", null, null, 15)); - assertEquals(0, result.get("status").getAsInt()); - assertTrue(result.get("error").getAsString().contains("scheme")); - assertEquals("blocked", lastOutcome()); - } - - @Test - public void connectionFailureBecomesErrorJson() { - // 端口 1 上没有服务;断言的是"失败被翻译成 JSON"而不是具体异常类型。 - JsonObject result = parse(PluginNetBridge.request("p", "GET", "http://127.0.0.1:1/x", null, null, 2)); - assertEquals(0, result.get("status").getAsInt()); - assertNotNull(result.get("error")); - assertEquals("error", lastOutcome()); - } - - @Test - public void malformedHeadersJsonIsRejected() { - JsonObject result = parse(PluginNetBridge.request("p", "GET", server.url("/x").toString(), "{not json", null, 15)); - assertEquals(0, result.get("status").getAsInt()); - assertTrue(result.get("error").getAsString().contains("headers")); - } - - @Test - public void oversizedResponseBodyIsTruncated() throws Exception { - server.enqueue(new MockResponse.Builder().code(200) - .body("x".repeat(PluginNetBridge.MAX_RESPONSE_BYTES + 4096)).build()); - - JsonObject result = parse(PluginNetBridge.request("p", "GET", server.url("/big").toString(), null, null, 15)); - - assertEquals(PluginNetBridge.MAX_RESPONSE_BYTES, result.get("body").getAsString().length()); - } - - @Test - public void requestIsAuditedWithoutQueryString() throws Exception { - server.enqueue(new MockResponse.Builder().code(200).body("ok").build()); - - PluginNetBridge.request("p", "GET", server.url("/subject/1?token=secret").toString(), null, null, 15); - - PluginAuditLog.Entry entry = PluginAuditLog.get().entries().get(0); - assertEquals("p", entry.pluginId()); - assertEquals("GET", entry.method()); - assertEquals(200, entry.status()); - assertEquals("ok", entry.outcome()); - // query 里常带签名,审计只留 host + path。 - assertFalse(entry.path().contains("secret")); - } - - @Test - public void blockedCallIsAudited() { - PluginNetBridge.blocked("p", "GET", "https://example.com/x", "missing permission net.request"); - PluginAuditLog.Entry entry = PluginAuditLog.get().entries().get(0); - assertEquals("blocked", entry.outcome()); - assertEquals("p", entry.pluginId()); - } - - private static String lastOutcome() { - return PluginAuditLog.get().entries().get(PluginAuditLog.get().size() - 1).outcome(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginPackageImporterTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginPackageImporterTest.java deleted file mode 100644 index b3e38b4fa5..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginPackageImporterTest.java +++ /dev/null @@ -1,334 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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.After; -import org.junit.Before; -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.List; - -/** - * 插件包导入测试。 - * - *

全部用 {@code java.io} 临时目录构造输入:JVM 单测里没有 AssetManager, - * 也不能依赖 {@code Init.context()}({@code Path.files()} 拿不到 Context), - * 因此通过 {@link PluginPackageImporter#overrideRootForTest(File)} 与 - * {@link PluginSourceConfig#useStoreForTest(PluginSourceConfig.Store)} 两个包内接缝把 - * 文件根与持久化后端换成临时目录。 - * - *

重点不只是"能导入",而是三条失败姿态:超限不留残留、中途失败整包回滚、 - * 路径穿越与符号链接必须拒绝。这三条一旦松动,插件包就成了任意文件写入通道。 - */ -public class PluginPackageImporterTest { - - private File pluginsRoot; - private File workDir; - private MemoryStore store; - - @Before - public void setUp() throws Exception { - workDir = Files.createTempDirectory("plugin-import").toFile(); - pluginsRoot = new File(workDir, "files/plugins"); - store = new MemoryStore(); - PluginSourceConfig.useStoreForTest(store); - PluginPackageImporter.overrideRootForTest(pluginsRoot); - } - - @After - public void tearDown() { - PluginPackageImporter.overrideRootForTest(null); - PluginSourceConfig.useStoreForTest(null); - delete(workDir); - } - - @Test - public void importsPackageAndRewritesEntryToAbsolutePath() throws Exception { - File source = packageDir("src-a", manifest("demo.hello", "hello.js", "js"), "hello.js", "webhtv.log('hi');"); - - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(source); - - assertNull(result.error()); - assertNotNull(result.entry()); - assertEquals("demo.hello", result.entry().id()); - assertEquals("js", result.entry().runtimeApi()); - assertTrue(result.entry().enabled()); - - File target = new File(result.entry().directory()); - assertTrue("package directory must exist", target.isDirectory()); - File script = new File(target, "hello.js"); - File manifest = new File(target, "manifest.json"); - assertTrue(script.isFile()); - assertTrue(manifest.isFile()); - assertEquals("script content must be copied verbatim", "webhtv.log('hi');", - Files.readString(script.toPath(), StandardCharsets.UTF_8)); - - // 改写后的入口必须是复制后的绝对路径:装载期据此走 PluginScriptSource 的裸绝对路径形态。 - String rewritten = Files.readString(manifest.toPath(), StandardCharsets.UTF_8); - assertTrue("entry must point at the copied script: " + rewritten, - rewritten.contains(script.getAbsolutePath().replace("\\", "\\\\")) - || rewritten.contains(script.getAbsolutePath().replace("\\", "/"))); - PluginManifest parsed = PluginManifest.parse(rewritten); - assertNotNull(parsed); - assertEquals(script.getAbsolutePath(), new File(parsed.getRuntimeEntry()).getAbsolutePath()); - - // 导入即登记,配置与盘上包必须同时成立。 - assertNotNull(PluginSourceConfig.get().find("demo.hello")); - assertEquals(1, PluginSourceConfig.get().entries().size()); - } - - @Test - public void copiesOnlyManifestAndEntrySiblings() throws Exception { - File source = packageDir("src-b", manifest("demo.scoped", "js/hello.js", "js"), "js/hello.js", "1;"); - write(new File(source, "js/helper.js"), "// helper"); - write(new File(source, "unrelated/secret.txt"), "should not be copied"); - - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(source); - - assertNull(result.error()); - File target = new File(result.entry().directory()); - assertTrue(new File(target, "manifest.json").isFile()); - assertTrue("entry sibling must be copied", new File(target, "js/helper.js").isFile()); - assertFalse("unrelated subtree must not be copied", new File(target, "unrelated").exists()); - } - - @Test - public void rejectsMissingManifest() throws Exception { - File source = new File(workDir, "src-nomanifest"); - assertTrue(source.mkdirs()); - write(new File(source, "hello.js"), "1;"); - - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(source); - - assertNull(result.entry()); - assertNotNull(result.error()); - assertTrue(result.error(), result.error().contains("manifest.json")); - assertTrue("nothing may be created on failure", PluginSourceConfig.get().entries().isEmpty()); - assertFalse(new File(pluginsRoot, "demo.hello").exists()); - } - - @Test - public void rejectsInvalidManifest() throws Exception { - File source = new File(workDir, "src-bad"); - assertTrue(source.mkdirs()); - // 缺 protocol / version / sdk:PluginManifest.parse 必然返回 null - write(new File(source, "manifest.json"), "{\"id\":\"demo.bad\",\"runtime\":{\"api\":\"js\",\"entry\":\"hello.js\"}}"); - write(new File(source, "hello.js"), "1;"); - - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(source); - - assertNull(result.entry()); - assertTrue(result.error(), result.error().contains("清单不合法")); - } - - @Test - public void distinguishesUnsupportedRuntimeFromInvalidManifest() throws Exception { - File source = new File(workDir, "src-lua"); - assertTrue(source.mkdirs()); - write(new File(source, "manifest.json"), manifest("demo.lua", "hello.js", "lua")); - write(new File(source, "hello.js"), "1;"); - - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(source); - - assertNull(result.entry()); - assertNotNull(result.error()); - assertTrue("unsupported runtime must be its own message: " + result.error(), - result.error().contains("运行时不支持")); - assertFalse(result.error().contains("清单不合法")); - } - - @Test - public void rejectsOversizedScript() throws Exception { - File source = packageDir("src-big", manifest("demo.big", "hello.js", "js"), "hello.js", "1;"); - write(new File(source, "hello.js"), repeat('a', PluginScriptSource.MAX_SCRIPT_BYTES + 1)); - - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(source); - - assertNull(result.entry()); - assertTrue(result.error(), result.error().contains(String.valueOf(PluginScriptSource.MAX_SCRIPT_BYTES))); - assertFalse("oversized package must leave nothing behind", new File(pluginsRoot, "demo.big").exists()); - } - - @Test - public void rejectsPackageOverTotalBudget() throws Exception { - File source = packageDir("src-huge", manifest("demo.huge", "hello.js", "js"), "hello.js", "1;"); - // 每个文件都在单文件上限内,但总量越过 4MB:必须按整包口径拒绝。 - for (int i = 0; i < 9; i++) { - write(new File(source, "chunk" + i + ".js"), repeat('b', 500 * 1024)); - } - - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(source); - - assertNull(result.entry()); - assertTrue(result.error(), result.error().contains(String.valueOf(PluginPackageImporter.MAX_PACKAGE_BYTES))); - assertFalse(new File(pluginsRoot, "demo.huge").exists()); - } - - @Test - public void rejectsTooManyFiles() throws Exception { - File source = packageDir("src-many", manifest("demo.many", "hello.js", "js"), "hello.js", "1;"); - for (int i = 0; i < PluginPackageImporter.MAX_FILES; i++) { - write(new File(source, "f" + i + ".js"), "1;"); - } - - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(source); - - assertNull(result.entry()); - assertTrue(result.error(), result.error().contains(String.valueOf(PluginPackageImporter.MAX_FILES))); - } - - @Test - public void rejectsPathTraversalInEntry() throws Exception { - write(new File(workDir, "escape.js"), "stolen"); - File source = new File(workDir, "src-escape"); - assertTrue(source.mkdirs()); - write(new File(source, "manifest.json"), manifest("demo.escape", "../escape.js", "js")); - - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(source); - - assertNull(result.entry()); - assertTrue(result.error(), result.error().contains("路径穿越")); - assertFalse(new File(pluginsRoot, "demo.escape").exists()); - } - - @Test - public void rejectsAbsoluteAndProtocolEntries() throws Exception { - PluginPackageImporter.Result absolute = PluginPackageImporter.importFrom( - packageDir("src-abs", manifest("demo.abs", "/etc/passwd", "js"), "hello.js", "1;")); - assertNull(absolute.entry()); - assertTrue(absolute.error(), absolute.error().contains("相对路径")); - - PluginPackageImporter.Result protocol = PluginPackageImporter.importFrom( - packageDir("src-proto", manifest("demo.proto", "assets://plugin/example/hello.js", "js"), "hello.js", "1;")); - assertNull(protocol.entry()); - assertTrue(protocol.error(), protocol.error().contains("协议前缀")); - } - - @Test - public void reimportSameIdReplacesDirectory() throws Exception { - File first = packageDir("src-v1", manifestWithName("demo.repeat", "hello.js", "js", "v1", "旧版"), "hello.js", "old();"); - PluginPackageImporter.Result firstResult = PluginPackageImporter.importFrom(first); - assertNull(firstResult.error()); - - File second = packageDir("src-v2", manifestWithName("demo.repeat", "hello.js", "js", "v2", "新版"), "hello.js", "new();"); - write(new File(second, "extra.js"), "// extra from v2"); - PluginPackageImporter.Result secondResult = PluginPackageImporter.importFrom(second); - - assertNull(secondResult.error()); - List entries = PluginSourceConfig.get().entries(); - assertEquals("same id must never produce two entries", 1, entries.size()); - assertEquals("新版", entries.get(0).name()); - // 插件版本来自 pluginVersion 键;version 只承担协议版本,所以这里能真正区分新旧包。 - assertEquals("v2", entries.get(0).version()); - - File target = new File(secondResult.entry().directory()); - assertEquals("new();", Files.readString(new File(target, "hello.js").toPath(), StandardCharsets.UTF_8)); - assertTrue("v2 files must be present", new File(target, "extra.js").isFile()); - assertFalse("v1 files must be gone", new File(target, "v1-only.js").exists()); - } - - @Test - public void failedImportLeavesNoResidue() throws Exception { - // 入口脚本为空:复制已经发生,失败点在复制之后 → 最考验回滚。 - File source = packageDir("src-empty", manifest("demo.empty", "hello.js", "js"), "hello.js", ""); - - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(source); - - assertNull(result.entry()); - assertNotNull(result.error()); - assertFalse("half-copied package must be removed", new File(pluginsRoot, "demo.empty").exists()); - assertNull("config entry must be rolled back too", PluginSourceConfig.get().find("demo.empty")); - assertTrue(PluginSourceConfig.get().entries().isEmpty()); - } - - @Test - public void failedReimportDoesNotLeaveStaleEntry() throws Exception { - File good = packageDir("src-good", manifest("demo.stale", "hello.js", "js"), "hello.js", "ok();"); - assertNull(PluginPackageImporter.importFrom(good).error()); - assertNotNull(PluginSourceConfig.get().find("demo.stale")); - - File broken = packageDir("src-broken", manifest("demo.stale", "hello.js", "js"), "hello.js", ""); - PluginPackageImporter.Result result = PluginPackageImporter.importFrom(broken); - - assertNull(result.entry()); - assertFalse(new File(pluginsRoot, "demo.stale").exists()); - assertNull("a failed re-import must not leave a config entry pointing at nothing", - PluginSourceConfig.get().find("demo.stale")); - } - - @Test - public void nullAndNonDirectoryInputsAreRejected() throws Exception { - assertNotNull(PluginPackageImporter.importFrom(null).error()); - File file = new File(workDir, "not-a-dir.txt"); - write(file, "x"); - assertNotNull(PluginPackageImporter.importFrom(file).error()); - assertNotNull(PluginPackageImporter.importFrom(new File(workDir, "missing")).error()); - } - - // ---- helpers ---- - - private File packageDir(String name, String manifestJson, String entryName, String script) throws Exception { - File dir = new File(workDir, name); - assertTrue(dir.mkdirs()); - write(new File(dir, "manifest.json"), manifestJson); - write(new File(dir, entryName), script); - return dir; - } - - private static String manifest(String id, String entry, String api) { - return manifestWithName(id, entry, api, "1.0.0", id); - } - - private static String manifestWithName(String id, String entry, String api, String version, String name) { - return "{\"protocol\":\"webhtv-plugin\",\"version\":1,\"id\":\"" + id + "\",\"name\":\"" + name - + "\",\"pluginVersion\":\"" + version - + "\",\"sdk\":1,\"runtime\":{\"api\":\"" + api + "\",\"entry\":\"" + entry - + "\",\"timeout\":15},\"events\":[\"ui.contentShown\"],\"permissions\":[\"plugin.events\"]}"; - } - - private static String manifestNamed(String id, String entry, String api, String name) { - return manifestWithName(id, entry, api, "1", name); - } - - private static String repeat(char c, int count) { - char[] chars = new char[count]; - java.util.Arrays.fill(chars, c); - return new String(chars); - } - - private static void write(File file, String text) throws Exception { - File parent = file.getParentFile(); - if (parent != null && !parent.isDirectory()) assertTrue(parent.mkdirs()); - Files.writeString(file.toPath(), text, StandardCharsets.UTF_8); - } - - private static void delete(File file) { - if (file == null || !file.exists()) return; - File[] children = file.listFiles(); - if (children != null) for (File child : children) delete(child); - file.delete(); - } - - /** 内存版存储:单测里没有 Context,{@code Prefers} 不可用。 */ - static final class MemoryStore implements PluginSourceConfig.Store { - - String json = ""; - - @Override - public String read() { - return json; - } - - @Override - public void write(String value) { - json = value; - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginPanelPlaybackWiringTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginPanelPlaybackWiringTest.java deleted file mode 100644 index 40446496a2..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginPanelPlaybackWiringTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 面板的播放态接线守卫(纯 JVM,读源码文本)。 - * - *

为什么用源码断言而不是行为测试:暂停/恢复发生在 {@code PlaybackActivity} 的真实播放器上, - * 纯 JVM 里没有 Looper、没有 ExoPlayer;真机用例只能证明「点了会关」,证明不了「关了就恢复」。 - * 接线一旦被后来的重构悄悄拿掉,用户侧的表现是「视频永远停在暂停态」——静默、且只有真实播放中才会遇到。 - * 这里把三条关键接线钉死在文本上:打开时暂停、失败即恢复、dismiss 时恢复且只恢复一次。 - */ -public class PluginPanelPlaybackWiringTest { - - private static final String SHOW_HEAD = "public static void show(@Nullable FragmentActivity activity, @Nullable String title, @Nullable String html,"; - private static final String DISMISS_HEAD = "public void onDismiss(@NonNull DialogInterface dialog)"; - - @Test - public void panelPausesHostPlaybackBeforeItOpens() throws Exception { - String dialog = dialog(); - String show = methodBody(dialog, SHOW_HEAD); - - assertTrue("面板必须在打开前暂停宿主播放", show.contains("activity instanceof PlaybackActivity")); - assertTrue("面板必须在打开前暂停宿主播放", show.contains("pauseForOverlayPlayback()")); - assertTrue("暂停结果必须进 Bundle,不能只留字段", show.contains("args.putBoolean(ARG_RESUME_PARENT, resumeParent);")); - assertTrue("暂停结果必须进 Bundle,不能只留字段", show.contains("dialog.setArguments(args);")); - assertTrue("事务提交失败必须立刻恢复播放", show.contains("resumeAfterOverlayPlayback(resumeParent)")); - assertTrue("恢复必须发生在 catch 里,而不是无条件执行", - show.contains("catch (RuntimeException e)")); - assertTrue("恢复必须在 catch 分支之后", - show.indexOf("catch (RuntimeException e)") < show.indexOf("resumeAfterOverlayPlayback(resumeParent)")); - } - - @Test - public void panelResumesHostPlaybackExactlyOnceOnDismiss() throws Exception { - String dialog = dialog(); - String dismiss = methodBody(dialog, DISMISS_HEAD); - - assertTrue("dismiss 必须恢复播放", dismiss.contains("resumeAfterOverlayPlayback(resumeParent)")); - assertTrue("恢复必须有 parentResumed 守卫", dismiss.contains("if (!parentResumed)")); - assertTrue("守卫必须先置位再恢复", dismiss.contains("parentResumed = true;")); - assertTrue("配置变更时旧 Activity 不该恢复播放", dismiss.contains("isChangingConfigurations()")); - assertFalse("恢复对象不许来自字段", dismiss.contains("owner")); - assertTrue("先恢复播放,再还焦点", - dismiss.indexOf("resumeAfterOverlayPlayback(resumeParent)") < dismiss.indexOf("closed.run()")); - assertFalse("不该为播放态新开 public 方法", dialog.contains("public void pauseForOverlayPlayback")); - assertFalse("不该为播放态新开 public 方法", dialog.contains("public void resumeForOverlayPlayback")); - } - - /** - * 本轮返工的核心:恢复状态必须活过 Fragment 重建。 - * - *

旋屏时 FragmentManager 用无参构造器重建 Fragment,字段值不会被重放。若「是否要恢复」 - * 存在字段里,重建后的实例会以为自己从未暂停过播放,关闭面板时静默跳过恢复,视频永久停在 - * 暂停帧——用户只能自己按播放键,且没有任何错误可见。 - */ - @Test - public void resumeIntentSurvivesFragmentRecreation() throws Exception { - String dialog = dialog(); - String dismiss = methodBody(dialog, DISMISS_HEAD); - String initView = methodBody(dialog, "protected void initView()"); - - assertFalse("不许再用字段持有 Activity(旋屏后为 null,且泄漏 Activity)", - dialog.contains("private PlaybackActivity playback")); - assertTrue("恢复意图必须有 Bundle key", dialog.contains("ARG_RESUME_PARENT")); - assertTrue("打开时写入 Bundle", dialog.contains("setArguments(")); - assertTrue("重建后必须回读 Bundle", initView.contains("getArguments()")); - assertTrue("重建后必须回读 Bundle", initView.contains("getBoolean(ARG_RESUME_PARENT, false)")); - assertTrue("恢复对象必须取自 getActivity()", - dismiss.contains("instanceof PlaybackActivity") || dismiss.contains("(PlaybackActivity) activity")); - assertTrue("恢复对象必须取自 getActivity()", dismiss.contains("getActivity()")); - } - - @Test - public void playbackWiringDidNotDisturbPanelContracts() throws Exception { - String dialog = dialog(); - - assertTrue("四参 show 的签名不许变", dialog.contains(SHOW_HEAD)); - assertTrue("三参重载必须继续转调四参", dialog.contains("show(activity, title, html, null);")); - assertTrue(dialog.contains("WebViewUtil.configureBase(view, TAG);")); - assertTrue(dialog.contains("WebViewUtil.configureHome(view);")); - // 约定在加载态引入后收窄了:html 为 null 表示"等异步结果",此时**不能**灌空 html, - // 否则加载中会先闪一个空白页。这里钉住"只有非加载态才 loadDataWithBaseURL"。 - assertTrue("加载中不许灌空 html", dialog.contains("if (!loading) view.loadDataWithBaseURL(null, html")); - assertTrue("加载中必须可见地转圈", - dialog.contains("binding.pluginPanelLoading.setVisibility(loading ? View.VISIBLE : View.GONE)")); - assertTrue("加载中正文必须让位", - dialog.contains("view.setVisibility(loading ? View.INVISIBLE : View.VISIBLE)")); - assertTrue("电视端焦点通道不许丢", dialog.contains("pluginPanelClose.setNextFocusDownId(R.id.pluginPanelWeb)")); - assertTrue("电视端焦点通道不许丢", dialog.contains("pluginPanelWeb.setNextFocusUpId(R.id.pluginPanelClose)")); - } - - /** - * 加载壳与结果回填的接线守卫。 - * - *

这三条覆盖的正是用户报的三个症状:点击无反馈、连点叠面板、以及结果回来时串台。 - * 都是纯 JVM 无法行为验证的部分(要真 FragmentManager 与 WebView),因此钉在源码接线上。 - */ - @Test - public void loadingShellIsOpenedBeforeThePluginRuns() throws Exception { - String controller = read("src", "main", "java", "com", "fongmi", "android", "tv", "plugin", "PluginController.java"); - String action = methodBody(controller, "private void onSlotAction(String pluginId, String action)"); - - assertTrue("必须在动作派发之前开壳", action.contains("showLoadingPanel(")); - assertTrue("开壳必须在 actions.execute 之前", - action.indexOf("showLoadingPanel(") < action.indexOf("actions.execute(")); - assertTrue("结果回来必须填进已有的壳,而不是再开一个", action.contains("routeSlotResult(") && action.contains("token")); - assertTrue("进行中标记必须在 finally 里归还", action.contains("finally") && action.contains("pending.remove(")); - } - - @Test - public void repeatedClicksDoNotStackPanels() throws Exception { - String dialog = dialog(); - String showLoading = methodBody(dialog, "public static String showLoading("); - - assertTrue("已有面板必须复用而不是再开一个", showLoading.contains("find(activity)")); - assertTrue("复用时要换发新 token,旧结果才会失效", showLoading.contains("existing.token = token")); - assertTrue("复用分支必须先于 new PluginPanelDialog()", - showLoading.indexOf("existing.token = token") < showLoading.indexOf("new PluginPanelDialog()")); - } - - @Test - public void emptyResultCannotLeaveASpinnerForever() throws Exception { - String dialog = dialog(); - - assertTrue("必须有撤销加载壳的入口", dialog.contains("public static void dismissLoading(")); - assertTrue("撤销必须校验 token", methodBody(dialog, "public static void dismissLoading(").contains("token.equals(existing.token)")); - } - - @Test - public void playbackActivityStillProvidesTheOverlayContract() throws Exception { - String playback = read("src", "main", "java", "com", "fongmi", "android", "tv", "ui", "activity", "PlaybackActivity.java"); - - assertTrue(playback.contains("public final boolean pauseForOverlayPlayback()")); - assertTrue(playback.contains("public final void resumeAfterOverlayPlayback(boolean shouldResume)")); - } - - private static String dialog() throws Exception { - return read("src", "main", "java", "com", "fongmi", "android", "tv", "ui", "dialog", "PluginPanelDialog.java"); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("Missing method: " + signature, start >= 0); - int open = source.indexOf('{', start); - assertTrue("Missing method body: " + signature, open >= 0); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char value = source.charAt(i); - if (value == '{') depth++; - if (value == '}' && --depth == 0) return source.substring(open + 1, i); - } - throw new AssertionError("Unclosed method: " + signature); - } - - private static String read(String... parts) throws Exception { - Path root = Files.exists(Path.of("src", "main")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(Path.of("", parts)), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginRemoteSourceTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginRemoteSourceTest.java deleted file mode 100644 index 19ecc62d8e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginRemoteSourceTest.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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.List; - -/** - * 远程插件源解析测试。 - * - *

只覆盖 {@link PluginRemoteSource#resolve(String)}:它是纯字符串处理,能在普通 JVM 里 - * 完整验证;{@code download} 走项目共享的 OkHttp 客户端(依赖 {@code Init.context()}), - * 只能真机验证,本测试没有替它造假。 - * - *

断言的重点是两侧:三条接受的形态必须生成正确的 {@code codeload} 地址,其余一切 - * (http、file、content、GitHub 的非仓库页面、非 zip 直链、带口令的地址)必须被拒且给中文原因。 - */ -public class PluginRemoteSourceTest { - - private static final String CANONICAL = "https://codeload.github.com/owner/repo/zip/refs/heads/"; - - @Test - public void repoRootYieldsMainThenMaster() { - assertTwoBranchCandidates("https://github.com/owner/repo"); - } - - @Test - public void repoRootToleratesTrailingSlash() { - assertTwoBranchCandidates("https://github.com/owner/repo/"); - } - - @Test - public void repoRootToleratesDotGitSuffix() { - assertTwoBranchCandidates("https://github.com/owner/repo.git"); - } - - @Test - public void repoRootIgnoresQueryAndFragment() { - assertTwoBranchCandidates("https://github.com/owner/repo?tab=readme-ov-file"); - assertTwoBranchCandidates("https://github.com/owner/repo#install"); - } - - @Test - public void treeUrlUsesTheGivenBranchOnly() { - PluginRemoteSource.Resolution result = PluginRemoteSource.resolve("https://github.com/owner/repo/tree/dev"); - - assertTrue(result.error(), result.isSuccess()); - assertEquals("用户已经说了分支,就不该再猜 main", 1, result.urls().size()); - assertEquals(CANONICAL + "dev", result.urls().get(0)); - } - - @Test - public void treeUrlIgnoresSubdirectory() { - PluginRemoteSource.Resolution result = - PluginRemoteSource.resolve("https://github.com/owner/repo/tree/dev/plugins/douban"); - - assertTrue(result.error(), result.isSuccess()); - assertEquals(1, result.urls().size()); - assertEquals("子目录不改变下载地址:整包下来之后再按目录挑插件", CANONICAL + "dev", result.urls().get(0)); - } - - @Test - public void branchNameAllowsDotsAndDashes() { - PluginRemoteSource.Resolution result = - PluginRemoteSource.resolve("https://github.com/owner/repo/tree/release-1.2_x"); - - assertTrue(result.error(), result.isSuccess()); - assertEquals(CANONICAL + "release-1.2_x", result.urls().get(0)); - } - - @Test - public void encodedSlashBranchIsRejected() { - // %2F 解出来是路径分隔符,不可能是一段合法分支名:拒掉而不是把它拼进 codeload 路径。 - PluginRemoteSource.Resolution result = - PluginRemoteSource.resolve("https://github.com/owner/repo/tree/feature%2Fx"); - - assertFalse(result.isSuccess()); - assertNotNull(result.error()); - assertTrue(result.error(), result.error().contains("分支名")); - assertTrue(result.urls().isEmpty()); - } - - @Test - public void directZipOnAnyHostIsUsedAsIs() { - PluginRemoteSource.Resolution result = PluginRemoteSource.resolve("https://example.com/plugins/bundle.zip"); - - assertTrue(result.error(), result.isSuccess()); - assertEquals(List.of("https://example.com/plugins/bundle.zip"), result.urls()); - } - - @Test - public void directZipIsCaseInsensitiveAndKeepsQuery() { - PluginRemoteSource.Resolution upper = PluginRemoteSource.resolve("https://example.com/BUNDLE.ZIP"); - assertTrue(upper.error(), upper.isSuccess()); - assertEquals(List.of("https://example.com/BUNDLE.ZIP"), upper.urls()); - - PluginRemoteSource.Resolution signed = PluginRemoteSource.resolve("https://cdn.example.com/b.zip?token=abc"); - assertTrue(signed.error(), signed.isSuccess()); - assertEquals("直链必须原样返回,签名参数不能被重组丢掉", - List.of("https://cdn.example.com/b.zip?token=abc"), signed.urls()); - } - - @Test - public void rejectsPlainHttp() { - PluginRemoteSource.Resolution result = PluginRemoteSource.resolve("http://github.com/owner/repo"); - - assertFalse(result.isSuccess()); - assertNotNull(result.error()); - assertTrue("必须点明只接受 https:" + result.error(), result.error().contains("https")); - } - - @Test - public void rejectsNonHttpsSchemes() { - assertRejected("file:///sdcard/plugin.zip"); - assertRejected("content://media/external/file/1234"); - assertRejected("ftp://example.com/bundle.zip"); - } - - @Test - public void rejectsEmptyInput() { - assertRejected(""); - assertRejected(" "); - assertRejected(null); - } - - @Test - public void rejectsGitHubPagesThatAreNotRepoRootOrTree() { - assertRejected("https://github.com/owner/repo/blob/main/x.zip"); - assertRejected("https://github.com/owner/repo/releases"); - assertRejected("https://github.com/owner/repo/issues"); - assertRejected("https://github.com/owner/repo/archive/refs/heads/main.zip"); - assertRejected("https://github.com/owner"); - assertRejected("https://github.com/owner/repo/tree"); - } - - @Test - public void rejectsCredentialsInUrl() { - PluginRemoteSource.Resolution result = - PluginRemoteSource.resolve("https://user:secret@github.com/owner/repo"); - - assertFalse(result.isSuccess()); - assertNotNull(result.error()); - assertFalse("错误信息绝不能回显口令:" + result.error(), result.error().contains("secret")); - assertFalse(result.error().contains("user")); - } - - @Test - public void rejectsNonZipHttpsUrl() { - assertRejected("https://example.com/plugins/index.html"); - assertRejected("https://example.com/plugins/"); - } - - @Test - public void rejectsNamesOutsideConservativePattern() { - assertRejected("https://github.com/ow ner/repo"); - assertRejected("https://github.com/ope%20ner/repo"); - assertRejected("https://github.com/../repo"); - } - - @Test - public void wwwHostIsTreatedAsGithub() { - PluginRemoteSource.Resolution result = PluginRemoteSource.resolve("https://www.github.com/owner/repo"); - - assertTrue(result.error(), result.isSuccess()); - assertEquals(2, result.urls().size()); - assertEquals(CANONICAL + "main", result.urls().get(0)); - } - - @Test - public void bundleSizeCapIsStatedInBytes() { - assertEquals(16L * 1024L * 1024L, PluginRemoteSource.MAX_BUNDLE_BYTES); - } - - // ---- helpers ---- - - private static void assertTwoBranchCandidates(String url) { - PluginRemoteSource.Resolution result = PluginRemoteSource.resolve(url); - - assertNull(result.error()); - assertTrue(url + " -> " + result.urls(), result.isSuccess()); - assertEquals("仓库根必须给出两个候选:" + result.urls(), 2, result.urls().size()); - assertEquals(CANONICAL + "main", result.urls().get(0)); - assertEquals(CANONICAL + "master", result.urls().get(1)); - } - - private static void assertRejected(String url) { - PluginRemoteSource.Resolution result = PluginRemoteSource.resolve(url); - - assertFalse("should be rejected: " + url, result.isSuccess()); - assertNotNull("rejection must carry a reason: " + url, result.error()); - assertFalse(result.error().isEmpty()); - assertTrue("rejected input must not yield urls: " + url, result.urls().isEmpty()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginRuntimeFactoryTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginRuntimeFactoryTest.java deleted file mode 100644 index 8ff90b7c5a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginRuntimeFactoryTest.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -/** - * 运行时工厂测试。核心是:**未实现的引擎必须明确失败,而不是静默变成空实现**。 - * - *

这条看似琐碎,但它决定了故障归因:如果未实现引擎返回一个什么都不做的运行时, - * 用户会看到"插件装了但没反应",无法区分是插件写错了还是宿主没实现。 - */ -public class PluginRuntimeFactoryTest { - - private static PluginRuntime noop() { - return new PluginRuntime() { - @Override - public String init(String pluginId, String entry, String config) { - return "ok"; - } - - @Override - public String onEvent(String pluginId, String event, String payload) { - return "ok"; - } - - @Override - public String action(String pluginId, String action, String payload) { - return "ok"; - } - - @Override - public void destroy(String pluginId) { - } - }; - } - - @Test - public void registeredApiResolvesToItsRuntime() { - PluginRuntime runtime = noop(); - PluginRuntimeFactory factory = new PluginRuntimeFactory().register("js", runtime); - - assertTrue(factory.isSupported("js")); - assertEquals(runtime, factory.create("js")); - assertEquals(runtime, factory.create("JS")); - assertEquals(runtime, factory.create(" js ")); - } - - @Test - public void unregisteredApiReturnsFailingPlaceholderNotNull() { - PluginRuntimeFactory factory = new PluginRuntimeFactory(); - - PluginRuntime created = factory.create("js"); - assertNotNull("factory must never return null", created); - assertFalse(factory.isSupported("js")); - // 必须抛错而不是静默返回空值 - assertThrows(UnsupportedOperationException.class, () -> created.init("p", "p.js", "{}")); - assertThrows(UnsupportedOperationException.class, () -> created.onEvent("p", "e", "{}")); - assertThrows(UnsupportedOperationException.class, () -> created.action("p", "a", "{}")); - // destroy 必须安全 - created.destroy("p"); - } - - @Test - public void unsupportedRuntimeMessageDistinguishesUnknownFromUnimplemented() { - PluginRuntimeFactory factory = new PluginRuntimeFactory(); - - UnsupportedOperationException known = assertThrows(UnsupportedOperationException.class, - () -> factory.create("py").init("p", "p.py", "{}")); - assertTrue("known engine says not implemented: " + known.getMessage(), - known.getMessage().contains("not implemented")); - - UnsupportedOperationException unknown = assertThrows(UnsupportedOperationException.class, - () -> factory.create("wasm").init("p", "p.wasm", "{}")); - assertTrue("unknown engine says unknown: " + unknown.getMessage(), - unknown.getMessage().contains("unknown runtime")); - } - - @Test - public void nullAndBlankApisAreHandled() { - PluginRuntimeFactory factory = new PluginRuntimeFactory(); - assertFalse(factory.isSupported(null)); - assertFalse(factory.isSupported("")); - assertFalse(PluginRuntimeFactory.isKnownApi(null)); - assertNotNull(factory.create(null)); - } - - @Test - public void registerIgnoresInvalidInput() { - PluginRuntimeFactory factory = new PluginRuntimeFactory(); - factory.register(null, noop()); - factory.register("", noop()); - factory.register("js", null); - - assertTrue(factory.registeredApis().isEmpty()); - } - - @Test - public void describeReportsReadinessPerEngine() { - PluginRuntimeFactory factory = new PluginRuntimeFactory().register("js", noop()); - var described = factory.describe(); - - assertEquals("ready", described.get("js")); - assertEquals("not implemented", described.get("py")); - assertEquals("not implemented", described.get("html")); - } - - @Test - public void registeredApisSnapshotIsImmutableCopy() { - PluginRuntimeFactory factory = new PluginRuntimeFactory().register("js", noop()); - var apis = factory.registeredApis(); - assertEquals(1, apis.size()); - factory.clear(); - assertEquals("snapshot must not reflect later mutation", 1, apis.size()); - assertTrue(factory.registeredApis().isEmpty()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginScriptSourceTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginScriptSourceTest.java deleted file mode 100644 index 067c365b28..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginScriptSourceTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -/** - * 脚本入口解析测试。 - * - *

关键断言是**远程 URL 必须被拒绝**:放开远程加载等于把"插件能执行什么代码" - * 交给网络,与 §5 安全边界冲突。远程分发是后续切片的事,且必须先有签名校验。 - */ -public class PluginScriptSourceTest { - - @Test - public void rejectsNullAndBlank() { - assertNull(PluginScriptSource.read(null)); - assertNull(PluginScriptSource.read("")); - assertNull(PluginScriptSource.read(" ")); - } - - @Test - public void rejectsRemoteUrls() { - // 这是安全断言,不是功能缺失:远程加载必须等签名与来源校验就位 - assertNull(PluginScriptSource.read("https://example.com/x.js")); - assertNull(PluginScriptSource.read("http://example.com/x.js")); - assertNull(PluginScriptSource.read("ftp://example.com/x.js")); - } - - @Test - public void rejectsRelativePaths() { - assertNull(PluginScriptSource.read("../escape.js")); - assertNull(PluginScriptSource.read("hello.js")); - assertNull(PluginScriptSource.read("./hello.js")); - } - - @Test - public void rejectsNonExistentFile() { - assertNull(PluginScriptSource.read("file:///definitely/not/here/x.js")); - assertNull(PluginScriptSource.read("/definitely/not/here/x.js")); - } - - @Test - public void neverThrowsOnGarbageInput() { - // 审计与装载路径不能被畸形输入中断 - assertNull(PluginScriptSource.read("\u0000")); - assertNull(PluginScriptSource.read("assets://")); - assertNull(PluginScriptSource.read("file://")); - assertNull(PluginScriptSource.read("://")); - } - - @Test - public void bareAbsolutePathRequiresUnixRoot() { - // 裸绝对路径只认 "/" 开头——这是 Android 语义(设备上不存在盘符)。 - // 放宽到任意非空路径会让 "../escape.js" 这类相对路径蒙混过关, - // 因此这里主动钉住"盘符路径被拒绝"这一契约。 - assertNull(PluginScriptSource.read("C:/temp/x.js")); - assertNull(PluginScriptSource.read("C:\\temp\\x.js")); - } - - @Test - public void readsFileSchemeScript() throws Exception { - java.io.File temp = java.io.File.createTempFile("plugin-test", ".js"); - temp.deleteOnExit(); - java.nio.file.Files.writeString(temp.toPath(), "webhtv.log('x');", - java.nio.charset.StandardCharsets.UTF_8); - - String source = PluginScriptSource.read("file://" + temp.getAbsolutePath().replace('\\', '/')); - - assertNotNull("file:// script should be readable", source); - assertTrue(source.contains("webhtv.log")); - } - - @Test - public void rejectsOversizedScript() throws Exception { - java.io.File temp = java.io.File.createTempFile("plugin-big", ".js"); - temp.deleteOnExit(); - byte[] big = new byte[PluginScriptSource.MAX_SCRIPT_BYTES + 1024]; - java.util.Arrays.fill(big, (byte) 'a'); - java.nio.file.Files.write(temp.toPath(), big); - - assertNull("oversized script must be rejected", PluginScriptSource.read(temp.getAbsolutePath())); - } - - @Test - public void rejectsEmptyFile() throws Exception { - java.io.File temp = java.io.File.createTempFile("plugin-empty", ".js"); - temp.deleteOnExit(); - // 空文件必须被拒绝,而不是读成 "" 继续往下走 - assertNull(PluginScriptSource.read(temp.getAbsolutePath())); - } - - @Test - public void maxScriptBytesIsBounded() { - assertTrue("script cap must stay small enough to protect the JS engine", - PluginScriptSource.MAX_SCRIPT_BYTES <= 1024 * 1024); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginSourceConfigTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginSourceConfigTest.java deleted file mode 100644 index 8a5cc066c6..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginSourceConfigTest.java +++ /dev/null @@ -1,243 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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.After; -import org.junit.Before; -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.List; - -/** - * 持久化配置测试。 - * - *

覆盖三件事:**往返存储**(save/读回后内容一致)、**稳定顺序与覆盖语义** - * (同 id 不出现两条)、以及 **{@link PluginSourceConfig#toManifests()} 的跳过行为** - * (缺清单、空清单、非法清单都只跳过自己,不拖累整批)。 - * - *

存储用内存实现({@link PluginSourceConfig.Store}):JVM 单测没有 Context, - * {@code Prefers} 会在桩代码上抛异常。 - */ -public class PluginSourceConfigTest { - - private File workDir; - private MemoryStore store; - private PluginSourceConfig config; - - @Before - public void setUp() throws Exception { - workDir = Files.createTempDirectory("plugin-config").toFile(); - store = new MemoryStore(); - config = PluginSourceConfig.useStoreForTest(store); - } - - @After - public void tearDown() { - PluginSourceConfig.useStoreForTest(null); - delete(workDir); - } - - @Test - public void defaultsToEmptyAndNeverNull() { - assertNotNull(PluginSourceConfig.get()); - assertTrue(config.entries().isEmpty()); - assertTrue(config.enabledEntries().isEmpty()); - assertNull(config.find("nope")); - assertTrue(config.toManifests().isEmpty()); - } - - @Test - public void putOverwritesByIdAndKeepsInsertionOrder() { - config.put(entry("a", true, 1L)); - config.put(entry("b", true, 2L)); - config.put(entry("a", false, 3L)); - - List entries = config.entries(); - assertEquals(2, entries.size()); - assertEquals("a", entries.get(0).id()); - assertEquals("b", entries.get(1).id()); - assertFalse("second put for the same id must win", entries.get(0).enabled()); - assertEquals(3L, entries.get(0).importedAt()); - assertEquals(1, config.enabledEntries().size()); - } - - @Test - public void entriesAreImmutableSnapshots() { - config.put(entry("a", true, 1L)); - List entries = config.entries(); - try { - entries.add(entry("b", true, 2L)); - } catch (UnsupportedOperationException expected) { - return; - } - throw new AssertionError("entries() must be immutable"); - } - - @Test - public void rejectsUnsafeIds() { - // 纯点 id 会把写入点抬到 plugins 的父目录,必须在入口就丢掉。 - config.put(new PluginSourceConfig.Entry("..", "..", "1", "js", "/tmp/x", true, 0L)); - config.put(new PluginSourceConfig.Entry("", "empty", "1", "js", "/tmp/x", true, 0L)); - config.put(new PluginSourceConfig.Entry("a/b", "slash", "1", "js", "/tmp/x", true, 0L)); - - assertTrue(config.entries().isEmpty()); - } - - @Test - public void setEnabledAndRemoveWork() { - config.put(entry("a", true, 1L)); - config.setEnabled("a", false); - assertFalse(config.find("a").enabled()); - assertEquals(0, config.enabledEntries().size()); - - config.setEnabled("does.not.exist", true); - assertEquals("unknown id must be ignored", 1, config.entries().size()); - - config.remove("a"); - assertNull(config.find("a")); - assertTrue(config.entries().isEmpty()); - } - - /** save → 新实例读回:这是"重启后插件还在"的唯一证据。 */ - @Test - public void saveRoundTripsThroughStore() { - config.put(new PluginSourceConfig.Entry("demo.one", "一号", "1.2.3", "js", workDir.getAbsolutePath(), true, 111L)); - config.put(new PluginSourceConfig.Entry("demo.two", "二号", "0.9", "html", workDir.getAbsolutePath(), false, 222L)); - config.save(); - assertFalse("save must actually write", store.json.isEmpty()); - - PluginSourceConfig reloaded = PluginSourceConfig.useStoreForTest(store); - - List entries = reloaded.entries(); - assertEquals(2, entries.size()); - PluginSourceConfig.Entry one = reloaded.find("demo.one"); - assertNotNull(one); - assertEquals("一号", one.name()); - assertEquals("1.2.3", one.version()); - assertEquals("js", one.runtimeApi()); - assertEquals(workDir.getAbsolutePath(), one.directory()); - assertTrue(one.enabled()); - assertEquals(111L, one.importedAt()); - assertFalse(reloaded.find("demo.two").enabled()); - assertEquals(1, reloaded.enabledEntries().size()); - } - - @Test - public void survivesCorruptStorage() { - store.json = "{ this is not json"; - PluginSourceConfig broken = PluginSourceConfig.useStoreForTest(store); - assertTrue("corrupt storage must degrade to empty, not throw", broken.entries().isEmpty()); - - store.json = ""; - assertTrue(PluginSourceConfig.useStoreForTest(store).entries().isEmpty()); - } - - @Test - public void dropsDirtyEntriesOnLoad() { - store.json = "{\"version\":1,\"entries\":[" - + "{\"id\":\"..\",\"directory\":\"/tmp/x\",\"enabled\":true}," - + "{\"id\":\"ok\",\"directory\":\"/tmp/y\",\"enabled\":true}," - + "{\"id\":\"no.dir\",\"enabled\":true}]}"; - - PluginSourceConfig loaded = PluginSourceConfig.useStoreForTest(store); - - assertEquals("only the safe entry may survive", 1, loaded.entries().size()); - assertNotNull(loaded.find("ok")); - assertNull(loaded.find("..")); - assertNull(loaded.find("no.dir")); - } - - @Test - public void toManifestsKeepsOnlyEnabledAndSkipsBadPackages() throws Exception { - File enabledDir = packageDir("p-enabled", manifestJson("demo.on", "hello.js")); - write(new File(enabledDir, "hello.js"), "1;"); - File disabledDir = packageDir("p-disabled", manifestJson("demo.off", "hello.js")); - write(new File(disabledDir, "hello.js"), "1;"); - // 目录存在但没有任何清单文件:Path.read 返回 "",必须跳过。 - File missingDir = packageDir("p-missing", null); - // 清单存在但非法。 - File invalidDir = packageDir("p-invalid", "{\"protocol\":\"other\"}"); - // 目录根本不存在。 - File absentDir = new File(workDir, "p-absent"); - - config.put(new PluginSourceConfig.Entry("demo.on", "on", "1", "js", enabledDir.getAbsolutePath(), true, 1L)); - config.put(new PluginSourceConfig.Entry("demo.off", "off", "1", "js", disabledDir.getAbsolutePath(), false, 2L)); - config.put(new PluginSourceConfig.Entry("demo.missing", "missing", "1", "js", missingDir.getAbsolutePath(), true, 3L)); - config.put(new PluginSourceConfig.Entry("demo.invalid", "invalid", "1", "js", invalidDir.getAbsolutePath(), true, 4L)); - config.put(new PluginSourceConfig.Entry("demo.absent", "absent", "1", "js", absentDir.getAbsolutePath(), true, 5L)); - - List manifests = config.toManifests(); - - assertEquals("only the healthy enabled package may load", 1, manifests.size()); - assertEquals("demo.on", manifests.get(0).getId()); - } - - @Test - public void safeIdRuleMatchesDirectoryNameNeeds() { - assertTrue(PluginSourceConfig.isSafeId("example.hello")); - assertTrue(PluginSourceConfig.isSafeId("a")); - assertTrue(PluginSourceConfig.isSafeId("demo_1-x")); - assertFalse(PluginSourceConfig.isSafeId(null)); - assertFalse(PluginSourceConfig.isSafeId("")); - assertFalse(PluginSourceConfig.isSafeId("..")); - assertFalse("dots alone are a traversal", PluginSourceConfig.isSafeId("...")); - assertFalse(PluginSourceConfig.isSafeId("../x")); - assertFalse(PluginSourceConfig.isSafeId("a/b")); - assertFalse(PluginSourceConfig.isSafeId("a b")); - } - - // ---- helpers ---- - - private PluginSourceConfig.Entry entry(String id, boolean enabled, long importedAt) { - return new PluginSourceConfig.Entry(id, id, "1", "js", new File(workDir, id).getAbsolutePath(), enabled, importedAt); - } - - private File packageDir(String name, String manifestJson) throws Exception { - File dir = new File(workDir, name); - assertTrue(dir.mkdirs()); - if (manifestJson != null) write(new File(dir, "manifest.json"), manifestJson); - return dir; - } - - private static String manifestJson(String id, String entry) { - return "{\"protocol\":\"webhtv-plugin\",\"version\":1,\"id\":\"" + id + "\",\"name\":\"" + id - + "\",\"sdk\":1,\"runtime\":{\"api\":\"js\",\"entry\":\"" + entry + "\"}," - + "\"events\":[\"ui.contentShown\"],\"permissions\":[\"plugin.events\"]}"; - } - - private static void write(File file, String text) throws Exception { - File parent = file.getParentFile(); - if (parent != null && !parent.isDirectory()) assertTrue(parent.mkdirs()); - Files.writeString(file.toPath(), text, StandardCharsets.UTF_8); - } - - private static void delete(File file) { - if (file == null || !file.exists()) return; - File[] children = file.listFiles(); - if (children != null) for (File child : children) delete(child); - file.delete(); - } - - /** 内存版存储:单测里没有 Context。 */ - static final class MemoryStore implements PluginSourceConfig.Store { - - String json = ""; - - @Override - public String read() { - return json; - } - - @Override - public void write(String value) { - json = value; - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/plugin/PluginUiPresenterTest.java b/app/src/test/java/com/fongmi/android/tv/plugin/PluginUiPresenterTest.java deleted file mode 100644 index ce6fe54151..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/plugin/PluginUiPresenterTest.java +++ /dev/null @@ -1,373 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * 槽位选择逻辑的纯 JVM 测试。真正把 View 加上屏幕需要 Android 运行时, - * 由 {@code PluginUiPresenterDeviceTest} 覆盖;这里只锁住"哪些槽位会被渲染"这条判定。 - */ -public class PluginUiPresenterTest { - - private static PluginHost.PluginSlot slot(String pluginId, String type, String action) { - return new PluginHost.PluginSlot(pluginId, new PluginManifest.UiSlot(type, "标签", action, null, null)); - } - - @Test - public void countsFloatingToolbarAndPanelButNotPage() { - // panel 有自己的入口控件(与 floating 同形),因此计入可渲染数量; - // page 是整页接管,不由本渲染器挂控件,仍排除。 - List slots = List.of( - slot("p", "floating", "a"), - slot("p", "toolbar", "b"), - slot("p", "panel", "c"), - slot("p", "page", "d"), - slot("q", "floating", "e")); - - assertEquals(4, PluginUiPresenter.countRenderable(slots)); - } - - @Test - public void nullAndEmptyAreSafe() { - assertEquals(0, PluginUiPresenter.countRenderable(null)); - assertEquals(0, PluginUiPresenter.countRenderable(new ArrayList<>())); - } - - @Test - public void nullEntriesInsideListAreSkipped() { - List slots = new ArrayList<>(); - slots.add(null); - slots.add(slot("p", "floating", "a")); - assertEquals(1, PluginUiPresenter.countRenderable(slots)); - } - - @Test - public void tagCarriesPluginIdSoClicksCanBeRouted() { - // pluginId 必须在 tag 里:点击时宿主只有 action 字符串, - // 而 PluginHost.action(pluginId, action, payload) 需要 pluginId 才能定位运行时。 - assertEquals("webhtv-plugin-host:douban.comments:open", - PluginUiPresenter.tagOf(slot("douban.comments", "floating", "open"))); - } - - @Test - public void detachOnFreshPresenterIsSafe() { - PluginUiPresenter presenter = new PluginUiPresenter(); - presenter.detach(); - presenter.detach(); - assertEquals(0, presenter.attachedCount()); - assertTrue(presenter.lastFallbacks().isEmpty()); - } - - @Test - public void clickBeforeAttachIsHarmless() { - PluginUiPresenter presenter = new PluginUiPresenter(); - presenter.setOnSlotAction((pluginId, action) -> { - throw new AssertionError("must not be invoked without a rendered view"); - }); - assertFalse(presenter.attachedCount() > 0); - } - - // ---- 页面过滤 ---- - // 拖动与落点记忆依赖真实触摸事件与 SharedPreferences,纯 JVM 无处安放, - // 只有真机(androidTest)能验;这里只锁住"该页该不该出现这个入口"这条纯函数判定。 - - @Test - public void emptySurfaceSetShowsOnEveryPage() { - // 历史清单没有 surfaces 字段。若把"未声明"当成"哪都不显示", - // 升级一次宿主就会让所有既有插件集体消失,所以缺省必须是全页显示。 - assertTrue(PluginSurface.matches(Set.of(), PluginSurface.HOME)); - assertTrue(PluginSurface.matches(Set.of(), PluginSurface.DETAIL)); - assertTrue(PluginSurface.matches(null, PluginSurface.PLAYBACK)); - } - - @Test - public void declaredSurfaceMatchesCurrentPage() { - assertTrue(PluginSurface.matches(Set.of(PluginSurface.DETAIL), PluginSurface.DETAIL)); - assertTrue(PluginSurface.matches(Set.of(PluginSurface.DETAIL, PluginSurface.PLAYBACK), PluginSurface.PLAYBACK)); - } - - @Test - public void undeclaredCurrentPageIsFilteredOut() { - // 首页不该出现只在详情页生效的入口:那里没有剧集上下文,点开只能看到"宿主没有传片名"。 - assertFalse(PluginSurface.matches(Set.of(PluginSurface.DETAIL, PluginSurface.PLAYBACK), PluginSurface.HOME)); - assertFalse(PluginSurface.matches(Set.of(PluginSurface.OTHER), PluginSurface.HOME)); - } - - @Test - public void surfaceMatchingIgnoresCase() { - // 清单是手写 JSON,作者写成 "Detail" 不该静默失效。 - assertTrue(PluginSurface.matches(Set.of("Detail"), PluginSurface.DETAIL)); - assertTrue(PluginSurface.matches(Set.of(PluginSurface.PLAYBACK), "PLAYBACK")); - } - - @Test - public void unknownDeclaredSurfacesAreIgnoredInsteadOfThrowing() { - // 插件可能声明宿主还不认识的页面名;不认识就当不匹配,不该让渲染路径炸掉。 - assertFalse(PluginSurface.matches(Set.of("settings", "cast"), PluginSurface.HOME)); - assertTrue(PluginSurface.matches(Set.of("settings", PluginSurface.HOME), PluginSurface.HOME)); - } - - @Test - public void nullActivityIsOtherSurface() { - // Detail 必须由名字先判出来(TmdbDetailActivity 也是 PlaybackActivity 的子类), - // 这条只验证判不出东西时不抛、统一落到 other。 - assertEquals(PluginSurface.OTHER, PluginSurface.of(null)); - } - - // ---- 全屏范围 ---- - - /** - * 全屏是**排他**的:进入沉浸态后生效范围里只剩 fullscreen。 - * - *

这条是全屏隐藏能成立的前提。若全屏时集合里仍含 playback,所有声明 playback 的 - * 插件都会照旧显示——也就是用户报的"全屏后豆瓣 chip 还在"。 - */ - @Test - public void immersiveScopeReplacesPageScope() { - assertEquals(Set.of(PluginSurface.FULLSCREEN), PluginSurface.scopesOf(PluginSurface.PLAYBACK, true)); - assertEquals(Set.of(PluginSurface.FULLSCREEN), PluginSurface.scopesOf(PluginSurface.DETAIL, true)); - // 非沉浸态保持原页面,不能顺手把其他页面也改掉。 - assertEquals(Set.of(PluginSurface.PLAYBACK), PluginSurface.scopesOf(PluginSurface.PLAYBACK, false)); - assertEquals(Set.of(PluginSurface.DETAIL), PluginSurface.scopesOf(PluginSurface.DETAIL, false)); - } - - /** 只声明 detail/playback 的插件(豆瓣短评就是这种)在全屏下必须落选。 */ - @Test - public void playbackScopedEntranceIsHiddenInFullscreen() { - Set active = PluginSurface.scopesOf(PluginSurface.PLAYBACK, true); - assertFalse(PluginSurface.matchesAny(Set.of(PluginSurface.DETAIL, PluginSurface.PLAYBACK), active)); - assertFalse(PluginSurface.matchesAny(Set.of(PluginSurface.PLAYBACK), active)); - } - - /** 显式声明 fullscreen 的插件在全屏下要能显示,否则这个能力等于不存在。 */ - @Test - public void fullscreenScopedEntranceIsShownInFullscreen() { - Set active = PluginSurface.scopesOf(PluginSurface.PLAYBACK, true); - assertTrue(PluginSurface.matchesAny(Set.of(PluginSurface.FULLSCREEN), active)); - assertTrue(PluginSurface.matchesAny(Set.of(PluginSurface.PLAYBACK, PluginSurface.FULLSCREEN), active)); - // 只在全屏用的入口,退回内嵌小窗后应当消失。 - assertFalse(PluginSurface.matchesAny(Set.of(PluginSurface.FULLSCREEN), - PluginSurface.scopesOf(PluginSurface.PLAYBACK, false))); - } - - /** 空集仍是"全页显示":老清单不会因为这次改动在全屏下凭空消失。 */ - @Test - public void undeclaredSurfacesStayVisibleInFullscreen() { - Set active = PluginSurface.scopesOf(PluginSurface.PLAYBACK, true); - assertTrue(PluginSurface.matchesAny(Set.of(), active)); - assertTrue(PluginSurface.matchesAny(null, active)); - } - - // ---- 电视端范围(详情与播放同在一页) ---- - - /** - * 电视端 playback 必须同时算 detail。 - * - *

电视端没有独立详情页:详情与播放同在一个 {@code VideoActivity} 里,类名不含 Detail, - * {@code PluginSurface.of} 只能把它判成 playback。若这里仍按单页算,清单写 - * {@code surfaces:["detail"]} 的插件在电视上永远没有入口——刚加的电视插件行正好收不到它们。 - */ - @Test - public void leanbackPlaybackScopeAlsoCoversDetail() { - assertEquals(Set.of(PluginSurface.DETAIL, PluginSurface.PLAYBACK), - PluginSurface.scopesOf(PluginSurface.PLAYBACK, false, true)); - // 手机端保持单页语义:只在详情页生效的入口不该跑到播放页上。 - assertEquals(Set.of(PluginSurface.PLAYBACK), PluginSurface.scopesOf(PluginSurface.PLAYBACK, false, false)); - // 非播放页不受端影响。 - assertEquals(Set.of(PluginSurface.HOME), PluginSurface.scopesOf(PluginSurface.HOME, false, true)); - // 全屏仍然排他:两参与三参都只返回 fullscreen,否则声明 playback 的入口会在全屏下照旧显示。 - assertEquals(Set.of(PluginSurface.FULLSCREEN), PluginSurface.scopesOf(PluginSurface.PLAYBACK, true, true)); - assertEquals(Set.of(PluginSurface.FULLSCREEN), PluginSurface.scopesOf(PluginSurface.PLAYBACK, true)); - assertEquals(Set.of(PluginSurface.FULLSCREEN), PluginSurface.scopesOf(PluginSurface.DETAIL, true)); - } - - /** 声明 detail 的入口:电视上要能出现,手机播放页上仍要消失。 */ - @Test - public void detailOnlyEntranceIsVisibleOnTvVideoPageOnly() { - assertTrue(PluginSurface.matchesAny(Set.of(PluginSurface.DETAIL), - PluginSurface.scopesOf(PluginSurface.PLAYBACK, false, true))); - assertFalse(PluginSurface.matchesAny(Set.of(PluginSurface.DETAIL), - PluginSurface.scopesOf(PluginSurface.PLAYBACK, false, false))); - } - - /** - * 接线断言:{@code attach} 必须把 leanback 一起交给纯函数。 - * - *

回退成两参 {@code scopesOf(page, immersive)} 或更早的 - * {@code matches(surfaces, of(activity))} 时这条会红——那时电视端的 {@code detail} 声明 - * 也就同时消失了。纯 JVM 拿不到 Activity/View,只能读源码钉住这条接线。 - */ - @Test - public void attachPassesLeanbackIntoScopesOf() throws Exception { - String body = methodBody(source("src", "main", "java", "com", "fongmi", "android", "tv", "plugin", - "PluginUiPresenter.java"), "public void attach("); - - assertTrue("attach 必须走集合判定 scopesOf", body.contains("PluginSurface.scopesOf(")); - assertFalse("单页面判定 matches(...) 会让电视端丢掉 detail 声明", body.contains("PluginSurface.matches(")); - int call = body.indexOf("PluginSurface.scopesOf("); - assertTrue("scopesOf 必须收到 attach 的 leanback 形参", - body.substring(call, body.indexOf(';', call)).contains("leanback")); - } - - // ---- 连点抑制(PluginController) ---- - // 本来该落在 PluginControllerTest:本轮只允许改这 4 个文件,而这条行为又是防连点的**唯一** - // 可 JVM 验证点,所以借用本文件。装置很小:一份 panel 槽位清单 + 一个会阻塞的 fake runtime。 - - @Test - public void repeatedPanelClickRunsThePluginOnce() throws Exception { - PluginController.reset(); - PluginController controller = new PluginController(); - CountDownLatch entered = new CountDownLatch(1); - CountDownLatch again = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - AtomicInteger runs = new AtomicInteger(); - controller.registerRuntime("js", new PluginRuntime() { - @Override - public String init(String pluginId, String entry, String config) { - return "ok"; - } - - @Override - public String onEvent(String pluginId, String event, String payload) { - return "ok"; - } - - @Override - public String action(String pluginId, String action, String payload) { - if (runs.incrementAndGet() > 1) again.countDown(); - entered.countDown(); - try { - release.await(3, TimeUnit.SECONDS); - } catch (InterruptedException ignored) { - } - return "ok"; - } - - @Override - public void destroy(String pluginId) { - } - }); - controller.reload(List.of(panelManifest()), null); - - controller.dispatchSlotAction("a", "comments.show"); - assertTrue("动作必须先抵达运行时", entered.await(3, TimeUnit.SECONDS)); - // 第一次还在跑时再点一次:必须直接返回,既不重跑插件也不开第二个壳。 - controller.dispatchSlotAction("a", "comments.show"); - release.countDown(); - - assertFalse("进行中的同一 panel 动作不得再派发", again.await(1, TimeUnit.SECONDS)); - assertEquals(1, runs.get()); - // 动作结束后 key 必须归还,否则这颗入口永久失效。 - controller.dispatchSlotAction("a", "comments.show"); - assertTrue("动作结束后必须能再次派发", again.await(3, TimeUnit.SECONDS)); - assertEquals(2, runs.get()); - PluginController.reset(); - } - - private static PluginManifest panelManifest() { - return PluginManifest.parse(""" - { - "protocol": "webhtv-plugin", - "version": 1, - "id": "a", - "name": "a", - "sdk": 1, - "runtime": { "api": "js", "entry": "assets://plugin/example/hello.js" }, - "permissions": ["plugin.events", "ui.panel"], - "ui": [ { "slot": "panel", "label": "评论", "action": "comments.show" } ] - } - """); - } - - // ---- 结果落点(panel 的结果必须有去处) ---- - // 同样本该落在 PluginControllerTest:本轮只允许改 PluginController 与这个文件。行为断言做不到—— - // fillPanel 把工作投给 mainHandler(),而 JVM 下 Looper 是桩、mainHandler() 返回 null,整段分支 - // 根本跑不到;所以这里改用源码断言把两条接线钉死。 - - /** - * 开不出壳(token 为 null)时,回来的 html 必须开成一个有内容的面板,不能静默丢弃。 - * - *

回退点:删掉 {@code PluginController} 里 - * {@code if (token == null) { PluginPanelDialog.show(...) }} 这个退化分支(或把它改成 - * {@code return})本用例立刻变红——那正是"用户点了 chip 屏幕上毫无反应"。 - */ - @Test - public void panelResultWithoutAShellStillOpensAContentPanel() throws Exception { - String body = methodBody(controllerSource(), "private void fillPanel("); - - int fallback = body.indexOf("token == null"); - assertTrue("token 为 null 时必须有退化分支", fallback >= 0); - // 空 html 仍然只收尾、不弹空壳:这条必须排在退化分支之前。 - assertTrue("空 html 不能借退化分支弹出空面板", body.indexOf("html == null || html.isEmpty()") < fallback); - int show = body.indexOf("PluginPanelDialog.show(fragment, title, html"); - assertTrue("退化分支必须真的开面板,而不是静默丢弃", show > fallback); - assertTrue("有壳时仍然走 fill,不能被退化分支顶掉", - show < body.indexOf("PluginPanelDialog.fill(fragment, token, html)")); - } - - /** - * 结果侧不得再回查槽位:panel 判定与标题必须随 token 从派发侧传下来。 - * - *

回退点:把 {@code routeSlotResult} 改回内部 {@code host.slotOfAction(...)}(第二个断言红), - * 或把 {@code onSlotAction} 的调用改回 {@code routeSlotResult(activity, pluginId, action, ...)} - * (最后两个断言红)。那种写法在插件被卸载/清单变化时会把 panel 结果掉进预览分支, - * 已开的壳永不收尾。 - */ - @Test - public void resultRoutingDoesNotReLookUpTheSlot() throws Exception { - String controller = controllerSource(); - String route = methodBody(controller, "private void routeSlotResult("); - assertFalse("结果侧不得回查槽位", route.contains("slotOfAction(")); - assertTrue("panel 的判定要真的用上传下来的参数", route.contains("if (panel)")); - - int signature = controller.indexOf("private void routeSlotResult("); - assertTrue("找不到 routeSlotResult 签名", signature >= 0); - String params = controller.substring(signature, controller.indexOf('{', signature)); - assertTrue("panel 判定必须由派发侧传下来: " + params, params.contains("boolean panel")); - assertTrue("标题必须随 token 一起传下来: " + params, params.contains("String label")); - - String onSlot = methodBody(controller, "private void onSlotAction("); - int call = onSlot.indexOf("routeSlotResult("); - assertTrue("派发侧必须真的把判定结果传下去", call >= 0); - String args = onSlot.substring(call, onSlot.indexOf(';', call)); - assertTrue("缺少 panel 实参: " + args, args.contains("panel")); - assertTrue("缺少 label 实参: " + args, args.contains("label")); - } - - private static String controllerSource() throws Exception { - return source("src", "main", "java", "com", "fongmi", "android", "tv", "plugin", "PluginController.java"); - } - - /** 读源码:工作目录可能是 app/ 也可能是仓库根,两种都要能读到。 */ - private static String source(String... parts) throws Exception { - Path root = Files.exists(Path.of("src", "main")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(Path.of("", parts)), StandardCharsets.UTF_8); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("Missing method: " + signature, start >= 0); - int open = source.indexOf('{', start); - assertTrue("Missing method body: " + signature, open >= 0); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char value = source.charAt(i); - if (value == '{') depth++; - if (value == '}' && --depth == 0) return source.substring(open + 1, i); - } - throw new AssertionError("Unclosed method: " + signature); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/ApkUrlPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/ApkUrlPolicyTest.java deleted file mode 100644 index 236d7d338f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/server/process/ApkUrlPolicyTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.fongmi.android.tv.server.process; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.List; - -import org.junit.Test; - -import okhttp3.HttpUrl; - -public class ApkUrlPolicyTest { - - @Test - public void acceptsPublicHttpsUrlAndHttpsRedirect() { - HttpUrl source = ApkUrlPolicy.parse("https://gh.acmsz.top/https://github.com/app.apk"); - assertEquals("https", source.scheme()); - assertEquals("cdn.example.com", ApkUrlPolicy.redirect(source, "https://cdn.example.com/app.apk").host()); - } - - @Test - public void rejectsUnsafeSchemesCredentialsAndLiteralAddresses() { - assertThrows(IllegalArgumentException.class, () -> ApkUrlPolicy.parse("http://example.com/app.apk")); - assertThrows(IllegalArgumentException.class, () -> ApkUrlPolicy.parse("file:///tmp/app.apk")); - assertThrows(IllegalArgumentException.class, () -> ApkUrlPolicy.parse("https://user:pass@example.com/app.apk")); - assertThrows(IllegalArgumentException.class, () -> ApkUrlPolicy.parse("https://127.0.0.1/app.apk")); - assertThrows(IllegalArgumentException.class, () -> ApkUrlPolicy.parse("https://10.0.0.1/app.apk")); - assertThrows(IllegalArgumentException.class, () -> ApkUrlPolicy.parse("https://[::1]/app.apk")); - assertThrows(IllegalArgumentException.class, () -> ApkUrlPolicy.parse("https://[fc00::1]/app.apk")); - } - - @Test - public void rejectsRedirectDowngradeAndPrivateDnsAnswers() throws Exception { - HttpUrl source = ApkUrlPolicy.parse("https://example.com/app.apk"); - assertThrows(IllegalArgumentException.class, () -> ApkUrlPolicy.redirect(source, null)); - assertThrows(IllegalArgumentException.class, () -> ApkUrlPolicy.redirect(source, "http://cdn.example.com/app.apk")); - assertThrows(UnknownHostException.class, () -> ApkUrlPolicy.requirePublicAddresses("example.com", List.of(InetAddress.getByName("192.168.1.2")))); - assertThrows(UnknownHostException.class, () -> ApkUrlPolicy.requirePublicAddresses("example.com", List.of(InetAddress.getByName("8.8.8.8"), InetAddress.getByName("127.0.0.1")))); - } - - @Test - public void recognizesPublicAndSpecialUseAddresses() throws Exception { - assertTrue(ApkUrlPolicy.isPublicAddress(InetAddress.getByName("8.8.8.8"))); - assertTrue(ApkUrlPolicy.isPublicAddress(InetAddress.getByName("2606:4700:4700::1111"))); - assertFalse(ApkUrlPolicy.isPublicAddress(InetAddress.getByName("100.64.0.1"))); - assertFalse(ApkUrlPolicy.isPublicAddress(InetAddress.getByName("198.18.0.1"))); - assertFalse(ApkUrlPolicy.isPublicAddress(InetAddress.getByName("2001:db8::1"))); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/ApkUrlPushTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/ApkUrlPushTest.java deleted file mode 100644 index 9f6d375f0f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/server/process/ApkUrlPushTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.fongmi.android.tv.server.process; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InterruptedIOException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -import org.junit.Test; - -import okhttp3.Protocol; - -public class ApkUrlPushTest { - - @Test - public void boundedCopyWritesExpectedContent() throws Exception { - byte[] value = new byte[]{1, 2, 3, 4}; - ByteArrayOutputStream output = new ByteArrayOutputStream(); - long written = ApkUrlPush.copy(new ByteArrayInputStream(value), output, value.length, 16, 8, 4); - assertEquals(value.length, written); - assertArrayEquals(value, output.toByteArray()); - } - - @Test - public void boundedCopyRejectsTruncationOverflowAndStorageShortage() { - assertThrows(IOException.class, () -> ApkUrlPush.copy(new ByteArrayInputStream(new byte[]{1, 2}), new ByteArrayOutputStream(), 3, 16, 8, 4)); - assertThrows(IOException.class, () -> ApkUrlPush.copy(new ByteArrayInputStream(new byte[]{1, 2, 3, 4, 5}), new ByteArrayOutputStream(), -1, 16, 4, 4)); - assertThrows(IOException.class, () -> ApkUrlPush.copy(new ByteArrayInputStream(new byte[]{1, 2, 3}), new ByteArrayOutputStream(), -1, 6, 8, 4)); - } - - @Test - public void boundedCopyRejectsEmptyContent() { - assertThrows(IOException.class, () -> ApkUrlPush.copy(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 0, 16, 8, 4)); - } - - @Test - public void apkDownloadClientUsesHttp11Only() { - assertEquals(List.of(Protocol.HTTP_1_1), ApkUrlPush.createClient().protocols()); - } - - @Test - public void boundedCopyReportsKnownLengthProgress() throws Exception { - byte[] value = new byte[]{1, 2, 3, 4}; - List events = new ArrayList<>(); - ApkUrlPush.copy(new ByteArrayInputStream(value), new ByteArrayOutputStream(), value.length, 16, 8, 4, () -> false, - (progress, bytes, total, speed, elapsed) -> events.add(new long[]{progress, bytes, total, speed, elapsed})); - - assertFalse(events.isEmpty()); - long[] last = events.get(events.size() - 1); - assertEquals(100, last[0]); - assertEquals(value.length, last[1]); - assertEquals(value.length, last[2]); - } - - @Test - public void boundedCopyStopsWhenCanceled() { - AtomicBoolean canceled = new AtomicBoolean(); - ByteArrayInputStream input = new ByteArrayInputStream(new byte[]{1, 2, 3, 4, 5, 6, 7, 8}) { - @Override - public synchronized int read(byte[] buffer, int offset, int length) { - return super.read(buffer, offset, Math.min(2, length)); - } - }; - - assertThrows(InterruptedIOException.class, () -> ApkUrlPush.copy(input, new ByteArrayOutputStream(), 8, 16, 12, 4, canceled::get, - (progress, bytes, total, speed, elapsed) -> { - if (bytes >= 2) canceled.set(true); - })); - assertTrue(canceled.get()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/ConfigUseLiveSyncAcceptanceTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/ConfigUseLiveSyncAcceptanceTest.java deleted file mode 100644 index 0dce8ac713..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/server/process/ConfigUseLiveSyncAcceptanceTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.fongmi.android.tv.server.process; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.setting.ConfigSyncPolicy; - -import org.junit.Test; - -public class ConfigUseLiveSyncAcceptanceTest { - - @Test - public void synchronizedPairingContinuesWhenUrlsMatch() { - assertTrue(ConfigSyncPolicy.shouldSyncLive("https://example.com/config", "https://example.com/config")); - } - - @Test - public void independentLiveConfigIsPreservedWhenUrlsDiffer() { - assertFalse(ConfigSyncPolicy.shouldSyncLive("https://vod.example.com/config", "https://live.example.com/config")); - } - - @Test - public void emptyInitialPairingContinuesWhenBothUrlsAreEmpty() { - assertTrue(ConfigSyncPolicy.shouldSyncLive("", "")); - } - - @Test - public void nullVodUrlIsNotTreatedAsSameIndependentLiveSource() { - assertFalse(ConfigSyncPolicy.shouldSyncLive(null, "https://live.example.com/config")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/HistoryScopeSyncTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/HistoryScopeSyncTest.java deleted file mode 100644 index 372e07dbb7..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/server/process/HistoryScopeSyncTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.fongmi.android.tv.server.process; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import com.fongmi.android.tv.api.config.VodSiteCatalog; -import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.bean.History; - -import org.junit.Test; - -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * 桥接推送"按源落库"的协议契约。 - * - *

真正的落库路径(History.syncTo / VodSiteCatalog.findConfig 命中分支)要 Room 与运行中的 - * 上下文,纯 JVM 单测无法构造;这里锁定的是不依赖 Android 的部分:新协议开关的判定、 - * 未知配置标识的报错、以及按 cid 落库的入口签名存在。 - */ -public class HistoryScopeSyncTest { - - private static Map params(String configKey) { - Map params = new HashMap<>(); - params.put("type", "history"); - params.put("mode", "1"); - if (configKey != null) params.put("configKey", configKey); - return params; - } - - @Test - public void scopedPushRequiresNonBlankConfigKey() { - assertTrue(Action.isScopedHistoryPush(params("bridge-1"))); - assertFalse(Action.isScopedHistoryPush(params(null))); - assertFalse(Action.isScopedHistoryPush(params(""))); - assertFalse(Action.isScopedHistoryPush(params(" "))); - assertFalse(Action.isScopedHistoryPush(null)); - } - - @Test - public void scopedPushDoesNotDependOnLegacyConfigField() { - Map params = params("bridge-1"); - assertFalse(params.containsKey("config")); - assertTrue(Action.isScopedHistoryPush(params)); - } - - @Test - public void blankConfigKeyIsRejectedBeforeTouchingDatabase() { - for (String key : new String[]{null, "", " "}) { - try { - VodSiteCatalog.findConfig(key); - fail("expected IllegalArgumentException for key=" + key); - } catch (IllegalArgumentException e) { - assertEquals("缺少配置标识", e.getMessage()); - } - } - } - - /** - * M4:落库必须写到「那个配置」的 cid。 - * - *

桥接推送的整个价值就在这一行:请求写 B,落库就必须是 B 的 cid。抽出的接缝让本用例 - * 能在不碰 Room 的前提下看到 cid;一旦实现改回 {@code VodConfig.getCid()},这里立刻变红。 - */ - @Test - public void scopedPushWritesToTargetCid() { - Recorder recorder = new Recorder(); - Config target = config(77, "https://target.example/config"); - - Action.pushScopedHistory(target, List.of(new History()), false, recorder); - - assertEquals(List.of(77), recorder.written); - assertEquals("不 force 时不得清空目标源历史", List.of(), recorder.cleared); - assertEquals(1, recorder.refreshed); - } - - /** 同上,force 先清目标 cid 再写;换一个配置就必须换一个 cid,证明 cid 不是常量。 */ - @Test - public void scopedPushClearsTargetCidAndFollowsConfig() { - Recorder first = new Recorder(); - Action.pushScopedHistory(config(77, "https://target.example/config"), List.of(new History()), true, first); - assertEquals("force 必须先清空同一个 cid", List.of(77), first.cleared); - assertEquals(List.of(77), first.written); - - Recorder second = new Recorder(); - Action.pushScopedHistory(config(91, "https://other.example/config"), List.of(new History()), false, second); - assertEquals("cid 只能来自解析出的配置", List.of(91), second.written); - } - - private static Config config(int id, String url) { - Config config = new Config(); - config.setType(0); - config.setUrl(url); - config.setId(id); - return config; - } - - private static final class Recorder implements Action.ScopedHistorySink { - - private final List cleared = new ArrayList<>(); - private final List written = new ArrayList<>(); - private int refreshed; - - @Override public void clear(int cid) { cleared.add(cid); } - - @Override public void write(int cid, List targets) { written.add(cid); } - - @Override public void refresh() { refreshed++; } - } - - @Test - public void cidScopedHistoryEntryPointsExist() throws Exception { - Method syncTo = History.class.getMethod("syncTo", int.class, List.class); - assertNotNull(syncTo); - assertTrue(java.lang.reflect.Modifier.isStatic(syncTo.getModifiers())); - - Method findByName = History.class.getMethod("findByName", int.class, String.class); - assertNotNull(findByName); - assertTrue(java.lang.reflect.Modifier.isStatic(findByName.getModifiers())); - assertEquals(List.class, findByName.getReturnType()); - } - - @Test - public void legacySyncStillTargetsActiveSource() throws Exception { - Method legacy = History.class.getMethod("sync", List.class); - assertTrue(java.lang.reflect.Modifier.isStatic(legacy.getModifiers())); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/M3u8LegacyFallbackTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/M3u8LegacyFallbackTest.java deleted file mode 100644 index 6c22fc9f75..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/server/process/M3u8LegacyFallbackTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.fongmi.android.tv.server.process; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -/** Keeps the local HLS proxy fallback controlled by the master switch and its dedicated toggle. */ -public class M3u8LegacyFallbackTest { - - @Test - public void proxyUsesLegacyFallbackWithoutRequiringStructuredRules() throws Exception { - String source = Files.readString(Path.of("src/main/java/com/fongmi/android/tv/server/process/M3u8.java")); - - assertFalse(source.contains("hlsRules(), true")); - assertFalse(source.contains("rules, true")); - assertTrue(source.contains("legacyFallback = HlsRuleConfig.isLegacyFallbackEnabled()")); - assertFalse(source.contains("legacyFallback = !rules.isEmpty()")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/PlaybackHistoryApiTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/PlaybackHistoryApiTest.java deleted file mode 100644 index c4e9b3ad0f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/server/process/PlaybackHistoryApiTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.fongmi.android.tv.server.process; - -import static org.junit.Assert.assertEquals; - -import java.util.HashMap; -import java.util.Map; - -import org.junit.Test; - -/** - * 按站点读历史端点的参数归一化契约。 - * - *

只测纯逻辑部分(参数解析与夹取)。HTTP 层(状态码、CORS、响应体形态、 - * 观影记录同步开关的 403 门控)**目前没有自动化覆盖**:实例化端点需要 - * {@code IHTTPSession} 与运行中的 {@code NanoHTTPD},纯 JVM 单测无法构造。 - * 那部分靠真机手工验证(开关关闭 → 403;开启 → 200 且 items 只含 apiSafe 字段)。 - * 本类不声称覆盖它。 - */ -public class PlaybackHistoryApiTest { - - @Test - public void siteKeyPrefersSiteKeyOverSiteAlias() { - Map params = new HashMap<>(); - params.put("siteKey", "csp_main"); - params.put("site", "csp_alias"); - - assertEquals("csp_main", PlaybackHistoryApi.siteKey(params)); - } - - @Test - public void siteKeyFallsBackToSiteAliasAndTrims() { - Map params = new HashMap<>(); - params.put("site", " csp_alias "); - - assertEquals("csp_alias", PlaybackHistoryApi.siteKey(params)); - } - - @Test - public void siteKeyIsEmptyWhenAbsent() { - // 空 siteKey 由调用方判 400;这里只保证不会返回 null 或抛异常。 - assertEquals("", PlaybackHistoryApi.siteKey(new HashMap<>())); - assertEquals("", PlaybackHistoryApi.siteKey(Map.of("siteKey", " "))); - } - - @Test - public void blankSiteKeyStillFallsBackToSiteAlias() { - // 回归锁:`?siteKey=%20&site=csp_x` 里调用方明确给了别名, - // 若只看 isEmpty 就返回空串,端点会报 400 而调用方拿不到解释。 - // 只有主参数与别名**都**空才返回空串。 - Map params = new HashMap<>(); - params.put("siteKey", " "); - params.put("site", "csp_alias"); - - assertEquals("csp_alias", PlaybackHistoryApi.siteKey(params)); - } - - @Test - public void limitFallsBackToDefaultOnMissingOrMalformed() { - // 坏参数一律回落默认(0 表示"用默认"),而不是报 400—— - // 对端传了一个坏 limit 不该让整次拉取失败。 - assertEquals(0, PlaybackHistoryApi.limit(new HashMap<>())); - assertEquals(0, PlaybackHistoryApi.limit(Map.of("limit", "abc"))); - assertEquals(0, PlaybackHistoryApi.limit(Map.of("limit", ""))); - assertEquals(0, PlaybackHistoryApi.limit(Map.of("limit", "1.5"))); - } - - @Test - public void limitParsesValidNumber() { - assertEquals(7, PlaybackHistoryApi.limit(Map.of("limit", "7"))); - assertEquals(7, PlaybackHistoryApi.limit(Map.of("limit", " 7 "))); - // 负值原样返回,由 effectiveHistoryLimit 负责回落默认。 - assertEquals(-3, PlaybackHistoryApi.limit(Map.of("limit", "-3"))); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/ProxyRedirectResponseTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/ProxyRedirectResponseTest.java deleted file mode 100644 index 87ddf64ac9..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/server/process/ProxyRedirectResponseTest.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.fongmi.android.tv.server.process; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.lang.reflect.Method; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; - -import fi.iki.elonen.NanoHTTPD; -import fi.iki.elonen.NanoHTTPD.Response; - -public class ProxyRedirectResponseTest { - - private static final String INVALID_RESPONSE = "Invalid proxy response"; - - @Test - public void redirectMayHaveAnEmptyBody() throws Exception { - assertTrue(canResponseHaveEmptyBody(301)); - assertTrue(canResponseHaveEmptyBody(302)); - assertTrue(canResponseHaveEmptyBody(307)); - } - - @Test - public void nonRedirectStillRequiresAStreamBody() throws Exception { - assertFalse(canResponseHaveEmptyBody(200)); - assertFalse(canResponseHaveEmptyBody(204)); - assertFalse(canResponseHaveEmptyBody(400)); - } - - @Test - public void shortResponsesReturnServerErrorWithoutThrowing() throws Exception { - assertEquals(INVALID_RESPONSE, responseBody(createResponse(new Object[] {302}))); - assertEquals(INVALID_RESPONSE, responseBody(createResponse(new Object[] {302, "text/html"}))); - } - - @Test - public void redirectWithEmptyBodyRequiresLocationHeader() throws Exception { - Map headers = new HashMap<>(); - headers.put("Location", "https://example.com/video"); - - Response response = createResponse(new Object[] {302, "text/html", null, headers}); - - assertEquals(Response.Status.FOUND, response.getStatus()); - assertEquals("https://example.com/video", response.getHeader("Location")); - assertEquals(0, responseBody(response).length()); - } - - @Test - public void redirectWithoutLocationHeaderIsRejected() throws Exception { - Response response = createResponse(new Object[] {302, "text/html", null}); - - assertEquals(INVALID_RESPONSE, responseBody(response)); - } - - @Test - public void streamResponseKeepsBodyAndHeader() throws Exception { - Map headers = new HashMap<>(); - headers.put("Content-Type", "video/mp4"); - InputStream stream = new ByteArrayInputStream("video".getBytes(StandardCharsets.UTF_8)); - - Response response = createResponse(new Object[] {200, "video/mp4", stream, headers}); - - assertEquals(Response.Status.OK, response.getStatus()); - assertEquals("video/mp4", response.getHeader("Content-Type")); - assertEquals("video", responseBody(response)); - } - - @Test - public void objectResponseIsReturnedDirectly() throws Exception { - Response response = NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "text/plain", "ok"); - - assertSame(response, createResponse(new Object[] {response})); - } - - @Test - public void missingResponseReturnsServerError() throws Exception { - assertEquals(INVALID_RESPONSE, responseBody(createResponse(null))); - assertEquals(INVALID_RESPONSE, responseBody(createResponse(new Object[0]))); - } - - private static Response createResponse(Object[] rs) throws Exception { - Proxy proxy = new Proxy(); - Method method = Proxy.class.getDeclaredMethod("createResponse", Map.class, String.class, Object[].class); - method.setAccessible(true); - return (Response) method.invoke(proxy, new HashMap<>(), "/proxy", rs); - } - - private static String responseBody(Response response) throws Exception { - InputStream stream = response.getData(); - assertNotNull(stream); - byte[] buffer = new byte[8192]; - int length = stream.read(buffer); - return length < 0 ? "" : new String(buffer, 0, length, StandardCharsets.UTF_8); - } - - private static boolean canResponseHaveEmptyBody(int code) throws Exception { - Method method = Proxy.class.getDeclaredMethod("canResponseHaveEmptyBody", int.class); - method.setAccessible(true); - return (Boolean) method.invoke(null, code); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/VodApiTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/VodApiTest.java deleted file mode 100644 index ef2dc050d3..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/server/process/VodApiTest.java +++ /dev/null @@ -1,813 +0,0 @@ -package com.fongmi.android.tv.server.process; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import com.fongmi.android.tv.api.CatSource; -import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.api.config.VodSiteCatalog; -import com.fongmi.android.tv.api.loader.BaseLoader; -import com.fongmi.android.tv.bean.Class; -import com.fongmi.android.tv.bean.Config; -import com.fongmi.android.tv.bean.Flag; -import com.fongmi.android.tv.bean.Result; -import com.fongmi.android.tv.bean.Site; -import com.fongmi.android.tv.bean.Vod; -import com.fongmi.android.tv.bean.Rule; -import com.github.catvod.bean.Doh; -import com.github.catvod.crawler.Spider; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import org.junit.Test; - -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import fi.iki.elonen.NanoHTTPD; - -/** - * 本机 T4 网关的转换契约。 - * - *

参考对象是影视+ 实验室的 {@code T4Proxy.js} 与 {@code vod.php}:两者都把 - * Spider API 的返回重新包装成苹果CMS V10。这里断言同一份信封形状与字段名, - * 保证 T4 客户端在两套实现之间可以互换订阅。 - */ -public class VodApiTest { - - @Test - public void configListsEverySiteAsType4PointingBackAtGateway() { - List sites = new ArrayList<>(); - sites.add(Site.get("csp_Demo", "演示站")); - Site noSearch = Site.get("js_site", "JS站"); - noSearch.setSearchable(false); - sites.add(noSearch); - - JsonObject config = VodApi.configJson(sites, "http://192.168.1.9:9978/vod/api", null, null); - - assertEquals("", config.get("spider").getAsString()); - assertEquals("资源来自网络,仅供学习使用", config.get("warningText").getAsString()); - JsonArray out = config.getAsJsonArray("sites"); - assertEquals(2, out.size()); - - JsonObject first = out.get(0).getAsJsonObject(); - assertEquals("csp_Demo", first.get("key").getAsString()); - assertEquals("演示站", first.get("name").getAsString()); - assertEquals("http://192.168.1.9:9978/vod/api?key=csp_Demo", first.get("api").getAsString()); - assertEquals("type 必须是字符串 4,与 T4Proxy.js 一致", "4", first.get("type").getAsString()); - assertEquals(1, first.get("searchable").getAsInt()); - assertEquals(1, first.get("filterable").getAsInt()); - - assertEquals("不可搜索的站点要如实上报", 0, out.get(1).getAsJsonObject().get("searchable").getAsInt()); - assertTrue(config.has("doh")); - assertTrue(config.has("rules")); - assertTrue(config.has("lives")); - } - - @Test - public void macCmsEnvelopeMatchesT4ProxyShape() { - Result result = new Result(); - result.setList(List.of(vod("vod-1", "测试片"))); - result.setTypes(List.of(type("movie", "电影"))); - - JsonObject out = VodApi.macCms(result, 3); - - assertEquals(3, out.get("page").getAsInt()); - assertEquals(1, out.get("limit").getAsInt()); - assertEquals("站源没给总页数时按 9999 处理", 9999, out.get("pagecount").getAsInt()); - assertEquals(9999, out.get("total").getAsInt()); - assertEquals(1, out.getAsJsonArray("list").size()); - assertEquals(1, out.getAsJsonArray("class").size()); - assertEquals("movie", out.getAsJsonArray("class").get(0).getAsJsonObject().get("type_id").getAsString()); - } - - @Test - public void macCmsUsesSourcePageCountWhenPresent() { - Result result = new Result(); - setField(result, "pagecount", 7); - result.setList(List.of(vod("vod-1", "测试片"))); - - JsonObject out = VodApi.macCms(result, 2); - - assertEquals(7, out.get("pagecount").getAsInt()); - assertEquals(7, out.get("total").getAsInt()); - } - - @Test - public void macCmsNeverLeaksInternalSiteState() { - Vod item = vod("vod-1", "测试片"); - item.setSite(Site.get("csp_Secret", "内部站点")); - - JsonObject out = VodApi.macCms(result(item), 1).getAsJsonArray("list").get(0).getAsJsonObject(); - - assertFalse("T4 响应不能带出内部 Site 对象", out.has("site")); - assertFalse("T4 响应不能带出内部 flag 对象", out.has("vodFlags")); - assertFalse("T4 响应不能带出内部 TMDB 载荷", out.has("tmdb")); - } - - @Test - public void vodJsonMapsStandardAppleCmsFields() { - Vod item = vod("vod-9", "片名"); - item.setTypeName("动作片"); - item.setPic("https://img.example/9.jpg"); - item.setRemarks("更新至08集"); - item.setYear("2026"); - item.setArea("大陆"); - item.setDirector("导演"); - item.setActor("演员"); - item.setContent("简介"); - - JsonObject out = VodApi.vodJson(item); - - assertEquals("vod-9", out.get("vod_id").getAsString()); - assertEquals("片名", out.get("vod_name").getAsString()); - assertEquals("动作片", out.get("type_name").getAsString()); - assertEquals("https://img.example/9.jpg", out.get("vod_pic").getAsString()); - assertEquals("更新至08集", out.get("vod_remarks").getAsString()); - assertEquals("2026", out.get("vod_year").getAsString()); - assertEquals("大陆", out.get("vod_area").getAsString()); - assertEquals("导演", out.get("vod_director").getAsString()); - assertEquals("演员", out.get("vod_actor").getAsString()); - assertEquals("简介", out.get("vod_content").getAsString()); - } - - @Test - public void vodJsonFallsBackToFlagsWhenSpiderOnlyReturnsFlagArray() { - // T3 爬虫常见形态:只有 flags,没有 vod_play_from / vod_play_url - Vod item = new Vod(); - item.setId("vod-1"); - item.setName("测试片"); - Flag hd = Flag.create("HD"); - hd.setEpisodes("第01集$https://v.example/1.m3u8#第02集$https://v.example/2.m3u8"); - Flag sd = Flag.create("SD"); - sd.setEpisodes("第01集$https://v.example/sd1.m3u8"); - item.setFlags(new ArrayList<>(List.of(hd, sd))); - - JsonObject out = VodApi.vodJson(item); - - assertEquals("HD$$$SD", out.get("vod_play_from").getAsString()); - assertEquals("第01集$https://v.example/1.m3u8#第02集$https://v.example/2.m3u8$$$第01集$https://v.example/sd1.m3u8", - out.get("vod_play_url").getAsString()); - } - - @Test - public void vodJsonKeepsSourceProvidedPlayFields() { - Vod item = new Vod(); - item.setId("vod-1"); - item.setName("测试片"); - item.setPlayFrom("线路A"); - item.setPlayUrl("第01集$https://v.example/a.m3u8"); - - JsonObject out = VodApi.vodJson(item); - - assertEquals("线路A", out.get("vod_play_from").getAsString()); - assertEquals("第01集$https://v.example/a.m3u8", out.get("vod_play_url").getAsString()); - } - - @Test - public void playEnvelopeCarriesResolvedUrlAndClientHints() { - Result result = new Result(); - result.setUrl("https://v.example/final.m3u8"); - result.setFlag("HD"); - result.setParse(1); - result.setFormat("application/x-mpegURL"); - HashMap header = new HashMap<>(); - header.put("Referer", "https://v.example/"); - result.setHeader(header); - - JsonObject out = VodApi.play(result, "HD"); - - assertEquals("https://v.example/final.m3u8", out.get("url").getAsString()); - assertEquals("HD", out.get("flag").getAsString()); - assertEquals(1, out.get("parse").getAsInt()); - assertEquals("application/x-mpegURL", out.get("format").getAsString()); - assertEquals("https://v.example/", out.getAsJsonObject("header").get("Referer").getAsString()); - } - - @Test - public void playEnvelopeFallsBackToRequestedFlag() { - Result result = new Result(); - result.setUrl("https://v.example/final.m3u8"); - - JsonObject out = VodApi.play(result, "线路A"); - - assertEquals("线路A", out.get("flag").getAsString()); - } - - @Test - public void playEnvelopeListsMultipleResolvedUrls() { - Result result = new Result(); - // setUrl 会真正写入字段;之后 getUrl() 才返回同一个对象,add 才能生效。 - result.setUrl("https://v.example/a.m3u8"); - result.getUrl().add("https://v.example/b.m3u8"); - - JsonObject out = VodApi.play(result, "HD"); - - assertTrue(out.has("urls")); - assertEquals(2, out.getAsJsonArray("urls").size()); - assertEquals("首选仍是 position 指向的那条", "https://v.example/a.m3u8", out.get("url").getAsString()); - assertEquals("https://v.example/b.m3u8", out.getAsJsonArray("urls").get(1).getAsString()); - } - - @Test - public void playEnvelopeToleratesResultWithoutUrl() { - JsonObject out = VodApi.play(new Result(), "HD"); - - assertEquals("", out.get("url").getAsString()); - assertEquals("HD", out.get("flag").getAsString()); - } - - @Test - public void routePrefersConfigThenSearchThenExplicitAc() { - assertEquals("config", VodApi.route(parse("{\"ac\":\"config\"}"))); - assertEquals("config", VodApi.route(parse("{\"ac\":\"site\"}"))); - assertEquals("search", VodApi.route(parse("{\"wd\":\"关键词\"}"))); - assertEquals("search", VodApi.route(parse("{\"ac\":\"detail\",\"wd\":\"关键词\"}"))); - assertEquals("list", VodApi.route(parse("{\"ac\":\"detail\"}"))); - assertEquals("play", VodApi.route(parse("{\"ac\":\"play\"}"))); - assertEquals("list", VodApi.route(parse("{\"ac\":\"list\"}"))); - assertEquals("list", VodApi.route(parse("{}"))); - } - - @Test - public void routeInfersFromParametersWhenAcIsAbsent() { - assertEquals("detail", VodApi.route(parse("{\"ids\":\"1,2\"}"))); - assertEquals("play", VodApi.route(parse("{\"flag\":\"HD\",\"id\":\"1\"}"))); - assertEquals("play", VodApi.route(parse("{\"flag\":\"HD\",\"play\":\"1\"}"))); - assertEquals("list", VodApi.route(parse("{\"t\":\"1\",\"pg\":\"2\"}"))); - assertEquals("list", VodApi.route(parse("{\"flag\":\"HD\"}"))); - } - - @Test - public void siteKeyComesFromPathOrQuery() { - assertEquals("csp_Demo", VodApi.siteKeyFromPath("/vod/api/csp_Demo")); - // IHTTPSession.getUri() 已去掉查询部分并解码路径。 - assertEquals("中文/key?+", VodApi.siteKeyFromPath("/vod/api/中文/key?+")); - assertEquals("csp_Demo/detail", VodApi.siteKeyFromPath("/vod/api/csp_Demo/detail")); - assertEquals("", VodApi.siteKeyFromPath("/vod/api")); - assertEquals("", VodApi.siteKeyFromPath("/vod/api?ac=config")); - assertEquals("", VodApi.siteKeyFromPath("/spider")); - } - - @Test - public void extendMergesExplicitFilterFields() { - HashMap extend = VodApi.extendFrom(parse("{\"area\":\"大陆\",\"year\":\"2026\"}")); - - assertEquals("大陆", extend.get("area")); - assertEquals("2026", extend.get("year")); - assertFalse(extend.containsKey("type")); - } - - @Test - public void extendReadsJsonExtendParameter() { - HashMap extend = VodApi.extendFrom(parse("{\"extend\":\"{\\\"lang\\\":\\\"国语\\\"}\"}")); - - assertEquals("国语", extend.get("lang")); - } - - @Test - public void explicitFilterFieldsOverrideExtendJson() { - HashMap extend = VodApi.extendFrom( - parse("{\"extend\":\"{\\\"year\\\":\\\"2020\\\"}\",\"year\":\"2026\"}")); - - assertEquals("2026", extend.get("year")); - } - - @Test - public void blankExtendIsEmptyWithoutDecoding() { - assertTrue(VodApi.extendFrom(parse("{}")).isEmpty()); - assertEquals(null, VodApi.decodeExtend("")); - } - - @Test - public void idsAcceptsCommaListAndJsonArray() { - assertEquals("1,2", VodApi.csv(parse("{\"ids\":\"1,2\"}"), "ids")); - assertEquals("1,2", VodApi.csv(parse("{\"ids\":[\"1\",\"2\"]}"), "ids")); - assertEquals("", VodApi.csv(parse("{}"), "ids")); - } - - @Test - public void errorEnvelopeMatchesSpiderApiShape() { - JsonObject out = VodApi.error("site not found: demo"); - - assertEquals(-1, out.get("code").getAsInt()); - assertEquals("site not found: demo", out.get("msg").getAsString()); - } - - @Test - public void realT4CategoryRequestMustNotBeTreatedAsDetail() { - assertEquals("list", VodApi.route(parse("{\"ac\":\"detail\",\"t\":\"movie\",\"pg\":\"2\"}"))); - assertEquals("detail", VodApi.route(parse("{\"ac\":\"detail\",\"ids\":\"a,b\"}"))); - assertEquals("list", VodApi.route(parse("{\"ac\":\"detail\"}"))); - } - - @Test - public void routeDoesNotClaimAdjacentEndpoints() { - assertTrue(new VodApi().isRequest(null, "/vod/api")); - assertTrue(new VodApi().isRequest(null, "/vod/api/demo")); - assertFalse(new VodApi().isRequest(null, "/vod/apix")); - } - - @Test - public void urlSafeBase64FiltersMustReachCategoryUnchanged() { - String ext = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString( - "{\"year\":\"2026\",\"area\":\"大陆\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); - JsonObject request = new JsonObject(); - request.addProperty("ext", ext); - assertEquals("2026", VodApi.extendFrom(request).get("year")); - assertEquals("大陆", VodApi.extendFrom(request).get("area")); - } - - @Test - public void httpRoundTripSupportsActualT4ClientRequests() throws Exception { - try (HttpFixture fixture = new HttpFixture()) { - JsonObject config = fixture.request("GET", "?ac=config", null).body; - String api = config.getAsJsonArray("sites").get(0).getAsJsonObject().get("api").getAsString(); - assertTrue(api, api.endsWith("?key=" + encode(HttpFixture.KEY))); - String route = "?key=" + encode(HttpFixture.KEY); - JsonObject home = fixture.request("GET", route, null).body; - assertEquals("7", home.getAsJsonArray("class").get(0).getAsJsonObject().get("type_id").getAsString()); - assertTrue(home.has("filters")); - assertEquals("推荐", home.getAsJsonArray("list").get(0).getAsJsonObject().get("vod_name").getAsString()); - - String ext = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString("{\"year\":\"2026\"}".getBytes(StandardCharsets.UTF_8)); - JsonObject category = fixture.request("GET", route + "&ac=detail&t=7&pg=2&ext=" + ext, null).body; - assertEquals("category:7:2:true", fixture.spider.lastCall); - assertEquals("2026", fixture.spider.extend.get("year")); - assertEquals(4, category.get("pagecount").getAsInt()); - assertEquals(10, category.get("limit").getAsInt()); - assertEquals(33, category.get("total").getAsInt()); - assertEquals("7", category.getAsJsonArray("list").get(0).getAsJsonObject().get("type_id").getAsString()); - - JsonObject detail = fixture.request("POST", route, "{\"ac\":\"detail\",\"ids\":[\"first\",\"second,opaque\"]}").body; - assertEquals(List.of("first", "second,opaque"), fixture.spider.ids); - assertEquals(2, detail.getAsJsonArray("list").size()); - assertFalse(detail.getAsJsonArray("list").get(0).getAsJsonObject().has("site")); - fixture.request("GET", route + "&wd=test&quick=true&pg=2", null); - assertEquals("search:test:true:2", fixture.spider.lastCall); - fixture.request("GET", route + "&wd=test", null); - assertEquals("search:test:false:1", fixture.spider.lastCall); - - JsonObject play = fixture.request("GET", route + "&play=opaque%2Bplay&flag=HD", null).body; - assertEquals("play:HD:opaque+play", fixture.spider.lastCall); - assertEquals(List.of("vip"), fixture.spider.flags); - assertEquals(1, play.get("parse").getAsInt()); - assertEquals(1, play.get("jx").getAsInt()); - assertEquals(fixture.origin + "/proxy?do=fixture&token=a%2Bb&siteKey=" + encode(HttpFixture.KEY), play.get("url").getAsString()); - assertEquals("https://example.test/", play.getAsJsonObject("header").get("Referer").getAsString()); - } - } - - @Test - public void httpPreflightAndErrorsDoNotExecuteSpiders() throws Exception { - try (HttpFixture fixture = new HttpFixture()) { - assertEquals(204, fixture.request("OPTIONS", "?key=" + encode(HttpFixture.KEY) + "&play=x&flag=HD", null).status); - assertEquals("", fixture.spider.lastCall); - assertEquals(-1, fixture.request("GET", "?key=unknown", null).body.get("code").getAsInt()); - assertEquals(-1, fixture.request("POST", "", "{malformed").body.get("code").getAsInt()); - assertEquals(-1, fixture.request("GET", "?key=" + encode(HttpFixture.KEY) + "&ac=play", null).body.get("code").getAsInt()); - assertEquals("", fixture.spider.lastCall); - } - } - - @Test - public void localUrlsKeepOpaqueQueryAndPinProxySource() { - String origin = "http://192.168.50.3:9978"; - assertEquals(origin + "/proxy?url=https%3A%2F%2Fa.test%2Fv&sig=a%2Bb&siteKey=source%2Bkey", - VodApi.remoteUrl("http://localhost:9978/proxy?url=https%3A%2F%2Fa.test%2Fv&sig=a%2Bb", origin, "source+key", 9978)); - String external = "https://cdn.test/v?next=http://127.0.0.1:9978/proxy"; - assertEquals(external, VodApi.remoteUrl(external, origin, "source", 9978)); - assertEquals("http://127.0.0.1:1234/v", VodApi.remoteUrl("http://127.0.0.1:1234/v", origin, "source", 9978)); - assertEquals(origin + "/proxy?siteKey=original", VodApi.remoteUrl("http://[::1]:9978/proxy?siteKey=original", origin, "other", 9978)); - } - - @Test - public void rawPlaybackKeepsAlternativesSubtitlesAndDrm() { - JsonObject raw = parse("{\"url\":[\"HD\",\"http://127.0.0.1:9978/proxy?do=x\",\"SD\",\"https://cdn.test/sd\"],\"parse\":1,\"playUrl\":\"https://parse.test/?url=\",\"header\":{\"Referer\":\"http://localhost:9978/unchanged\"},\"subs\":[{\"url\":\"http://localhost:9978/sub.vtt\"}],\"drm\":{\"server\":\"http://localhost:9978/license\"}}"); - JsonObject out = VodApi.playJson(raw, "HD", Site.get("s", "source")); - VodApi.externalize(out, "http://192.168.1.1:9978", "s", 9978); - assertEquals("HD", out.getAsJsonArray("url").get(0).getAsString()); - assertEquals("http://192.168.1.1:9978/proxy?do=x&siteKey=s", out.getAsJsonArray("url").get(1).getAsString()); - assertEquals("https://cdn.test/sd", out.getAsJsonArray("url").get(3).getAsString()); - assertEquals("https://parse.test/?url=", out.get("playUrl").getAsString()); - assertEquals("http://localhost:9978/unchanged", out.getAsJsonObject("header").get("Referer").getAsString()); - assertEquals("http://192.168.1.1:9978/sub.vtt", out.getAsJsonArray("subs").get(0).getAsJsonObject().get("url").getAsString()); - assertEquals("http://192.168.1.1:9978/license", out.getAsJsonObject("drm").get("server").getAsString()); - } - - @Test - public void emptyPagesStopAndLargeTotalsDoNotOverflow() { - assertEquals(3, VodApi.formatContent(parse("{\"list\":[]}"), 3).get("pagecount").getAsInt()); - JsonObject full = VodApi.formatContent(parse("{\"pagecount\":2147483647,\"limit\":20,\"list\":[]}"), 1); - assertEquals(42949672940L, full.get("total").getAsLong()); - } - - @Test - public void jsonObjectFiltersAndEmptyFlagSlotsArePreserved() { - assertEquals("2026", VodApi.extendFrom(parse("{\"extend\":{\"year\":\"2026\"}}")).get("year")); - JsonObject out = VodApi.formatContent(parse("{\"list\":[{\"vod_id\":\"a\",\"flags\":[{\"flag\":\"empty\",\"episodes\":[]},{\"flag\":\"HD\",\"episodes\":[{\"name\":\"01\",\"url\":\"id\"}]}]}]}"), 1); - JsonObject vod = out.getAsJsonArray("list").get(0).getAsJsonObject(); - assertEquals("empty$$$HD", vod.get("vod_play_from").getAsString()); - assertEquals("$$$01$id", vod.get("vod_play_url").getAsString()); - } - - @Test - public void selfSubscriptionIsRejectedWithoutBlockingOtherDevices() { - assertTrue(VodApi.isSelfGateway("http://127.0.0.1:9978/vod/api?key=x", "http://192.168.1.1:9978", 9978)); - assertTrue(VodApi.isSelfGateway("http://192.168.1.1:9978/vod/api/x", "http://192.168.1.1:9978", 9978)); - assertFalse(VodApi.isSelfGateway("http://192.168.1.2:9978/vod/api/x", "http://192.168.1.1:9978", 9978)); - } - - @Test - public void directProxyPinningDoesNotDisableLegacyJarProxy() { - assertFalse(VodApi.supportsDirectProxy(new Spider() { })); - assertTrue(VodApi.supportsDirectProxy(new FixtureSpider())); - } - - /** 桥接进来的配置:站点清单来自 catalog,当前激活源必须完全不参与。 */ - @Test - public void configKeyServesAnotherConfigWithoutTouchingActiveSource() throws Exception { - FixtureSpider other = new FixtureSpider(); - Site site = new Site() { - @Override public Spider spider() { return other; } - @Override public Site recent() { throw new AssertionError("Catalog must not change the active loader"); } - }; - site.setKey("catalog_site"); - site.setName("订阅站"); - site.setApi("csp_Catalog"); - site.setType(3); - VodSiteCatalog.Source catalogs = interfaceKey -> { - assertEquals("bridge-1", interfaceKey); - return new VodSiteCatalog.Catalog(List.of(site), List.of(), List.of(), List.of("svip")); - }; - - try (HttpFixture fixture = new HttpFixture(catalogs)) { - JsonObject config = fixture.request("GET", "?ac=config&configKey=bridge-1", null).body; - JsonObject item = config.getAsJsonArray("sites").get(0).getAsJsonObject(); - assertEquals("catalog_site", item.get("key").getAsString()); - assertTrue(item.get("api").getAsString(), item.get("api").getAsString().endsWith("?key=catalog_site&configKey=bridge-1")); - - String route = "?key=catalog_site&configKey=bridge-1"; - JsonObject home = fixture.request("GET", route, null).body; - assertEquals("7", home.getAsJsonArray("class").get(0).getAsJsonObject().get("type_id").getAsString()); - assertEquals("home:true", other.lastCall); - - JsonObject play = fixture.request("GET", route + "&play=opaque&flag=HD", null).body; - assertEquals("play:HD:opaque", other.lastCall); - assertEquals(List.of("svip"), other.flags); - assertEquals(fixture.origin + "/proxy?do=fixture&token=a%2Bb&siteKey=catalog_site&configKey=bridge-1", - play.get("url").getAsString()); - assertEquals("桥接配置不能触发当前激活源", "", fixture.spider.lastCall); - } - } - - @Test - public void unknownConfigKeyFailsWithoutFallingBackToActiveSource() throws Exception { - VodSiteCatalog.Source catalogs = interfaceKey -> { - throw new IllegalArgumentException("未知的配置标识: " + interfaceKey); - }; - - try (HttpFixture fixture = new HttpFixture(catalogs)) { - JsonObject body = fixture.request("GET", "?ac=config&configKey=missing", null).body; - assertEquals(-1, body.get("code").getAsInt()); - assertEquals("未知的配置标识: missing", body.get("msg").getAsString()); - assertEquals("", fixture.spider.lastCall); - } - } - - @Test - public void proxyUrlsCarrySiteAndConfigKeyForNonActiveSources() { - String origin = "http://192.168.50.3:9978"; - assertEquals(origin + "/proxy?do=x&siteKey=site&configKey=cfg%2B1", - VodApi.remoteUrl("http://127.0.0.1:9978/proxy?do=x", origin, "site", "cfg+1", 9978)); - assertEquals(origin + "/proxy?do=x&siteKey=site", - VodApi.remoteUrl("http://127.0.0.1:9978/proxy?do=x", origin, "site", "", 9978)); - assertEquals(origin + "/proxy?siteKey=orig&configKey=cfg", - VodApi.remoteUrl("http://127.0.0.1:9978/proxy?siteKey=orig&configKey=cfg", origin, "s", "cfg", 9978)); - assertEquals("http://127.0.0.1:1234/proxy?do=x", - VodApi.remoteUrl("http://127.0.0.1:1234/proxy?do=x", origin, "site", "cfg", 9978)); - } - - /** - * B1:桥接解析遇到非激活配置的猫源时必须直接失败。 - * - *

{@link CatSource#reuse} 是唯一的放行口子;实现里一旦退回 {@code serve},这里就会 - * 因为「当前没有 bundle 在服务」而拿到 IllegalStateException,而不是去动全局 node。 - */ - @Test - public void nonActiveBundleIsRejectedInsteadOfRestartingNode() { - try { - CatSource.reuse("http://127.0.0.1:9988/cat/index.js"); - fail("非激活配置的 bundle 必须被拒绝"); - } catch (IllegalStateException e) { - assertTrue(e.getMessage(), e.getMessage().contains("已拒绝解析")); - } - - String catalog = read("com/fongmi/android/tv/api/config/VodSiteCatalog.java"); - assertTrue("桥接解析只允许复用已在服务的 bundle", catalog.contains("CatSource.reuse(")); - assertFalse("桥接解析不得调用会换源杀进程的 CatSource.serve", catalog.contains("CatSource.serve(")); - - String runtime = read("com/fongmi/android/tv/node/NodeRuntime.java"); - assertTrue("NodeRuntime 需要只读判定", runtime.contains("public static boolean isServing(String url)")); - assertTrue("isServing 必须复用 same() 的比对语义", runtime.indexOf("isServing") < runtime.indexOf("private static boolean same(") + 4000); - } - - /** B2:缓存槽的命名空间前缀,空命名空间必须与改动前逐字节等价。 */ - @Test - public void spiderSlotKeepsActiveCacheKeysByteIdentical() { - assertEquals("active_site", BaseLoader.slot("", "active_site")); - assertEquals("active_site", BaseLoader.slot(null, "active_site")); - assertEquals("cfg@@@active_site", BaseLoader.slot("cfg", "active_site")); - assertEquals(java.util.Set.of("active_site"), BaseLoader.baseKeys(List.of("active_site"))); - assertEquals(java.util.Set.of("active_site"), BaseLoader.baseKeys(List.of("cfg@@@active_site"))); - assertEquals(java.util.Set.of("a", "b"), BaseLoader.baseKeys(List.of("a", "cfg@@@b"))); - - String loader = read("com/fongmi/android/tv/api/loader/BaseLoader.java"); - assertTrue("猫源站点的缓存键必须带命名空间", loader.contains("catSpiders.computeIfAbsent(slot, k -> new CatSpider(api))")); - assertTrue("四种 spider 都要拿到命名空间", - loader.contains("pyLoader.getSpider(namespace, key, api, ext)") - && loader.contains("jsLoader.getSpider(namespace, key, api, ext, jar)") - && loader.contains("jarLoader.getSpider(namespace, key, api, ext, jar)")); - assertTrue("桥接 spider 必须落在 configKey 命名空间里", - loader.contains("getSpider(VodSiteCatalog.namespace(configKey), site.getKey()")); - - for (String name : new String[]{"JsLoader", "PyLoader", "JarLoader"}) { - String source = read("com/fongmi/android/tv/api/loader/" + name + ".java"); - assertTrue(name + " 必须用 slot 建缓存键", source.contains("BaseLoader.slot(namespace, key)")); - } - String jar = read("com/fongmi/android/tv/api/loader/JarLoader.java"); - assertTrue("jar 的 dex 缓存仍只按 jar md5 建键", jar.contains("String jaKey = Util.md5(jar)")); - assertTrue("插件脚本保护要看到不带命名空间的 key", - read("com/fongmi/android/tv/api/loader/JsLoader.java").contains("BaseLoader.baseKeys(spiders.keySet())")); - } - - /** B3:显式带 configKey 时解析失败不许回落到激活配置。 */ - @Test - public void catalogProxyNeverFallsBackToActiveLoader() { - String loader = read("com/fongmi/android/tv/api/loader/BaseLoader.java"); - assertFalse("不得保留 spider 为空就回落的写法", loader.contains("spider != null ? spider :")); - assertTrue("带 configKey 时只走 catalogSpider", - loader.contains("if (!TextUtils.isEmpty(params.get(\"configKey\"))) return catalogSpider(")); - int method = loader.indexOf("private Spider catalogSpider("); - assertTrue("catalogSpider 必须向调用方抛异常", method > 0 && loader.indexOf("throws Exception", method) > method); - assertFalse("catalogSpider 不得把异常吞成 null", loader.substring(method, loader.indexOf("public void parseJar")).contains("catch")); - - String proxy = read("com/fongmi/android/tv/server/process/Proxy.java"); - assertTrue("异常必须转成可见的错误响应", proxy.contains("return Nano.error(Objects.toString(e.getMessage(), e.toString()));")); - } - - /** B4:配置标识的解析与既有播放身份解析器收敛成一套。 */ - @Test - public void configKeyResolutionAcceptsExactAndLegacyIdentity() { - Config exact = config(11, "https://exact.example/config"); - Config legacy = config(22, "https://legacy.example/config"); - - assertSame("精确命中优先", exact, VodSiteCatalog.resolve("k", exact, legacy)); - assertSame("未命中精确键时沿用既有解析器", legacy, VodSiteCatalog.resolve("k", null, legacy)); - - for (Config[] pair : new Config[][]{{null, null}, {exact, null}}) { - String id = pair[0] == null ? "missing" : "blank-url"; - if (pair[0] != null) pair[0].setUrl(" "); - try { - VodSiteCatalog.resolve(id, pair[0], pair[1]); - fail("未知的配置标识必须报错: " + id); - } catch (IllegalArgumentException e) { - assertEquals("未知的配置标识: " + id, e.getMessage()); - } - } - - String catalog = code("com/fongmi/android/tv/api/config/VodSiteCatalog.java"); - assertTrue("必须接入既有身份解析器", catalog.contains("PlaybackConfigIdentity.cidForKey(id)")); - assertTrue("兜底按 cid 只读取行", catalog.contains("Config.find(cid)")); - assertFalse("桥接解析绝不能走会插行的 Config.find(url, type)", catalog.contains("Config.find(url")); - } - - /** M1/M2/M3:缓存失效入口、时间戳取在解析之后、清单补齐原生加载的两步。 */ - @Test - public void catalogCacheAndSiteListMatchNativeLoading() { - String catalog = read("com/fongmi/android/tv/api/config/VodSiteCatalog.java"); - assertTrue("需要公开的失效入口", catalog.contains("public static void invalidate()")); - assertTrue("解析完成后再取时间戳", - catalog.indexOf("catalogOf(findConfig(id), id)") > 0 - && catalog.indexOf("new Entry(catalog, System.currentTimeMillis())") > catalog.indexOf("catalogOf(findConfig(id), id)")); - assertTrue("不得在解析前采样时间", !catalog.contains("long now = System.currentTimeMillis()")); - assertTrue("补齐 Site.findAll() 回填", catalog.contains("for (Site site : Site.findAll()) known.put(site.getKey(), site);")); - assertTrue("补齐 CustomCspSetting 合并", catalog.contains("CustomCspSetting.inject(sites)")); - - String config = read("com/fongmi/android/tv/bean/Config.java"); - int hooks = 0; - for (int at = config.indexOf("invalidateCatalog();"); at >= 0; at = config.indexOf("invalidateCatalog();", at + 1)) hooks++; - assertEquals("insert/save/delete 三处都要失效缓存", 3, hooks); - } - - private static Config config(int id, String url) { - Config config = new Config(); - config.setType(0); - config.setUrl(url); - config.setId(id); - return config; - } - - private static String code(String relative) { - StringBuilder builder = new StringBuilder(); - for (String line : read(relative).split("\n", -1)) { - String trimmed = line.trim(); - if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue; - builder.append(line).append('\n'); - } - return builder.toString(); - } - - private static String read(String relative) { - java.nio.file.Path path = mainJava().resolve(java.nio.file.Paths.get(relative.replace('/', java.io.File.separatorChar))); - try { - return new String(java.nio.file.Files.readAllBytes(path), StandardCharsets.UTF_8); - } catch (java.io.IOException e) { - throw new AssertionError("无法读取 " + path, e); - } - } - - private static java.nio.file.Path mainJava() { - java.nio.file.Path dir = java.nio.file.Paths.get("").toAbsolutePath(); - for (int i = 0; i < 6 && dir != null; i++, dir = dir.getParent()) { - java.nio.file.Path candidate = dir.resolve(java.nio.file.Paths.get("app", "src", "main", "java")); - if (java.nio.file.Files.isDirectory(candidate)) return candidate; - } - throw new IllegalStateException("app/src/main/java not found from " + java.nio.file.Paths.get("").toAbsolutePath()); - } - - private static String encode(String value) throws Exception { - return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); - } - - private static final class FixtureSpider extends Spider { - volatile String lastCall = ""; - volatile Map extend = Map.of(); - volatile List ids = List.of(); - volatile List flags = List.of(); - - @Override public String homeContent(boolean filter) { - lastCall = "home:" + filter; - return "{\"class\":[{\"type_id\":\"7\",\"type_name\":\"电影\"}],\"filters\":{\"7\":[{\"key\":\"year\",\"name\":\"年份\",\"value\":[{\"n\":\"2026\",\"v\":\"2026\"}]}]}}"; - } - @Override public String homeVideoContent() { return "{\"list\":[{\"vod_id\":\"home\",\"vod_name\":\"推荐\"}]}"; } - @Override public String categoryContent(String tid, String pg, boolean filter, HashMap extend) { - lastCall = "category:" + tid + ":" + pg + ":" + filter; - this.extend = new HashMap<>(extend); - return "{\"page\":2,\"pagecount\":4,\"limit\":10,\"total\":33,\"list\":[{\"vod_id\":\"category\",\"type_id\":\"7\",\"vod_name\":\"分类\"}]}"; - } - @Override public String detailContent(List ids) { - lastCall = "detail"; - this.ids = new ArrayList<>(ids); - JsonArray list = new JsonArray(); - for (String id : ids) { - JsonObject item = new JsonObject(); - item.addProperty("vod_id", id); - item.addProperty("vod_name", id); - item.addProperty("vod_play_from", "HD"); - item.addProperty("vod_play_url", "01$opaque"); - item.add("site", parse("{\"key\":\"private\"}")); - list.add(item); - } - JsonObject result = new JsonObject(); - result.add("list", list); - return result.toString(); - } - @Override public String searchContent(String key, boolean quick) { return searchContent(key, quick, "1"); } - @Override public String searchContent(String key, boolean quick, String pg) { - lastCall = "search:" + key + ":" + quick + ":" + pg; - return "{\"list\":[]}"; - } - @Override public Object[] proxy(Map params) { - return new Object[]{200, "text/plain", new java.io.ByteArrayInputStream("fixture".getBytes(StandardCharsets.UTF_8))}; - } - @Override public String playerContent(String flag, String id, List flags) { - lastCall = "play:" + flag + ":" + id; - this.flags = new ArrayList<>(flags); - return "{\"url\":\"http://127.0.0.1:9978/proxy?do=fixture&token=a%2Bb\",\"parse\":1,\"jx\":1,\"header\":{\"Referer\":\"https://example.test/\"}}"; - } - } - - private static final class HttpFixture implements AutoCloseable { - static final String KEY = "中文/+?&key"; - final FixtureSpider spider = new FixtureSpider(); - final NanoHTTPD server; - final String origin; - final int previousPort; - - HttpFixture() throws Exception { - this(null); - } - - HttpFixture(VodSiteCatalog.Source catalogs) throws Exception { - Site site = new Site() { - @Override public Spider spider() { return spider; } - @Override public Site recent() { throw new AssertionError("Gateway must not change the active loader"); } - }; - site.setKey(KEY); - site.setName("fixture"); - site.setApi("csp_Fixture"); - site.setType(3); - VodConfig sources = new VodConfig() { - @Override public List getSites() { return List.of(site); } - @Override public List getDoh() { return List.of(); } - @Override public List getRules() { return List.of(); } - @Override public List getFlags() { return List.of("vip"); } - }; - VodApi gateway = new VodApi(sources, catalogs != null ? catalogs : VodSiteCatalog.database()); - server = new NanoHTTPD("127.0.0.1", 0) { - @Override public Response serve(IHTTPSession session) { - if (!gateway.isRequest(session, session.getUri())) return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found"); - Map files = new HashMap<>(); - try { - if (session.getMethod() == Method.POST) session.parseBody(files); - return gateway.doResponse(session, session.getUri(), files); - } catch (Exception e) { - throw new AssertionError(e); - } - } - }; - previousPort = com.github.catvod.Proxy.getPort(); - com.github.catvod.Proxy.set(9978); - try { server.start(5000, true); } catch (Exception e) { com.github.catvod.Proxy.set(previousPort); throw e; } - origin = "http://127.0.0.1:" + server.getListeningPort(); - } - - HttpResult request(String method, String suffix, String body) throws Exception { - HttpURLConnection connection = (HttpURLConnection) new URL(origin + "/vod/api" + suffix).openConnection(); - connection.setConnectTimeout(5000); - connection.setReadTimeout(5000); - connection.setRequestMethod(method); - try { - if (body != null) { - connection.setDoOutput(true); - connection.setRequestProperty("Content-Type", "application/json; charset=utf-8"); - try (java.io.OutputStream output = connection.getOutputStream()) { output.write(body.getBytes(StandardCharsets.UTF_8)); } - } - int status = connection.getResponseCode(); - String data; - try (InputStream input = status >= 400 ? connection.getErrorStream() : connection.getInputStream()) { - data = input == null ? "" : new String(input.readAllBytes(), StandardCharsets.UTF_8); - } - return new HttpResult(status, data.isEmpty() ? new JsonObject() : JsonParser.parseString(data).getAsJsonObject()); - } finally { - connection.disconnect(); - } - } - @Override public void close() { - server.stop(); - com.github.catvod.Proxy.set(previousPort); - } - } - - private record HttpResult(int status, JsonObject body) { } - - private static Result result(Vod vod) { - Result result = new Result(); - result.setList(List.of(vod)); - return result; - } - - private static Vod vod(String id, String name) { - Vod vod = new Vod(); - vod.setId(id); - vod.setName(name); - return vod; - } - - private static Class type(String id, String name) { - Class type = new Class(); - type.setTypeId(id); - type.setTypeName(name); - return type; - } - - private static JsonObject parse(String json) { - JsonObject out = VodApi.jsonObject(json); - assertNotNull("应能解析为 JSON 对象: " + json, out); - return out; - } - - private static void setField(Object target, String name, Object value) { - try { - java.lang.reflect.Field field = target.getClass().getDeclaredField(name); - field.setAccessible(true); - field.set(target, value); - } catch (Exception e) { - throw new AssertionError("无法设置字段 " + name, e); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/server/proxy/MultiThreadProxyPlayerUiSourceTest.java b/app/src/test/java/com/fongmi/android/tv/server/proxy/MultiThreadProxyPlayerUiSourceTest.java index b8dc9d060e..ac63e10a6c 100644 --- a/app/src/test/java/com/fongmi/android/tv/server/proxy/MultiThreadProxyPlayerUiSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/server/proxy/MultiThreadProxyPlayerUiSourceTest.java @@ -37,25 +37,6 @@ public class MultiThreadProxyPlayerUiSourceTest { assertTrue(fusion.contains("detailActionView(R.id.multiThreadProxy, View.class).setOnClickListener")); assertTrue(fusion.contains("PlayerButtonSetting.MULTI_THREAD_PROXY, binding.playerMultiThreadProxy")); assertTrue(fusion.contains("player().reloadCurrentMediaItem()")); - // 回归:只登记 addActionButton 不足以让按钮可点,底部控制栏必须真正绑定点击监听器。 - assertTrue(mobile.contains("mBinding.control.action.multiThreadProxy.setOnClickListener(guarded(this::onMultiThreadProxy));")); - assertTrue(leanback.contains("mBinding.control.action.multiThreadProxy.setOnClickListener(guarded(this::onMultiThreadProxy));")); - assertTrue(fusion.contains("playerMultiThreadProxy.setOnClickListener")); - } - - @Test - public void multiThreadButtonIsHiddenByDefaultButStillUserConfigurable() throws Exception { - String catalog = read("main", "java", "com", "fongmi", "android", "tv", "setting", "PlayerButtonSetting.java"); - - assertTrue("多线程按钮必须在默认清单中标记为不显示", - catalog.contains("new Item(MULTI_THREAD_PROXY, R.string.multi_thread_proxy_button, false)")); - assertTrue("首次运行必须把默认不显示项写入隐藏偏好", - catalog.contains("Prefers.put(HIDDEN, join(hidden));")); - assertTrue("默认隐藏只能注入一次,之后以用户选择为准", - catalog.contains("if (Prefers.getBoolean(HIDDEN_SEEDED)) return hidden;") - && catalog.contains("Prefers.put(HIDDEN_SEEDED, true);")); - assertTrue("重置播放器按钮必须同时清除默认隐藏注入标记", catalog.contains("Prefers.remove(HIDDEN_SEEDED);")); - assertTrue("用户仍可通过 putVisible 打开该按钮", catalog.contains("public static void putVisible(String id, boolean visible)")); } @Test diff --git a/app/src/test/java/com/fongmi/android/tv/service/AiTitleExtractionServiceTest.java b/app/src/test/java/com/fongmi/android/tv/service/AiTitleExtractionServiceTest.java index baaefe13dd..4d95017ce7 100644 --- a/app/src/test/java/com/fongmi/android/tv/service/AiTitleExtractionServiceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/service/AiTitleExtractionServiceTest.java @@ -57,23 +57,6 @@ public class AiTitleExtractionServiceTest { assertTrue(prompt.contains("\"searchKeyword\":\"凡人修仙传\"")); } - @Test - public void buildPrompt_includesBoundedRecognitionContextAndEvidence() { - MediaTitleRequest request = MediaTitleRequest.builder() - .rawTitle("qyn 第二季 防和谐版") - .folderName("/media/庆余年") - .contextTitles(List.of("庆余年 第二季 第01集", "庆余年 第二季 第02集")) - .build(); - MediaTitleResolution rule = new MediaTitleParser().parse(request); - - String prompt = AiTitleExtractionService.buildPrompt(request, rule); - - assertTrue(prompt.contains("\"folderName\":\"庆余年\"")); - assertTrue(prompt.contains("\"contextTitles\":[\"庆余年 第二季 第01集\",\"庆余年 第二季 第02集\"]")); - assertTrue(prompt.contains("\"ruleSource\"")); - assertTrue(prompt.contains("\"contextConfidence\"")); - } - @Test public void parseResponse_acceptsStrictJsonAndAddsAliasCandidates() { MediaTitleResolution rule = new MediaTitleParser().parse(MediaTitleRequest.builder() @@ -99,49 +82,4 @@ public class AiTitleExtractionServiceTest { assertNull(result); } - - @Test - public void parseResponse_preservesFallbackMetadataWhenAiOmitsFields() { - MediaTitleResolution rule = new MediaTitleParser().parse(MediaTitleRequest.builder() - .rawTitle("庆余年2 S02E05 4K") - .build()); - - MediaTitleResolution result = AiTitleExtractionService.parseResponse( - "{\"canonicalTitle\":\"庆余年\",\"confidence\":0.91}", rule); - - assertNotNull(result); - assertEquals("tv", result.getMediaType()); - assertEquals(2, result.getSeasonNumber()); - assertEquals(5, result.getEpisodeNumber()); - assertEquals(0.91f, result.getConfidence(), 0.001f); - assertEquals(rule.getRuleSource(), result.getRuleSource()); - } - - @Test - public void parseResponse_rejectsOutOfRangeNumbersAndKeepsSafeFallback() { - MediaTitleResolution rule = new MediaTitleParser().parse(MediaTitleRequest.builder() - .rawTitle("庆余年2 S02E05 4K") - .build()); - - MediaTitleResolution result = AiTitleExtractionService.parseResponse( - "{\"canonicalTitle\":\"庆余年\",\"seasonNumber\":999,\"episodeNumber\":99999,\"year\":3024,\"confidence\":0.91}", rule); - - assertNotNull(result); - assertEquals(2, result.getSeasonNumber()); - assertEquals(5, result.getEpisodeNumber()); - assertEquals(0, result.getYear()); - } - - @Test - public void parseResponse_usesLaterValidNumberWhenEarlierAliasIsInvalid() { - MediaTitleResolution rule = new MediaTitleParser().parse(MediaTitleRequest.builder() - .rawTitle("庆余年2 S02E05 4K") - .build()); - - MediaTitleResolution result = AiTitleExtractionService.parseResponse( - "{\"canonicalTitle\":\"庆余年\",\"seasonNumber\":999,\"season\":2,\"confidence\":0.91}", rule); - - assertNotNull(result); - assertEquals(2, result.getSeasonNumber()); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/service/DriveCheckServiceTest.java b/app/src/test/java/com/fongmi/android/tv/service/DriveCheckServiceTest.java deleted file mode 100644 index eb7307cedb..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/service/DriveCheckServiceTest.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.fongmi.android.tv.service; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; - -public class DriveCheckServiceTest { - - private static final String[] POSITIVE_METADATA = { - "\"list\":[{\"fid\":\"file\"}]", - "\"count\":1", - "\"shareinfo\":{\"snap_id\":\"snapshot\"}", - "\"shareinfo\":{\"share_title\":\"title\"}" - }; - - @Test - public void expiredResponseWithFilesIsBad() { - JsonObject response = json(""" - { - "state": true, - "errno": 0, - "data": { - "share_state": 7, - "shareinfo": {"forbid_reason": "链接已过期"}, - "count": 1, - "list": [{"fid": "file"}] - } - } - """); - - assertEquals(DriveCheckService.STATE_BAD, - DriveCheckService.classify115ShareState(response.getAsJsonObject("data"))); - } - - @Test - public void expiredStateOverridesEveryPositiveMetadataHintWithoutAReason() { - for (String metadata : POSITIVE_METADATA) { - assertEquals(metadata, DriveCheckService.STATE_BAD, - DriveCheckService.classify115ShareState(json("{\"share_state\":7," + metadata + "}"))); - } - } - - @Test - public void expiredStateInShareInfoIsBad() { - assertEquals(DriveCheckService.STATE_BAD, DriveCheckService.classify115ShareState( - json("{\"count\":1,\"shareinfo\":{\"share_state\":7}}"))); - } - - @Test - public void forbidReasonOverridesActiveStateAndFiles() { - for (String reason : new String[]{"链接已过期", "链接已取消", "违规链接已屏蔽"}) { - JsonObject data = json("{\"share_state\":1,\"count\":1,\"shareinfo\":{}}"); - data.getAsJsonObject("shareinfo").addProperty("forbid_reason", reason); - - assertEquals(reason, DriveCheckService.STATE_BAD, DriveCheckService.classify115ShareState(data)); - } - } - - @Test - public void passwordReasonWithFilesIsLocked() { - for (String reason : new String[]{"提取码错误", "请输入密码"}) { - JsonObject data = json("{\"count\":1,\"shareinfo\":{}}"); - data.getAsJsonObject("shareinfo").addProperty("forbid_reason", reason); - - assertEquals(reason, DriveCheckService.STATE_LOCKED, DriveCheckService.classify115ShareState(data)); - } - } - - @Test - public void explicitExpiryTakesPriorityOverPasswordReason() { - assertEquals(DriveCheckService.STATE_BAD, DriveCheckService.classify115ShareState( - json("{\"share_state\":7,\"count\":1,\"shareinfo\":{\"forbid_reason\":\"请输入密码\"}}"))); - } - - @Test - public void activeShareWithoutAFileListIsOk() { - assertEquals(DriveCheckService.STATE_OK, - DriveCheckService.classify115ShareState(json("{\"share_state\":1}"))); - } - - @Test - public void legacyResponsesCanStillUsePositiveMetadata() { - for (String metadata : POSITIVE_METADATA) { - assertEquals(metadata, DriveCheckService.STATE_OK, - DriveCheckService.classify115ShareState(json("{" + metadata + "}"))); - } - } - - @Test - public void blankForbidReasonDoesNotInvalidateAnActiveShare() { - JsonObject data = json("{\"share_state\":1,\"shareinfo\":{}}"); - data.getAsJsonObject("shareinfo").addProperty("forbid_reason", " \n "); - - assertEquals(DriveCheckService.STATE_OK, DriveCheckService.classify115ShareState(data)); - } - - @Test - public void missingPositiveEvidenceIsStillBad() { - assertEquals(DriveCheckService.STATE_BAD, - DriveCheckService.classify115ShareState(json("{}"))); - } - - @Test - public void only115UsesANewCacheNamespace() { - String url = "https://115.com/s/swff16b3ngy?password=CNYY"; - assertNotEquals("115|" + url, DriveCheckService.cacheKey("115", url)); - assertEquals("115:v2|" + url, DriveCheckService.cacheKey("115", url)); - - for (String diskType : new String[]{"aliyun", "quark", "uc", "baidu", "tianyi", "123", "xunlei", "mobile"}) { - assertEquals(diskType + "|" + url, DriveCheckService.cacheKey(diskType, url)); - } - } - - private static JsonObject json(String value) { - return JsonParser.parseString(value).getAsJsonObject(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/service/IntroSkipServiceTest.java b/app/src/test/java/com/fongmi/android/tv/service/IntroSkipServiceTest.java index 196fce5cb4..4539863ccf 100644 --- a/app/src/test/java/com/fongmi/android/tv/service/IntroSkipServiceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/service/IntroSkipServiceTest.java @@ -5,13 +5,10 @@ import com.fongmi.android.tv.service.IntroSkipService.Segment; import org.junit.Test; -import java.util.ArrayList; import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; public class IntroSkipServiceTest { @@ -38,10 +35,7 @@ public class IntroSkipServiceTest { assertEquals(531_000, plan.getOpenings().get(1).getEndMs()); assertEquals(1, plan.getEndings().size()); assertEquals(3_631_500, plan.getEndings().get(0).getStartMs()); - // IntroDB 不给参考时长,无从判断是否延伸到文件结束,保留原始结束点并按有界处理。 - // 「结束点贴着本集结尾、跳过等于本集看完」由播放层的 endsWithFile 判断,不在这里预判。 assertEquals(3_699_500, plan.getEndings().get(0).getEndMs()); - assertFalse(plan.getEndings().get(0).isOpenEnded()); } @Test @@ -66,57 +60,6 @@ public class IntroSkipServiceTest { assertEquals(1, plan.getEndings().size()); assertEquals(5_801_777, plan.getEndings().get(0).getStartMs()); assertEquals(6_371_111, plan.getEndings().get(0).getEndMs()); - // 片尾后还剩 80 分钟内容,是个真实可跳的落点 - assertFalse(plan.getEndings().get(0).isOpenEnded()); - } - - @Test - public void parseTheIntroDb_shiftsCreditsWhenLocalSourceIsShorter() { - // 参考版本 45:00,本地片源 44:00(掐头/删减):片尾应按「距结尾 1:00」折算,而不是被整段丢弃 - String body = "{\"duration_ms\":2700000,\"credits\":[{\"start_ms\":2640000,\"end_ms\":2695000}]}"; - - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb(body, 2_640_000); - - assertEquals(1, plan.getEndings().size()); - assertEquals(2_580_000, plan.getEndings().get(0).getStartMs()); - assertEquals(2_640_000, plan.getEndings().get(0).getEndMs()); - assertTrue(plan.getEndings().get(0).isOpenEnded()); - } - - @Test - public void parseTheIntroDb_keepsPostCreditsSceneSkippable() { - // 片尾后还有 2 分钟彩蛋:折算后仍是可 seek 的落点,不能被当成 openEnded - String body = "{\"duration_ms\":7200000,\"credits\":[{\"start_ms\":6900000,\"end_ms\":7080000}]}"; - - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb(body, 7_140_000); - - assertEquals(1, plan.getEndings().size()); - assertEquals(6_840_000, plan.getEndings().get(0).getStartMs()); - assertEquals(7_020_000, plan.getEndings().get(0).getEndMs()); - assertFalse(plan.getEndings().get(0).isOpenEnded()); - } - - @Test - public void parseTheIntroDb_dropsCreditsWhenLocalSourceIsFarTooShort() { - // 参考 2 小时、片尾距结尾 20 分钟,本地只有 16 分钟(错配到了预告/片段): - // 折算后片尾起点落到 0 之前,无法对上时间轴,必须丢弃而不是乱跳 - String body = "{\"duration_ms\":7200000,\"credits\":[{\"start_ms\":6000000,\"end_ms\":null}]}"; - - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb(body, 1_000_000); - - assertEquals(0, plan.getEndings().size()); - } - - @Test - public void parseTheIntroDb_ignoresTinyDurationDrift() { - // 差值在容差内(1s):不折算,保持原始时间戳 - String body = "{\"duration_ms\":2700000,\"credits\":[{\"start_ms\":2640000,\"end_ms\":2680000}]}"; - - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb(body, 2_699_000); - - assertEquals(1, plan.getEndings().size()); - assertEquals(2_640_000, plan.getEndings().get(0).getStartMs()); - assertEquals(2_680_000, plan.getEndings().get(0).getEndMs()); } @Test @@ -131,251 +74,18 @@ public class IntroSkipServiceTest { } @Test - public void resolve_deduplicatesOverlappingProviderSegments() { - List raw = new ArrayList<>(); - raw.addAll(IntroSkipService.parseIntroDbRaw("{\"intro\":{\"start_ms\":437000,\"end_ms\":531000,\"confidence\":1,\"submission_count\":2}}")); - raw.addAll(IntroSkipService.parseTheIntroDbRaw("{\"intro\":[{\"start_ms\":438000,\"end_ms\":530000}]}")); + public void merge_deduplicatesOverlappingProviderSegments() { + IntroSkipPlan introDb = IntroSkipService.parseIntroDb( + "{\"intro\":{\"start_ms\":437000,\"end_ms\":531000,\"confidence\":1,\"submission_count\":2}}", + 3_700_000); + IntroSkipPlan theIntroDb = IntroSkipService.parseTheIntroDb( + "{\"intro\":[{\"start_ms\":438000,\"end_ms\":530000}]}", + 3_700_000); - IntroSkipPlan merged = IntroSkipPlan.from(raw, 3_700_000); + IntroSkipPlan merged = IntroSkipPlan.merge(List.of(introDb, theIntroDb)); - // 两家报的是同一段片头,保留分数更高的 IntroDB assertEquals(1, merged.getOpenings().size()); assertEquals("IntroDB", merged.getOpenings().get(0).getProvider()); assertEquals(437_000, merged.getOpenings().get(0).getStartMs()); } - - @Test - public void resolve_keepsAdjacentCreditsAndPreviewAsSeparateSegments() { - // 片尾紧接下集预告:起点只差 1 秒,但类型不同,不能被去重合成一段 - String body = "{\"duration_ms\":1500000," - + "\"credits\":[{\"start_ms\":1380000,\"end_ms\":1439000}]," - + "\"preview\":[{\"start_ms\":1440000,\"end_ms\":1500000}]}"; - - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb(body, 1_500_000); - - assertEquals(2, plan.getEndings().size()); - assertEquals(Segment.Kind.OUTRO, plan.getEndings().get(0).getKind()); - assertEquals(Segment.Kind.PREVIEW, plan.getEndings().get(1).getKind()); - } - - @Test - public void parseTheIntroDb_readsPreviewSegment() { - String body = "{\"duration_ms\":1500000,\"preview\":[{\"start_ms\":1450000,\"end_ms\":null}]}"; - - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb(body, 1_500_000); - - assertEquals(1, plan.getEndings().size()); - assertEquals(Segment.Kind.PREVIEW, plan.getEndings().get(0).getKind()); - assertTrue(plan.getEndings().get(0).isOpenEnded()); - } - - @Test - public void cacheKey_ignoresDurationEntirely() { - // 时长不是查询条件,不该进键:预载(时长未知)与真正播放(时长已知)必须命中同一条缓存, - // 否则每集要打两轮网络,预载也永远失效。 - IntroSkipService.Query preload = new IntroSkipService.Query(123, "tt1", "tv", 1, 2, 0); - IntroSkipService.Query playing = new IntroSkipService.Query(123, "tt1", "tv", 1, 2, 2_700_000); - IntroSkipService.Query shorter = new IntroSkipService.Query(123, "tt1", "tv", 1, 2, 2_400_000); - - assertEquals(preload.cacheKey(), playing.cacheKey()); - assertEquals(playing.cacheKey(), shorter.cacheKey()); - } - - @Test - public void cacheKey_separatesDifferentEpisodes() { - IntroSkipService.Query first = new IntroSkipService.Query(123, "tt1", "tv", 1, 1, 0); - IntroSkipService.Query second = new IntroSkipService.Query(123, "tt1", "tv", 1, 2, 0); - - assertNotEquals(first.cacheKey(), second.cacheKey()); - } - - @Test - public void resolve_openEndedCreditsDoNotSwallowFollowingPreview() { - // 片尾无 end_ms(常见)会被拉到文件结尾,拿它算重叠会把紧随其后的预告整个吞掉。 - // 用户明确勾了预告却永远看不到跳过,就是这条造成的。 - String body = "{\"duration_ms\":1500000," - + "\"credits\":[{\"start_ms\":1380000,\"end_ms\":null}]," - + "\"preview\":[{\"start_ms\":1440000,\"end_ms\":1500000}]}"; - - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb(body, 1_500_000); - - assertEquals(2, plan.getEndings().size()); - assertEquals(Segment.Kind.OUTRO, plan.getEndings().get(0).getKind()); - assertEquals(Segment.Kind.PREVIEW, plan.getEndings().get(1).getKind()); - } - - @Test - public void resolve_boundedSegmentWinsOverOpenEndedOne() { - // 同一段片尾,一家给了确切结束点、另一家没给。必须留有界的那个: - // 无界段没有 seek 落点,只能按「本集看完」处理,会把片尾后的彩蛋一起扔掉。 - List raw = new ArrayList<>(); - // IntroDB 分数更高(confidence 1 + 2 次提交)但没给结束点 - raw.addAll(IntroSkipService.parseIntroDbRaw( - "{\"duration_ms\":7200000,\"outro\":{\"start_ms\":6900000,\"confidence\":1,\"submission_count\":2}}")); - raw.addAll(IntroSkipService.parseTheIntroDbRaw( - "{\"duration_ms\":7200000,\"credits\":[{\"start_ms\":6900000,\"end_ms\":7080000}]}")); - - IntroSkipPlan plan = IntroSkipPlan.from(raw, 7_200_000); - - assertEquals(1, plan.getEndings().size()); - assertEquals("TheIntroDB", plan.getEndings().get(0).getProvider()); - assertFalse(plan.getEndings().get(0).isOpenEnded()); - assertEquals(7_080_000, plan.getEndings().get(0).getEndMs()); - } - - @Test - public void resolve_withoutReferenceDurationCreditsStayBounded() { - // IntroDB 从不给参考时长。此时不能拿本集时长去比 end——那是两条时间轴, - // 会把「片尾后还有内容」误判成「一直放到结尾」,跳过变成切集。 - String body = "{\"outro\":{\"start_ms\":6900000,\"end_ms\":7080000,\"confidence\":1,\"submission_count\":2}}"; - - IntroSkipPlan plan = IntroSkipService.parseIntroDb(body, 6_960_000); - - assertEquals(1, plan.getEndings().size()); - assertFalse(plan.getEndings().get(0).isOpenEnded()); - } - - @Test - public void resolve_rejectsReferenceDurationOfWrongMagnitude() { - // runtime_sec=2700(45 分钟)与本集 44 分钟量级相符:按距结尾的距离折算 - IntroSkipPlan shifted = IntroSkipService.parseTheIntroDb( - "{\"runtime_sec\":2700,\"credits\":[{\"start_ms\":2640000,\"end_ms\":2695000}]}", 2_640_000); - assertEquals(2_580_000, shifted.getEndings().get(0).getStartMs()); - - // runtime_sec=45(45 秒)量级完全不符,多半是单位或语义猜错:放弃折算、保留原始时间戳, - // 而不是拿错基准把整段平移出时间轴 - IntroSkipPlan unshifted = IntroSkipService.parseTheIntroDb( - "{\"runtime_sec\":45,\"credits\":[{\"start_ms\":2640000,\"end_ms\":2695000}]}", 2_700_000); - assertEquals(1, unshifted.getEndings().size()); - assertEquals(2_640_000, unshifted.getEndings().get(0).getStartMs()); - } - - @Test - public void resolve_ignoresUnitlessDurationField() { - // 裸 duration 无单位后缀,TMDB 系用它表示分钟;认了它就会算出天量偏移抹掉整段 - String body = "{\"duration\":45,\"credits\":[{\"start_ms\":2640000,\"end_ms\":2695000}]}"; - - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb(body, 2_700_000); - - assertEquals(1, plan.getEndings().size()); - assertEquals(2_640_000, plan.getEndings().get(0).getStartMs()); - } - - @Test - public void resolve_mergesCrossKindOpeningsFromBothProviders() { - // 两家对同一段开头标注不一致(recap 0-90s vs intro 0-45s),不合并会连跳两次 - List raw = new ArrayList<>(); - raw.addAll(IntroSkipService.parseIntroDbRaw("{\"recap\":{\"start_ms\":0,\"end_ms\":90000,\"confidence\":1,\"submission_count\":2}}")); - raw.addAll(IntroSkipService.parseTheIntroDbRaw("{\"intro\":[{\"start_ms\":0,\"end_ms\":45000}]}")); - - IntroSkipPlan plan = IntroSkipPlan.from(raw, 2_700_000); - - assertEquals(1, plan.getOpenings().size()); - assertEquals("IntroDB", plan.getOpenings().get(0).getProvider()); - } - - @Test - public void parseTheIntroDb_keepsZeroStartIntroAsSkippable() { - // 实测响应形状(逐玉 S1E1):第一段片头 start_ms 为 null,落成 0→45s。 - // 这类「从 0 开始」的片头必须照常保留,续播护栏才有东西可判。 - String body = "{\"tmdb_id\":279388,\"type\":\"tv\",\"season\":1,\"episode\":1," - + "\"intro\":[{\"start_ms\":null,\"end_ms\":45000},{\"start_ms\":160000,\"end_ms\":253000}]}"; - - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb(body, 2_841_680); - - assertEquals(2, plan.getOpenings().size()); - assertEquals(0, plan.getOpenings().get(0).getStartMs()); - assertEquals(45_000, plan.getOpenings().get(0).getEndMs()); - assertEquals(160_000, plan.getOpenings().get(1).getStartMs()); - assertEquals(253_000, plan.getOpenings().get(1).getEndMs()); - assertEquals(0, plan.getEndings().size()); - } - - @Test - public void rawSegments_resolveAgainstDifferentDurations() { - // 同一份缓存(原始段)换不同片源时长,各自折算出对得上本地时间轴的结果 - List raw = IntroSkipService.parseTheIntroDbRaw( - "{\"duration_ms\":2700000,\"credits\":[{\"start_ms\":2640000,\"end_ms\":2695000}]}"); - - IntroSkipPlan reference = IntroSkipPlan.from(raw, 2_700_000); - IntroSkipPlan shorter = IntroSkipPlan.from(raw, 2_640_000); - - assertEquals(2_640_000, reference.getEndings().get(0).getStartMs()); - assertEquals(2_580_000, shorter.getEndings().get(0).getStartMs()); - } - - @Test - public void segmentIdentitySeparatesSameProviderSameKindSegments() { - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb( - "{\"intro\":[{\"start_ms\":0,\"end_ms\":45000},{\"start_ms\":160000,\"end_ms\":253000}]}", - 2_841_680); - - assertEquals(2, plan.getOpenings().size()); - assertNotEquals(plan.getOpenings().get(0).getIdentity(), - plan.getOpenings().get(1).getIdentity()); - } - - @Test - public void cachePolicyRequiresEveryProviderAndPrefersKnownDuration() { - assertFalse(IntroSkipService.isCacheableResponse(2, 1, false)); - assertFalse(IntroSkipService.isCacheableResponse(2, 2, true)); - assertTrue(IntroSkipService.isCacheableResponse(2, 2, false)); - - assertFalse(IntroSkipService.canUseCachedResponse(0, 2_700_000)); - assertTrue(IntroSkipService.canUseCachedResponse(2_700_000, 0)); - assertTrue(IntroSkipService.canUseCachedResponse(2_700_000, 2_640_000)); - assertTrue(IntroSkipService.shouldReplaceCachedResponse(0, 2_700_000)); - assertFalse(IntroSkipService.shouldReplaceCachedResponse(2_700_000, 0)); - } - - @Test - public void distanceComparisonRejectsLongOverflow() { - assertFalse(IntroSkipService.Segment.withinDistance(Long.MAX_VALUE, Long.MIN_VALUE, 5)); - assertFalse(IntroSkipService.Segment.withinDistance(Long.MIN_VALUE, Long.MAX_VALUE, 5)); - } - - @Test - public void unknownTrailingEndIsNotOpenEndedWithoutExplicitReference() { - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb( - "{\"credits\":[{\"start_ms\":1380000,\"end_ms\":null}]}", 1_500_000); - - assertEquals(1, plan.getEndings().size()); - assertFalse(plan.getEndings().get(0).isOpenEnded()); - } - - @Test - public void parserRejectsOverflowingTimesAndClampsConfidence() { - IntroSkipPlan plan = IntroSkipService.parseIntroDb( - "{\"intro\":{\"start_sec\":1e300,\"end_sec\":1e300,\"confidence\":99}}", - 2_700_000); - - assertTrue(plan.isEmpty()); - assertEquals(1, IntroSkipService.clampConfidence(99), 0.0); - assertEquals(0, IntroSkipService.clampConfidence(-1), 0.0); - } - - @Test - public void resolvedSegments_keepDistinctStableIdentitiesForMultipleSameProviderSegments() { - List raw = IntroSkipService.parseTheIntroDbRaw( - "{\"duration_ms\":2700000,\"intro\":[" - + "{\"start_ms\":0,\"end_ms\":45000}," - + "{\"start_ms\":160000,\"end_ms\":253000}]}" ); - - IntroSkipPlan reference = IntroSkipPlan.from(raw, 2_700_000); - IntroSkipPlan shorter = IntroSkipPlan.from(raw, 2_699_000); - - assertEquals(2, reference.getOpenings().size()); - assertNotEquals(reference.getOpenings().get(0).getIdentity(), reference.getOpenings().get(1).getIdentity()); - assertEquals(reference.getOpenings().get(0).getIdentity(), shorter.getOpenings().get(0).getIdentity()); - assertEquals(reference.getOpenings().get(1).getIdentity(), shorter.getOpenings().get(1).getIdentity()); - } - - @Test - public void trailingSegmentEndingAfterReferenceDurationIsNotMarkedOpenEnded() { - String body = "{\"duration_ms\":1500000,\"credits\":[{\"start_ms\":1380000,\"end_ms\":1600000}]}"; - - IntroSkipPlan plan = IntroSkipService.parseTheIntroDb(body, 1_500_000); - - assertTrue(plan.getEndings().isEmpty()); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/service/PersonalRecommendationServiceTest.java b/app/src/test/java/com/fongmi/android/tv/service/PersonalRecommendationServiceTest.java index 31b935191a..fa5a1259c0 100644 --- a/app/src/test/java/com/fongmi/android/tv/service/PersonalRecommendationServiceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/service/PersonalRecommendationServiceTest.java @@ -8,8 +8,6 @@ import com.google.gson.JsonParser; import org.junit.Test; -import java.io.File; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -23,7 +21,6 @@ import java.util.concurrent.atomic.AtomicReference; 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.assertSame; import static org.junit.Assert.assertThrows; @@ -31,53 +28,6 @@ import static org.junit.Assert.assertTrue; public class PersonalRecommendationServiceTest { - @Test - public void resultCache_readsAndWritesFilteredPage() throws Exception { - File directory = java.nio.file.Files.createTempDirectory("personal-rec-cache").toFile(); - PersonalRecommendationCache cache = new PersonalRecommendationCache(directory); - TmdbItem item = new TmdbItem(1, "movie", "测试影片", "2024", "", "", ""); - PersonalRecommendationService.RecommendationPage page = new PersonalRecommendationService.RecommendationPage( - List.of(item), 0, PersonalRecommendationService.DEFAULT_PAGE_SIZE, true, "seed-a|feedback:"); - - cache.write("tmdb", "key-a", "seed-a|feedback:", page); - - PersonalRecommendationService.RecommendationPage loaded = cache.read("tmdb", "key-a", "seed-a|feedback:"); - assertNotNull(loaded); - assertEquals(1, loaded.getItems().size()); - assertEquals("测试影片", loaded.getItems().get(0).getTitle()); - assertEquals("seed-a|feedback:", loaded.getHistoryFingerprint()); - assertTrue(loaded.hasMore()); - } - - @Test - public void resultCache_rejectsFingerprintChangeAndCorruptPayload() throws Exception { - File directory = java.nio.file.Files.createTempDirectory("personal-rec-cache").toFile(); - PersonalRecommendationCache cache = new PersonalRecommendationCache(directory); - TmdbItem item = new TmdbItem(1, "movie", "测试影片", "2024", "", "", ""); - PersonalRecommendationService.RecommendationPage page = new PersonalRecommendationService.RecommendationPage( - List.of(item), 0, PersonalRecommendationService.DEFAULT_PAGE_SIZE, false, "seed-a|feedback:"); - - cache.write("douban", "key-a", "seed-a|feedback:", page); - assertNull(cache.read("douban", "key-a", "seed-b|feedback:")); - - java.nio.file.Files.write( - new File(directory, "douban_" + "corrupt" + ".json").toPath(), - "{broken".getBytes(StandardCharsets.UTF_8)); - assertNull(cache.read("douban", "corrupt", "seed-b|feedback:")); - } - - @Test - public void resultCache_ignoresEmptyPage() throws Exception { - File directory = java.nio.file.Files.createTempDirectory("personal-rec-cache").toFile(); - PersonalRecommendationCache cache = new PersonalRecommendationCache(directory); - PersonalRecommendationService.RecommendationPage empty = PersonalRecommendationService.RecommendationPage.empty("seed-a|feedback:"); - - cache.write("tmdb", "empty", "seed-a|feedback:", empty); - - assertEquals(0, directory.listFiles(File::isFile).length); - assertNull(cache.read("tmdb", "empty", "seed-a|feedback:")); - } - @Test public void enrichTmdbRatings_addsDoubanRatingWithoutDroppingTmdbMetadata() { PersonalRecommendationService service = new PersonalRecommendationService(null, null) { diff --git a/app/src/test/java/com/fongmi/android/tv/service/PersonalRecommendationSourceCredentialTest.java b/app/src/test/java/com/fongmi/android/tv/service/PersonalRecommendationSourceCredentialTest.java deleted file mode 100644 index 91c9f10c16..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/service/PersonalRecommendationSourceCredentialTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.fongmi.android.tv.service; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class PersonalRecommendationSourceCredentialTest { - - @Test - public void defaultPersonalRecommendationUsesCurrentEffectiveCredential() throws Exception { - String source = read("src/main/java/com/fongmi/android/tv/service/PersonalRecommendationService.java"); - - assertTrue(source.contains("this(new TmdbService(), TmdbConfig.effectiveCurrent());")); - } - - @Test - public void personAndEpisodeDetailRequestsUseEffectiveCredential() throws Exception { - String person = read("src/main/java/com/fongmi/android/tv/ui/activity/TmdbPersonActivity.java"); - String personDialog = read("src/main/java/com/fongmi/android/tv/ui/dialog/TmdbPersonDialog.java"); - String mobileEpisode = read("src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java"); - String leanbackEpisode = read("src/leanback/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java"); - - assertTrue(person.contains("tmdbConfig = TmdbConfig.effectiveCurrent();")); - assertTrue(personDialog.contains("this.tmdbConfig = TmdbConfig.effectiveCurrent();")); - assertTrue(mobileEpisode.contains("TmdbConfig config = TmdbConfig.effectiveCurrent();")); - assertTrue(leanbackEpisode.contains("TmdbConfig config = TmdbConfig.effectiveCurrent();")); - assertFalse(mobileEpisode.contains("TmdbConfig config = TmdbConfig.objectFrom(Setting.getTmdbConfig());")); - assertFalse(leanbackEpisode.contains("TmdbConfig config = TmdbConfig.objectFrom(Setting.getTmdbConfig());")); - } - - private static String read(String path) throws Exception { - return Files.readString(Path.of(path)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbConfigTestServiceTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbConfigTestServiceTest.java deleted file mode 100644 index 348d1d3677..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/service/TmdbConfigTestServiceTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.fongmi.android.tv.service; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.util.Base64; - -import okhttp3.OkHttpClient; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbConfigTestServiceTest { - - private MockWebServer server; - private OkHttpClient client; - - @Before - public void setUp() throws Exception { - server = new MockWebServer(); - server.start(); - client = new OkHttpClient(); - } - - @After - public void tearDown() throws Exception { - server.shutdown(); - } - - @Test - public void acceptsValidTmdbDataAndPngImage() throws Exception { - server.enqueue(new MockResponse().setHeader("Content-Type", "application/json").setBody("{\"images\":{}}")); - server.enqueue(new MockResponse().setHeader("Content-Type", "image/png") - .setBody(new okio.Buffer().write(Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB")))); - server.enqueue(new MockResponse().setHeader("Content-Type", "application/json") - .setBody("{\"Response\":\"True\",\"imdbID\":\"tt0111161\"}")); - TmdbConfigTestService.Result result = TmdbConfigTestService.test(client, "key", server.url("/").toString(), server.url("/").toString(), "omdb-key", server.url("/").toString()); - assertTrue(result.api.success); - assertTrue(result.image.success); - assertTrue(result.omdb.success); - assertTrue(result.api.latencyMillis >= 0); - assertTrue(result.image.latencyMillis >= 0); - assertTrue(result.omdb.latencyMillis >= 0); - assertEquals("/3/configuration?api_key=key", server.takeRequest().getPath()); - assertEquals("/t/p/w342/wwemzKWzjKYJFfCeiB57q3r4Bcm.png", server.takeRequest().getPath()); - assertEquals("/?i=tt0111161&apikey=omdb-key", server.takeRequest().getPath()); - } - - @Test - public void rejectsUnauthorizedApiAndNonImageResponse() { - server.enqueue(new MockResponse().setResponseCode(401)); - server.enqueue(new MockResponse().setHeader("Content-Type", "text/html").setBody("not an image")); - server.enqueue(new MockResponse().setHeader("Content-Type", "application/json") - .setBody("{\"Response\":\"False\",\"Error\":\"Invalid API key!\"}")); - TmdbConfigTestService.Result result = TmdbConfigTestService.test(client, "bad", server.url("/").toString(), server.url("/").toString(), "bad-omdb-key", server.url("/").toString()); - assertFalse(result.api.success); - assertFalse(result.image.success); - assertFalse(result.omdb.success); - assertEquals("Invalid API key!", result.omdb.message); - } - - @Test - public void rejectsJsonWithoutTmdbConfigurationShape() { - server.enqueue(new MockResponse().setHeader("Content-Type", "application/json").setBody("{\"ok\":true}")); - assertFalse(TmdbConfigTestService.testApi(client, "key", server.url("/").toString()).success); - } - - @Test - public void rejectsFakeImageBytes() { - server.enqueue(new MockResponse().setHeader("Content-Type", "image/png").setBody("fake")); - assertFalse(TmdbConfigTestService.testImage(client, server.url("/").toString()).success); - } - - @Test - public void normalizesSavedApiAndImagePaths() throws Exception { - server.enqueue(new MockResponse().setHeader("Content-Type", "application/json").setBody("{\"images\":{}}")); - server.enqueue(new MockResponse().setHeader("Content-Type", "image/png") - .setBody(new okio.Buffer().write(Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB")))); - - assertTrue(TmdbConfigTestService.testApi(client, "first.second.third", server.url("/3").toString()).success); - assertTrue(TmdbConfigTestService.testImage(client, server.url("/t/p/w185").toString()).success); - - RecordedRequest apiRequest = server.takeRequest(); - assertEquals("/3/configuration", apiRequest.getPath()); - assertEquals("Bearer first.second.third", apiRequest.getHeader("Authorization")); - assertEquals("/t/p/w342/wwemzKWzjKYJFfCeiB57q3r4Bcm.png", server.takeRequest().getPath()); - } - - @Test - public void invalidBearerHeaderBecomesFailedCheck() { - assertFalse(TmdbConfigTestService.testApi(client, "first.second.\nbad", server.url("/").toString()).success); - } - @Test - public void proxyBaseRoutesApiAndImageChecksToMirrorEndpoint() throws Exception { - server.enqueue(new MockResponse().setHeader("Content-Type", "application/json").setBody("{\"images\":{}}")); - server.enqueue(new MockResponse().setHeader("Content-Type", "image/png") - .setBody(new okio.Buffer().write(Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB")))); - - String proxy = server.url("/tmdb-mirror").toString(); - TmdbConfigTestService.Result result = TmdbConfigTestService.test(client, "key", "https://api.tmdb.org", "https://images.tmdb.org", proxy, "", server.url("/").toString()); - - assertTrue(result.api.success); - assertTrue(result.image.success); - assertEquals("/tmdb-mirror/3/configuration?api_key=key", server.takeRequest().getPath()); - assertEquals("/tmdb-mirror/t/p/w342/wwemzKWzjKYJFfCeiB57q3r4Bcm.png", server.takeRequest().getPath()); - } - -} diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbDisambiguationDetailParityTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbDisambiguationDetailParityTest.java deleted file mode 100644 index b500cb1b4e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/service/TmdbDisambiguationDetailParityTest.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.fongmi.android.tv.service; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbPerson; -import com.fongmi.android.tv.ui.helper.TmdbMatchPolicy; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import org.junit.Test; - -import java.lang.reflect.Field; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 证明消歧 lite 详情与完整详情在**打分输入**上完全等价。 - * - *

消歧只用 {@code credits}/{@code aggregate_credits}(人物)与根对象基础字段(标题/国家/季数)。 - * 本测试对同一夹具构造两份 JSON:完整版与去掉 - * images/translations/external_ids/content_ratings/recommendations/similar 的 lite 版, - * 断言 {@link TmdbService#cast}、{@link TmdbService#creators} 与 - * {@link TmdbMatchPolicy#splitSeasonDetailScore} 的输出逐条相同。 - * 只要该等价成立,消歧的排序与阈值判定就不可能因瘦身而改变。 - */ -public class TmdbDisambiguationDetailParityTest { - - private static final String FULL = """ - { - "id": 1399, - "name": "测试剧", - "original_name": "测试剧", - "first_air_date": "2011-04-17", - "origin_country": ["US"], - "number_of_seasons": 2, - "seasons": [{"season_number": 1, "episode_count": 10}, {"season_number": 2, "episode_count": 10}], - "credits": { - "cast": [ - {"id": 1, "name": "演员甲", "character": "角色甲", "profile_path": "/a.jpg", "known_for_department": "Acting"}, - {"id": 3, "name": "演员丙", "character": "角色丙", "profile_path": "/c.jpg", "known_for_department": "Acting"} - ], - "crew": [ - {"id": 2, "name": "导演乙", "job": "Director", "department": "Directing", "profile_path": null} - ] - }, - "aggregate_credits": { - "cast": [ - {"id": 1, "name": "演员甲", "roles": [{"character": "角色甲"}], "profile_path": "/a.jpg", "known_for_department": "Acting"}, - {"id": 3, "name": "演员丙", "roles": [{"character": "角色丙"}], "profile_path": "/c.jpg", "known_for_department": "Acting"} - ], - "crew": [ - {"id": 2, "name": "导演乙", "jobs": [{"job": "Director"}], "department": "Directing"} - ] - }, - "images": {"posters": [{"file_path": "/p.jpg", "width": 2000, "height": 3000}], "backdrops": []}, - "translations": {"translations": [{"iso_639_1": "zh", "data": {"name": "测试剧"}}]}, - "external_ids": {"imdb_id": "tt0000001"}, - "content_ratings": {"results": [{"iso_3166_1": "US", "rating": "TV-MA"}]}, - "recommendations": {"page": 1, "results": [{"id": 9, "name": "推荐剧"}]}, - "similar": {"page": 1, "results": [{"id": 8, "name": "相似剧"}]} - } - """; - - @Test - public void liteDetailKeepsEveryScoringInputIdentical() throws Exception { - TmdbService service = new TmdbService(); - TmdbConfig config = config(); - - JsonObject full = JsonParser.parseString(FULL).getAsJsonObject(); - JsonObject lite = JsonParser.parseString(FULL).getAsJsonObject(); - for (String key : List.of("images", "translations", "external_ids", "content_ratings", "recommendations", "similar")) { - lite.remove(key); - } - - // cast 是 scoreTmdbPeople 的第一路输入;creators 走 TextUtils.join(JVM 单测里是 Android 桩, - // 会抛 NoSuchMethodError),因此它的输入由 disambiguationAppendKeepsOnlyScoringBlocks 从 - // 请求参数层面锁定:credits.crew 与 aggregate_credits.crew 必须同时保留。 - assertEquals(describe(service.cast(full, config)), describe(service.cast(lite, config))); - - for (String text : List.of("测试剧 第一季", "测试剧 分季版", "测试剧", "别名的剧 第2季")) { - assertEquals("split-season score must not depend on stripped blocks: " + text, - TmdbMatchPolicy.splitSeasonDetailScore(text, full), - TmdbMatchPolicy.splitSeasonDetailScore(text, lite)); - } - } - - /** - * lite 请求只能保留打分需要的块:TV 必须同时带 {@code credits}(creators 的 crew 来源)与 - * {@code aggregate_credits}(cast 的优先来源),电影带 {@code credits}; - * 并且绝不能带 images/translations/recommendations/similar。 - */ - @Test - public void disambiguationAppendKeepsOnlyScoringBlocks() throws Exception { - String source = Files.readString( - Paths.get("src/main/java/com/fongmi/android/tv/service/TmdbService.java"), StandardCharsets.UTF_8); - int start = source.indexOf("private String disambiguationAppend("); - assertTrue("disambiguationAppend must exist", start >= 0); - int end = source.indexOf('}', source.indexOf("return", start)); - String body = source.substring(start, end + 1); - - assertTrue(body.contains("\"credits,aggregate_credits\"")); - assertTrue(body.contains("\"credits\"")); - for (String stripped : List.of("images", "translations", "external_ids", "content_ratings", "recommendations", "similar")) { - assertFalse("lite detail must not request " + stripped, body.contains(stripped)); - } - assertTrue("the lite append must be wired into the request", - source.contains("disambiguation ? disambiguationAppend(item) : detailAppend(item, includeRelated)")); - } - - private static List describe(List people) { - List out = new ArrayList<>(people.size()); - for (TmdbPerson person : people) { - out.add(person.getPersonId() + "|" + person.getName() + "|" + person.getSubtitle() + "|" + person.getKnownForDepartment()); - } - return out; - } - - private static TmdbConfig config() throws Exception { - TmdbConfig config = new TmdbConfig(); - for (String[] pair : new String[][]{ - {"apiBase", "https://api.tmdb.org/3"}, - {"apiKey", "test-key"}, - {"language", "zh-CN"}}) { - Field field = TmdbConfig.class.getDeclaredField(pair[0]); - field.setAccessible(true); - field.set(config, pair[1]); - } - return config.sanitize(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceAutoRouteTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceAutoRouteTest.java deleted file mode 100644 index ee164efe3e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceAutoRouteTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.fongmi.android.tv.service; - -import com.fongmi.android.tv.utils.TmdbProxy; - -import org.junit.After; -import org.junit.Test; - -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; - -public class TmdbServiceAutoRouteTest { - - @After - public void clearHealth() { - TmdbProxy.RouteSelector.clearForTest(); - } - - @Test - public void lowerLatencyRouteComesFirst() { - TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.API, TmdbProxy.OFFICIAL_API, 300); - TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.API, TmdbProxy.ITV666, 80); - - assertEquals(List.of(TmdbProxy.ITV666, TmdbProxy.OFFICIAL_API), - TmdbProxy.RouteSelector.order(TmdbProxy.RouteSelector.Kind.API, TmdbProxy.autoApiCandidates())); - } - - @Test - public void failedRouteIsTemporarilyMovedBehindHealthyRoute() { - TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, TmdbProxy.OFFICIAL_API); - - List routes = TmdbProxy.RouteSelector.order(TmdbProxy.RouteSelector.Kind.API, TmdbProxy.autoApiCandidates()); - assertEquals(TmdbProxy.ITV666, routes.get(0)); - assertFalse(routes.isEmpty()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceCacheKeyTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceCacheKeyTest.java index dbec15a41a..84b4b8af79 100644 --- a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceCacheKeyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceCacheKeyTest.java @@ -7,7 +7,6 @@ import com.google.gson.JsonParser; import org.junit.Test; import java.lang.reflect.Field; -import java.util.Set; import mockwebserver3.MockResponse; import mockwebserver3.MockWebServer; @@ -46,36 +45,6 @@ public class TmdbServiceCacheKeyTest { assertEquals(1, service.detailCacheKeys(item, config, true).size()); } - /** - * 消歧 lite 详情必须与完整/core 详情使用不同缓存条目,否则 lite 结果会把完整详情缓存 - * 覆盖成缺 images/translations/recommendations 的残缺对象;语言差异仍要分开。 - */ - @Test - public void disambiguationCacheKeyIsIsolatedFromDetailCaches() { - TmdbService service = new TmdbService(); - TmdbItem tv = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); - TmdbItem movie = new TmdbItem(123, "movie", "庆余年", "", "", "", ""); - TmdbConfig config = config("https://api.tmdb.org/3", "test-key", "zh-CN"); - - String lite = service.disambiguationCacheKey(tv, config); - assertNotEquals(service.detailCacheKey(tv, config, true), lite); - assertNotEquals(service.detailCacheKey(tv, config, false), lite); - assertNotEquals(lite, service.disambiguationCacheKey(movie, config)); - assertEquals(lite, service.disambiguationCacheKey(tv, config("https://mirror.example.com/3", "other-key", "ZH-Hans"))); - assertNotEquals(lite, service.disambiguationCacheKey(tv, config("https://api.tmdb.org/3", "test-key", "zh-TW"))); - } - - @Test - public void cacheKeysNormalizeLanguageCaseAndAliases() { - TmdbService service = new TmdbService(); - TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); - - assertEquals(service.detailCacheKey(item, config("https://api.tmdb.org/3", "key", "ZH-Hans"), true), - service.detailCacheKey(item, config("https://api.tmdb.org/3", "key", "zh-CN"), true)); - assertNotEquals(service.detailCacheKey(item, config("https://api.tmdb.org/3", "key", "zh-CN"), true), - service.detailCacheKey(item, config("https://api.tmdb.org/3", "key", "zh-TW"), true)); - } - @Test public void seasonAndEpisodeCacheKeysIgnoreAuthAndApiBase() { TmdbService service = new TmdbService(); @@ -88,25 +57,6 @@ public class TmdbServiceCacheKeyTest { assertEquals(service.episodeCacheKey(item, 2, 3, first), service.episodeCacheKey(item, 2, 3, second)); } - @Test - public void sourceDetailCacheKeyKeepsSeasonLanguageAndCapabilityMask() { - TmdbService service = new TmdbService(); - TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); - TmdbConfig chinese = config("https://api.tmdb.org/3", "first-key", "zh-CN"); - TmdbConfig english = config("https://api.tmdb.org/3", "first-key", "en-US"); - - String first = service.sourceDetailCacheKey(item, 2, chinese, Set.of("images", "credits")); - String reordered = service.sourceDetailCacheKey(item, 2, chinese, Set.of("credits", "images")); - String otherSeason = service.sourceDetailCacheKey(item, 3, chinese, Set.of("images", "credits")); - String otherLanguage = service.sourceDetailCacheKey(item, 2, english, Set.of("images", "credits")); - String otherMask = service.sourceDetailCacheKey(item, 2, chinese, Set.of("images")); - - assertEquals(first, reordered); - assertNotEquals(first, otherSeason); - assertNotEquals(first, otherLanguage); - assertNotEquals(first, otherMask); - } - @Test public void searchCacheKeyIgnoresAuthAndApiBaseButKeepsKeywordAndLanguage() { TmdbService service = new TmdbService(); diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceCredentialScopeTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceCredentialScopeTest.java deleted file mode 100644 index a6483516fb..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceCredentialScopeTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.fongmi.android.tv.service; - -import com.fongmi.android.tv.api.config.SubscriptionTmdbCredentialStore; -import com.fongmi.android.tv.bean.TmdbConfig; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.lang.reflect.Field; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -public class TmdbServiceCredentialScopeTest { - - private static final String SOURCE_KEY = "0123456789abcdef0123456789abcdef"; - - @Before - @After - public void clearStore() { - SubscriptionTmdbCredentialStore.clear(); - TmdbService.clearAuthFailuresForTest(); - } - - @Test - public void transientCredentialRejectsNonOfficialTransportBeforeRequest() throws Exception { - TmdbConfig config = transientConfig(); - set(config, "apiBase", "https://mirror.example.com/3"); - TmdbService service = new TmdbService(); - - assertThrows(IllegalStateException.class, () -> service.searchRaw("query", config)); - } - - @Test - public void staleTransientCredentialIsRejectedAfterSubscriptionSwitch() { - SubscriptionTmdbCredentialStore.Scope first = SubscriptionTmdbCredentialStore.beginSubscription(9, "https://source.first/config", "first"); - assertTrue(SubscriptionTmdbCredentialStore.accept(SOURCE_KEY, 9, "https://source.first/config", first.getEpoch(), "site", "vod")); - TmdbConfig staleConfig = TmdbConfig.effective(TmdbConfig.objectFrom("{}"), SubscriptionTmdbCredentialStore.snapshot(first)); - - SubscriptionTmdbCredentialStore.beginSubscription(10, "https://source.second/config", "second"); - TmdbService service = new TmdbService(); - - assertThrows(IllegalStateException.class, () -> service.searchRaw("query", staleConfig)); - } - - @Test - public void transientCredentialIsClearedAfterUnauthorizedResponse() { - SubscriptionTmdbCredentialStore.Scope scope = SubscriptionTmdbCredentialStore.beginSubscription(4, "https://source.example/config", "test"); - assertTrue(SubscriptionTmdbCredentialStore.accept(SOURCE_KEY, 4, "https://source.example/config", scope.getEpoch(), "site", "vod")); - TmdbConfig config = TmdbConfig.effective(TmdbConfig.objectFrom("{}"), SubscriptionTmdbCredentialStore.snapshot(scope)); - TmdbService service = new TmdbService(); - - RuntimeException failure = service.httpFailure(config, 403, "forbidden"); - - assertTrue(failure instanceof TmdbService.AuthException); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(scope).isEmpty()); - assertFalse(SubscriptionTmdbCredentialStore.isCurrent(scope)); - } - - @Test - public void userCredentialIsNotClearedByUnauthorizedResponse() { - TmdbConfig user = TmdbConfig.objectFrom("{\"apiKey\":\"user-key-0123456789\"}"); - TmdbService service = new TmdbService(); - - service.httpFailure(user, 401, "unauthorized"); - - assertTrue(user.isReady()); - assertFalse(user.isTransientSubscriptionCredential()); - } - - @Test - public void staleTransientCredentialFailureDoesNotClearNewSubscription() { - SubscriptionTmdbCredentialStore.Scope first = SubscriptionTmdbCredentialStore.beginSubscription(6, "https://source.first/config", "first"); - assertTrue(SubscriptionTmdbCredentialStore.accept(SOURCE_KEY, 6, "https://source.first/config", first.getEpoch(), "site", "vod")); - TmdbConfig firstConfig = TmdbConfig.effective(TmdbConfig.objectFrom("{}"), SubscriptionTmdbCredentialStore.snapshot(first)); - - SubscriptionTmdbCredentialStore.Scope second = SubscriptionTmdbCredentialStore.beginSubscription(7, "https://source.second/config", "second"); - assertTrue(SubscriptionTmdbCredentialStore.accept("fedcba9876543210fedcba9876543210", 7, "https://source.second/config", second.getEpoch(), "site", "vod")); - TmdbService service = new TmdbService(); - - service.httpFailure(firstConfig, 401, "unauthorized"); - assertEquals("fedcba9876543210fedcba9876543210", SubscriptionTmdbCredentialStore.snapshot(second).getApiKey()); - - TmdbConfig secondConfig = TmdbConfig.effective(TmdbConfig.objectFrom("{}"), SubscriptionTmdbCredentialStore.snapshot(second)); - service.httpFailure(secondConfig, 403, "forbidden"); - assertTrue(SubscriptionTmdbCredentialStore.snapshot(second).isEmpty()); - } - - private static TmdbConfig transientConfig() { - SubscriptionTmdbCredentialStore.Scope scope = SubscriptionTmdbCredentialStore.beginSubscription(5, "https://source.example/config", "test"); - assertTrue(SubscriptionTmdbCredentialStore.accept(SOURCE_KEY, 5, "https://source.example/config", scope.getEpoch(), "site", "vod")); - return TmdbConfig.effective(TmdbConfig.objectFrom("{}"), SubscriptionTmdbCredentialStore.snapshot(scope)); - } - - private static void set(TmdbConfig config, String name, String value) throws Exception { - Field field = TmdbConfig.class.getDeclaredField(name); - field.setAccessible(true); - field.set(config, value); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceErrorRedactionTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceErrorRedactionTest.java deleted file mode 100644 index 6cd7b6fa3e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceErrorRedactionTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.fongmi.android.tv.service; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbServiceErrorRedactionTest { - - private static final String KEY = "0123456789abcdef0123456789abcdef"; - - @Test - public void redactsApiKeyAndBearerFromNetworkErrorText() { - String message = "failed url=https://api.themoviedb.org/3/movie/550?api_key=" + KEY + "&language=zh-CN Authorization: Bearer " + KEY; - - String redacted = TmdbService.redactMessage(message); - - assertFalse(redacted.contains(KEY)); - assertTrue(redacted.contains("api_key=")); - assertTrue(redacted.contains("Bearer ")); - } - - @Test - public void redactsAlternateCredentialQueryNamesCaseInsensitively() { - String message = "https://api.tmdb.org/3/x?access_token=secret-token&foo=1&apikey=second-secret"; - - String redacted = TmdbService.redactMessage(message); - - assertFalse(redacted.contains("secret-token")); - assertFalse(redacted.contains("second-secret")); - assertTrue(redacted.contains("access_token=")); - assertTrue(redacted.contains("apikey=")); - } - - @Test - public void nullOrBlankErrorTextGetsStableNonSensitiveMessage() { - assertEquals("TMDB request failed", TmdbService.redactMessage(null)); - assertEquals("TMDB request failed", TmdbService.redactMessage(" ")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceTranslationTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceTranslationTest.java deleted file mode 100644 index cca630cda4..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceTranslationTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.fongmi.android.tv.service; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.fongmi.android.tv.bean.TmdbItem; -import com.google.gson.JsonParser; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -public class TmdbServiceTranslationTest { - - @Test - public void preferredTitleSelectsChineseTranslationOverEnglishPrimary() { - TmdbService service = new TmdbService(); - TmdbItem item = new TmdbItem(1, "tv", "English Title", "", "", "", ""); - String detail = """ - {"name":"English Title", - "translations":{"translations":[ - {"iso_639_1":"en","data":{"name":"English Title"}}, - {"iso_639_1":"zh","iso_3166_1":"CN","data":{"name":"中文标题"}} - ]}} - """; - - assertEquals("中文标题", service.preferredTitle(item, JsonParser.parseString(detail).getAsJsonObject(), config("zh-CN"))); - } - - @Test - public void translatedOverviewSelectsChineseTranslationOverEnglishPrimary() { - TmdbService service = new TmdbService(); - String detail = """ - {"overview":"English overview", - "translations":{"translations":[ - {"iso_639_1":"en","data":{"overview":"English overview"}}, - {"iso_639_1":"zh","iso_3166_1":"CN","data":{"overview":"中文简介"}} - ]}} - """; - - assertEquals("中文简介", service.translatedOverview(JsonParser.parseString(detail).getAsJsonObject(), config("zh-CN"))); - } - - @Test - public void episodesUseChineseEpisodeTranslation() { - TmdbService service = new TmdbService(); - String season = """ - {"episodes":[{ - "episode_number":1, - "name":"Episode 1", - "overview":"English episode", - "translations":{"translations":[ - {"iso_639_1":"en","data":{"name":"Episode 1","overview":"English episode"}}, - {"iso_639_1":"zh","iso_3166_1":"CN","data":{"name":"第一集","overview":"中文单集"}} - ]} - }]} - """; - - assertEquals(1, service.episodes(JsonParser.parseString(season).getAsJsonObject(), config("zh-CN"), 1, 1).size()); - assertEquals("第一集", service.episodes(JsonParser.parseString(season).getAsJsonObject(), config("zh-CN"), 1, 1).get(0).getTitle()); - assertEquals("中文单集", service.episodes(JsonParser.parseString(season).getAsJsonObject(), config("zh-CN"), 1, 1).get(0).getOverview()); - } - - private static TmdbConfig config(String language) { - try { - TmdbConfig config = new TmdbConfig(); - var field = TmdbConfig.class.getDeclaredField("language"); - field.setAccessible(true); - field.set(config, language); - return config; - } catch (Exception e) { - throw new AssertionError(e); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/AppBrandingContractTest.java b/app/src/test/java/com/fongmi/android/tv/setting/AppBrandingContractTest.java index dc9f85b242..250b788f4e 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/AppBrandingContractTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/AppBrandingContractTest.java @@ -52,8 +52,7 @@ public class AppBrandingContractTest { assertTrue(appBranding.contains("public static void applyLogo(@NonNull ImageView view)")); assertTrue(appBranding.contains("view.setImageDrawable(logoDrawable(view.getContext()));")); assertTrue(appBranding.contains("getActivityIcon(launcherIntent(context).getComponent())")); - // 首帧落品牌图标后要让配置自带的线路 logo 覆盖上来,否则自定义线路图标失效 - assertTrue(appBranding.contains("ImgUtil.logo(view, logo);")); + assertFalse(appBranding.contains("Glide")); assertTrue(appBranding.contains("Prefers.getPrefers().edit().putInt(ICON_KEY, normalizeIconMode(mode)).commit();")); assertFalse(appBranding.contains("ICON_CUSTOM")); assertFalse(appBranding.contains("Shortcut")); @@ -68,15 +67,6 @@ public class AppBrandingContractTest { assertFalse(mobile.contains("ImgUtil.logo(mBinding.logo);")); } - @Test - public void homeTitlePrefersSiteAndConfigNameOverAppName() throws Exception { - String appBranding = read("app/src/main/java/com/fongmi/android/tv/setting/AppBranding.java"); - - // 固定返回应用名会让所有站源都显示成「默影视」 - assertTrue(appBranding.contains("if (homeName != null && !homeName.trim().isEmpty()) return homeName;")); - assertTrue(appBranding.contains("if (configName != null && !configName.trim().isEmpty()) return configName;")); - } - @Test public void homeLogoIsBrandedBeforeFirstFrame() throws Exception { String layout = read("app/src/leanback/res/layout/activity_home.xml"); @@ -181,70 +171,6 @@ public class AppBrandingContractTest { if (leanback) assertTrue(manifest.contains("android.intent.category.LEANBACK_LAUNCHER")); } - @Test - public void tvBannerIsProvidedPerDensityAtFullResolution() throws Exception { - String manifest = read("app/src/leanback/AndroidManifest.xml"); - // 两个启动器入口(application 与 current alias)都必须带横幅, - // 只给 application 会让 TV 桌面的 alias 磁贴缺图。 - assertEquals(2, countOccurrences(manifest, "android:banner=\"@mipmap/ic_banner\"")); - - // 每档按 320x180 dp 出图,即官方 xhdpi 最小尺寸 320x180 px 的 2 倍。 - // 磁贴按 dp 画,只给最小尺寸时 1080p/4K 电视只能放大位图,字标出锯齿。 - int[][] expected = {{320, 180}, {480, 270}, {640, 360}, {960, 540}, {1280, 720}}; - String[] density = {"mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"}; - for (int i = 0; i < density.length; i++) { - Path file = banner(density[i]); - assertTrue("missing banner for " + density[i], Files.exists(file)); - int[] size = pngSize(Files.readAllBytes(file)); - assertEquals("width of " + density[i], expected[i][0], size[0]); - assertEquals("height of " + density[i], expected[i][1], size[1]); - // 16:9 是 Android TV 横幅的硬性规格,比例不对会被启动器拉伸。 - assertEquals("aspect of " + density[i], 16.0 / 9.0, - (double) size[0] / size[1], 0.001); - } - } - - @Test - public void tvBannerUsesBitmapDensitiesNotAdaptiveIcon() { - Path leanback = Path.of("app/src/leanback/res"); - if (!Files.exists(leanback)) leanback = Path.of("src/leanback/res"); - - // 关键防回归:mipmap-anydpi-v26/ic_banner.xml 是一个【包含 v26 的资源 - // 配置】,它会捕获所有 API>=26 的设备而不看屏幕密度,把 manifest 对 - // @mipmap/ic_banner 的引用从密度位图上抢走。它当时的内容是 108dp - // 方形自适应图标,启动器只能自行裁切进 16:9 磁贴,得到的就是用户 - // 报的“横幅糊成一团”与锯齿。横幅只能是位图。 - assertFalse(Files.exists(leanback.resolve("mipmap-anydpi-v26/ic_banner.xml"))); - assertFalse(Files.exists(leanback.resolve("drawable/ic_banner_foreground.xml"))); - // 同理,横幅不能落在 default(drawable) 桶里:xhdpi 电视会把 320x180 - // 拉大 2 倍显示,正是这次的模糊来源。 - assertFalse(Files.exists(leanback.resolve("drawable/ic_banner.png"))); - } - - private static Path banner(String density) { - Path path = Path.of("app/src/leanback/res") - .resolve("mipmap-" + density).resolve("ic_banner.png"); - if (!Files.exists(path)) { - path = Path.of("src/leanback/res") - .resolve("mipmap-" + density).resolve("ic_banner.png"); - } - return path; - } - - /** 只解析 PNG 头部的 IHDR,避免为一次断言引入图片解码依赖。 */ - private static int[] pngSize(byte[] bytes) { - byte[] signature = {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; - for (int i = 0; i < signature.length; i++) { - assertEquals("not a PNG", signature[i], bytes[i]); - } - return new int[]{readInt(bytes, 16), readInt(bytes, 20)}; - } - - private static int readInt(byte[] bytes, int offset) { - return ((bytes[offset] & 0xFF) << 24) | ((bytes[offset + 1] & 0xFF) << 16) - | ((bytes[offset + 2] & 0xFF) << 8) | (bytes[offset + 3] & 0xFF); - } - private static String read(String relative) throws Exception { Path path = Path.of(relative); if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); diff --git a/app/src/test/java/com/fongmi/android/tv/setting/CustomCspSettingTest.java b/app/src/test/java/com/fongmi/android/tv/setting/CustomCspSettingTest.java deleted file mode 100644 index 2e3f9967af..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/CustomCspSettingTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.fongmi.android.tv.setting; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -public class CustomCspSettingTest { - - @Test - public void blankSearchMatchesEveryItem() { - assertTrue(CustomCspSetting.matchesSearch(null, "星河影视", "source-key", "https://example.com/api")); - assertTrue(CustomCspSetting.matchesSearch(" ", "星河影视", "source-key", "https://example.com/api")); - } - - @Test - public void searchMatchesNameKeyAndUrlsIgnoringCase() { - String[] fields = {"影视仓", "movie-source", "https://Example.com/api", "https://example.com/home"}; - - assertTrue(CustomCspSetting.matchesSearch("影视", fields)); - assertTrue(CustomCspSetting.matchesSearch("MOVIE-SOURCE", fields)); - assertTrue(CustomCspSetting.matchesSearch("example.com/api", fields)); - assertTrue(CustomCspSetting.matchesSearch("EXAMPLE.COM/HOME", fields)); - assertFalse(CustomCspSetting.matchesSearch("not-found", fields)); - } - - @Test - public void searchMatchesLiveUrlAndOtherField() { - assertTrue(CustomCspSetting.matchesSearch("live.m3u8", "新闻直播", "https://tv.example.com/live.m3u8")); - assertTrue(CustomCspSetting.matchesSearch("HEADERS", "headers", "token")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/DanmakuBundleEndpointSourceTest.java b/app/src/test/java/com/fongmi/android/tv/setting/DanmakuBundleEndpointSourceTest.java deleted file mode 100644 index 80e212252e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/DanmakuBundleEndpointSourceTest.java +++ /dev/null @@ -1,175 +0,0 @@ -package com.fongmi.android.tv.setting; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class DanmakuBundleEndpointSourceTest { - - @Test - public void searchApiFallsBackToBundleDanmakuService() throws Exception { - // 本地包自带弹幕服务,但 index.config.js 没有 danmaku 字段,订阅也没有。回退必须排在 - // 用户手填和订阅配置之后:用户显式配置永远优先,本地包只是「没配时也能用」。 - String source = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "setting", "DanmakuSetting.java")); - String configured = methodBody(source, "public static String getConfiguredApiUrl()", "public static String getEffectiveApiUrl()"); - String effective = methodBody(source, "public static String getEffectiveApiUrl()", "public static String getValidApiUrl()"); - - int userUrl = configured.indexOf("String userUrl = getApiUrl();"); - int configUrl = configured.indexOf("VodConfig.get().getConfig().getDanmaku()"); - assertTrue(userUrl >= 0); - assertTrue(configUrl > userUrl); - assertContainsAll(effective, "String configured = getConfiguredApiUrl();", "bundleApiUrl()"); - } - - @Test - public void bundleFallbackStopsWhenTheActiveSourceIsNoLongerTheCatBundle() throws Exception { - // 切到普通订阅时 VodConfig 不停 node,:node 进程与端口都还在;只判存活会把本地包地址 - // 继续用在普通线路上——切源后仍「有接口」且能搜到弹幕,要重启 APP 才恢复。 - String source = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "setting", "DanmakuSetting.java")); - String bundleSource = methodBody(source, "public static boolean isBundleSource()", "private static String bundleApiUrl()"); - String fallback = methodBody(source, "private static String bundleApiUrl()", "public static String getValidApiUrl()"); - assertContainsAll(bundleSource, "CatSource.isBundle(VodConfig.get().getConfig().getUrl())"); - assertContainsAll(fallback, "isBundleSource() ? NodeRuntime.danmakuApiUrl() : \"\";"); - } - - @Test - public void apiDialogPrefillsOnlyConfiguredValueSoTheBundleFallbackIsNeverPersisted() throws Exception { - // 输入框回填若用 getEffectiveApiUrl(),用户不改直接确定,putApiUrl 会把运行时的本机 - // 回环地址固化进 prefs——换包后就是一个死地址,且再也不回退。 - for (String flavor : new String[]{"leanback", "mobile"}) { - String source = read(sourcePath(flavor, "java", "com", "fongmi", "android", "tv", "ui", "dialog", "DanmakuApiDialog.java")); - String initView = methodBody(source, "protected void initView()", "protected void initEvent()"); - // 只看真正回填的那一行;方法体里的注释会提到被排除的方法名。 - String prefill = initView.lines().filter(line -> line.contains("binding.text.setText(")).findFirst().orElse(""); - assertContainsAll(prefill, "DanmakuSetting.getConfiguredApiUrl()"); - assertFalse(prefill.contains("getEffectiveApiUrl()")); - } - } - - @Test - public void runtimeProbesTheBundleDanmakuPortOutsideTheCatSourcePort() throws Exception { - // 猫源本体端口与弹幕服务端口一起写在 files/node/bundle/port 里(实测 9988,9321)。 - // 只能靠 GET /danmaku 区分:本体回 404,弹幕服务回 200 且 body 是 JSON。 - String source = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "node", "NodeRuntime.java")); - String probe = methodBody(source, "private static void resolveDanmakuPort(int catPort, long generation)", "private static boolean isDanmakuService(okhttp3.OkHttpClient client, int candidate)"); - int serviceAt = source.indexOf("private static boolean isDanmakuService(okhttp3.OkHttpClient client, int candidate)"); - assertTrue(serviceAt > 0); - String isService = source.substring(serviceAt); - - assertTrue(probe.contains("readPorts(new File(NodeBundle.dir(App.get()), \"port\"))")); - assertTrue(probe.contains("candidate == catPort")); - assertTrue(probe.contains("isDanmakuService(client, candidate)")); - assertTrue(probe.contains("Task.execute(")); - // 换源会开出新世代,旧探测线程必须靠这条判据自己收手,否则会把上一代 bundle 的端口写回去。 - assertTrue(probe.contains("generation == START_GENERATION.get()")); - // 去重必须按世代:上一代线程还在跑时,按「有无线程」去重会把新世代唯一的触发点挡回去, - // 新 bundle 的弹幕端口就永久探不到。 - assertTrue(source.contains("private static final AtomicLong DANMAKU_PROBING_GENERATION = new AtomicLong(-1);")); - String claim = methodBody(source, "private static boolean claimDanmakuProbe(long generation)", "private static void releaseDanmakuProbe("); - String release = methodBody(source, "private static void releaseDanmakuProbe(long generation)", "private static void resolveDanmakuPort("); - assertContainsAll(probe, "if (!claimDanmakuProbe(generation)) return;", "releaseDanmakuProbe(generation);"); - // 探到的端口要另记一份给「曾经开过的端口」用,换源清零后才有据可查。 - assertContainsAll(probe, "danmakuPort = candidate;", "lastDanmakuPort = candidate;"); - assertContainsAll(claim, - "long current = DANMAKU_PROBING_GENERATION.get();", - "if (current == generation) return false;", - "DANMAKU_PROBING_GENERATION.compareAndSet(current, generation)", - "return true;"); - assertContainsAll(release, "DANMAKU_PROBING_GENERATION.compareAndSet(generation, -1);"); - assertFalse(probe.contains("DANMAKU_PROBING.")); - assertContainsAll(isService, - "\"/danmaku\"", - "response.code() != 200", - "body.startsWith(\"[\")", - "body.startsWith(\"{\")"); - } - - @Test - public void runtimeExposesTheProbedEndpointAndClearsItWithTheCatSourcePort() throws Exception { - String source = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "node", "NodeRuntime.java")); - String accessor = methodBody(source, "public static String danmakuApiUrl()", "public static boolean ownsLoopbackPort(String url)"); - String clear = methodBody(source, "private static void clearPort()", "public static synchronized void start("); - String owns = methodBody(source, "public static boolean ownsLoopbackPort(String url)", "private static void clearPort()"); - String start = methodBody(source, "public static synchronized void start(", "private static synchronized void timeout("); - String ready = methodBody(source, "case NodeService.MSG_READY:", "case NodeService.MSG_PROGRESS:"); - String error = methodBody(source, "case NodeService.MSG_ERROR:", "default:"); - - assertContainsAll(accessor, "loopbackUrl(value, \"/danmaku\")"); - // 子进程被系统杀掉时 running/端口都不会被清,只认端口号会把死地址交给弹幕搜索。 - assertContainsAll(accessor, "value <= 0 || !running || !serviceAlive()", "return \"\";"); - assertContainsAll(clear, "port = 0;", "danmakuPort = 0;"); - // 换源后正是靠 lastDanmakuPort 认出上一场本地包残留的弹幕地址,clearPort 必须留着它。 - assertContainsAll(source, "private static volatile int lastDanmakuPort;"); - assertContainsAll(owns, "value == port || value == danmakuPort || value == lastDanmakuPort"); - assertFalse(clear.contains("lastDanmakuPort = 0")); - // 换源/启动失败/就绪超时都必须把旧的弹幕端口一起清掉,否则新源会连到旧进程的地址。 - assertContainsAll(start, "clearPort();"); - assertContainsAll(error, "clearPort();"); - assertContainsAll(ready, "danmakuPort = 0;", "resolveDanmakuPort(port, generation);"); - // bundle 的弹幕服务可能晚于首次探测预算才落盘端口,复用捷径不补探就整场没有弹幕。 - assertContainsAll(start, "resolveDanmakuPort(port, START_GENERATION.get())"); - } - - @Test - public void playerDropsAStaleBundleDanmakuSourceAfterTheSourceSwitch() throws Exception { - String source = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "PlayerManager.java")); - String guard = methodBody(source, "private static boolean isStaleBundleDanmaku(", "private void clearDanmaku("); - String setter = methodBody(source, "String url = DanmakuUrlPolicy.normalize(", "DanmakuUrlPolicy.SourceType sourceType"); - String restore = methodBody(source, "private void restoreDanmakuDataSource()", "public void setDanmakuConfig("); - - // 只有「属于本地包、且本地包已经不是当前源」的回环地址才算残留:先问订阅是否仍是本地包 - // (探测窗口期内也必须为 true),再只认运行时真的开过的端口。 - assertContainsAll(guard, - "TextUtils.equals(url, DanmakuSetting.getConfiguredApiUrl())", - "if (!DanmakuUrlPolicy.isLoopback(url)) return false;", - "if (DanmakuSetting.isBundleSource()) return false;", - "return NodeRuntime.ownsLoopbackPort(url);"); - // 形态判据(回环 + path 恰好 /danmaku)不是归属证明:用户自建的回环弹幕服务地址与配置值 - // 不逐字相等时(localhost vs 127.0.0.1,或配置了内网地址)会被误当残留清掉,必须删除。 - assertFalse(source.contains("isBundleDanmakuEndpoint")); - assertContainsAll(setter, "isStaleBundleDanmaku(url)", "clearDanmaku(\"stale_bundle_source\")"); - assertContainsAll(restore, "isStaleBundleDanmaku(currentDanmakuUrl)", "clearDanmaku(\"stale_bundle_source\")"); - } - - @Test - public void sourceSwitchClearsTheDanmakuLeftByTheBundle() throws Exception { - String player = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "PlayerManager.java")); - String drop = methodBody(player, "public void dropStaleBundleDanmaku()", "private static boolean isStaleBundleDanmaku("); - assertContainsAll(drop, "isStaleBundleDanmaku(currentDanmakuUrl)", "clearDanmaku(\"stale_bundle_source\")"); - for (String flavor : new String[]{"leanback", "mobile"}) { - String activity = read(sourcePath(flavor, "java", "com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); - String event = methodBody(activity, "public void onConfigEvent(ConfigEvent event) {", "@Subscribe"); - assertContainsAll(event, "player().dropStaleBundleDanmaku();"); - } - } - - private static void assertContainsAll(String source, String... values) { - for (String value : values) assertTrue("Missing: " + value, source.contains(value)); - } - - private static String methodBody(String source, String start, String end) { - int from = source.indexOf(start); - int to = source.indexOf(end, from); - assertTrue("Missing method: " + start, from >= 0); - assertTrue("Missing method boundary: " + end, to > from); - return source.substring(from, to); - } - - private static String read(Path path) throws Exception { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - private static Path sourcePath(String... parts) { - Path path = Path.of("src", parts[0], parts[1]); - for (int i = 2; i < parts.length; i++) path = path.resolve(parts[i]); - if (Files.exists(path)) return path; - path = Path.of("app", "src", parts[0], parts[1]); - for (int i = 2; i < parts.length; i++) path = path.resolve(parts[i]); - return path; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/DetailRuntimeModePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/setting/DetailRuntimeModePolicyTest.java deleted file mode 100644 index 57dd61d139..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/DetailRuntimeModePolicyTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.fongmi.android.tv.setting; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class DetailRuntimeModePolicyTest { - - @Test - public void directModeNeverUsesSourceOrNetwork() { - DetailRuntimeModePolicy.Decision decision = resolve(Setting.DETAIL_OPEN_DIRECT, true, true, TmdbSourceState.RENDERABLE); - - assertEquals(Setting.DETAIL_OPEN_DIRECT, decision.runtimeMode()); - assertFalse(decision.sourceOnly()); - assertFalse(decision.networkAllowed()); - } - - @Test - public void sitePolicyCanAlwaysForceOriginalDetail() { - DetailRuntimeModePolicy.Decision decision = resolve(Setting.DETAIL_OPEN_ENHANCED, true, false, TmdbSourceState.RENDERABLE); - - assertEquals(Setting.DETAIL_OPEN_DIRECT, decision.runtimeMode()); - assertFalse(decision.sourceOnly()); - assertFalse(decision.networkAllowed()); - } - - @Test - public void keylessModeFallsBackUnlessSourceIsRenderable() { - DetailRuntimeModePolicy.Decision absent = resolve(Setting.DETAIL_OPEN_ENHANCED, false, true, TmdbSourceState.ABSENT_OR_INVALID); - DetailRuntimeModePolicy.Decision identityOnly = resolve(Setting.DETAIL_OPEN_ENHANCED, false, true, TmdbSourceState.IDENTITY_ONLY); - DetailRuntimeModePolicy.Decision renderable = resolve(Setting.DETAIL_OPEN_ENHANCED, false, true, TmdbSourceState.RENDERABLE); - - assertEquals(Setting.DETAIL_OPEN_DIRECT, absent.runtimeMode()); - assertEquals(Setting.DETAIL_OPEN_DIRECT, identityOnly.runtimeMode()); - assertEquals(Setting.DETAIL_OPEN_ENHANCED, renderable.runtimeMode()); - assertTrue(renderable.sourceOnly()); - assertFalse(renderable.networkAllowed()); - } - - @Test - public void networkCapabilityIsIndependentFromSourceAvailability() { - TmdbSourceState[] states = TmdbSourceState.values(); - for (int mode : new int[]{Setting.DETAIL_OPEN_ORIGINAL_ENHANCED, Setting.DETAIL_OPEN_FUSION, Setting.DETAIL_OPEN_ENHANCED, Setting.DETAIL_OPEN_PLAYER}) { - for (TmdbSourceState state : states) { - DetailRuntimeModePolicy.Decision decision = resolve(mode, true, true, state); - assertEquals(mode, decision.runtimeMode()); - assertTrue(decision.networkAllowed()); - assertFalse(decision.sourceOnly()); - } - } - } - - private static DetailRuntimeModePolicy.Decision resolve(int mode, boolean ready, boolean allowed, TmdbSourceState state) { - return DetailRuntimeModePolicy.resolve(new DetailRuntimeModePolicy.Input(mode, ready, allowed, state)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicyTest.java deleted file mode 100644 index 07a600a2b1..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicyTest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.fongmi.android.tv.setting; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class FlagSwitchPlaybackPolicyTest { - - @Test - public void disabledToggleNeverDefersPlayback() { - assertFalse(FlagSwitchPlaybackPolicy.shouldDeferPlayback(false, false)); - assertFalse(FlagSwitchPlaybackPolicy.shouldDeferPlayback(false, true)); - } - - @Test - public void enabledToggleDefersManualLineSwitchOnly() { - assertTrue(FlagSwitchPlaybackPolicy.shouldDeferPlayback(true, false)); - assertFalse(FlagSwitchPlaybackPolicy.shouldDeferPlayback(true, true)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackWiringTest.java b/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackWiringTest.java deleted file mode 100644 index 95e3ab1fd4..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackWiringTest.java +++ /dev/null @@ -1,258 +0,0 @@ -package com.fongmi.android.tv.setting; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 钉住「切换线路不自动播放」开关的接线不变式:存储键、抑制点、程序化例外、设置页与布局。 - * - *

只钉不变式,不钉具体实现形态:抑制必须发生在 {@code onRefresh()} 的拆流与取流之前 - * (旧流继续播,直到用户点具体集数;同时仍在途的旧取流结果必须就地作废), - * 且程序化切线路(历史恢复、自动换源、TMDB 匹配)必须继续自动起播。 - */ -public class FlagSwitchPlaybackWiringTest { - - private static final String MOBILE = "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"; - private static final String LEANBACK = "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"; - - @Test - public void settingStoresTheToggleUnderItsOwnKey() throws Exception { - String source = read(sourcePath("main", "java").resolve(Path.of("com", "fongmi", "android", "tv", "setting", "PlayerSetting.java"))); - - assertTrue("开关必须独立存储,默认关闭以保持升级后的既有行为", - source.contains("Prefers.getBoolean(\"player_defer_flag_switch\", false)")); - assertTrue("开关必须可写", source.contains("Prefers.put(\"player_defer_flag_switch\", defer)")); - assertTrue("必须暴露读取访问器", source.contains("public static boolean isDeferFlagSwitchPlayback()")); - assertTrue("必须暴露写入访问器", source.contains("public static void putDeferFlagSwitchPlayback(boolean defer)")); - } - - @Test - public void refreshKeepsTheCurrentStreamOnlyWhileTheFlagSwitchIsArmed() throws Exception { - for (String path : new String[]{MOBILE, LEANBACK}) { - String source = read(path); - String body = methodBody(source, "private void onRefresh()"); - int guard = body.indexOf("if (mDeferFlagSwitchPlayback)"); - int fetch = body.indexOf("getPlayer(getFlag(), getEpisode());"); - int release = body.indexOf("mDeferFlagSwitchPlayback = false;", guard); - int branchEnd = body.indexOf("return;", guard); - String guardBranch = branchEnd > guard ? body.substring(guard, branchEnd) : ""; - - assertTrue(path + " 的 onRefresh 必须在拆流前检查抑制标记", guard >= 0); - assertTrue(path + " 的取流调用必须仍然存在", fetch >= 0); - assertTrue(path + " 的抑制检查必须早于取流调用", guard < fetch); - assertTrue(path + " 的抑制必须早于 stop(),否则当前正在播的视频会被停掉", - guard < body.indexOf("player().stop();")); - assertTrue(path + " 的抑制必须早于 clear(),否则当前正在播的视频会被清掉", - guard < body.indexOf("player().clear();")); - assertTrue(path + " 的抑制分支必须在早退前作废仍在途的旧取流请求,否则旧线路稍后会自动起播", - guardBranch.contains("cancelPlayerContent()")); - assertTrue(path + " 的抑制分支必须在早退前复位取流上下文,否则旧结果会被当成当前请求", - guardBranch.contains("invalidatePlayerContent()")); - assertTrue(path + " 的抑制标记必须在 onRefresh 内消费即复位,否则后续播放键恢复会被永久吞掉", - release > guard && release < fetch); - } - } - - @Test - public void manualLineSwitchArmsTheDeferralAndKeepsThePinnedRefreshCall() throws Exception { - for (String path : new String[]{MOBILE, LEANBACK}) { - String source = read(path); - String body = methodBody(source, "public void onItemClick(Flag item)"); - - assertTrue(path + " 的切线路必须咨询共享策略", - body.contains("FlagSwitchPlaybackPolicy.shouldDeferPlayback(PlayerSetting.isDeferFlagSwitchPlayback(), mProgrammaticFlagSwitch)")); - assertTrue(path + " 的切线路必须在刷新前武装抑制标记", - body.indexOf("mDeferFlagSwitchPlayback =") < body.indexOf("boolean episodeChanged = seamless(resolved);")); - assertTrue(path + " 必须在方法末尾复位抑制标记,避免泄漏到后续播放操作", - body.indexOf("mDeferFlagSwitchPlayback = false;") > body.indexOf("loadTmdbRelatedVideosForCurrentEpisode();")); - assertTrue(path + " 必须保留既有的初始绑定刷新调用", - body.contains("if (initialBinding && !episodeChanged) onRefresh();")); - } - } - - @Test - public void programmaticLineSwitchStillAutoplays() throws Exception { - for (String path : new String[]{MOBILE, LEANBACK}) { - String source = read(path); - - assertProgrammatic(source, path, "private void checkFlag(Vod item)", "onItemClick(resolveHistoryPlaybackFlag(item.getFlags()));"); - assertProgrammatic(source, path, "private void resumeHistoryAfterTmdbMatch()", "onItemClick(targetFlag);"); - assertProgrammatic(source, path, "private void nextFlag(int position)", "onItemClick(flag);"); - } - } - - @Test - public void settingPagesAndLayoutsExposeTheToggle() throws Exception { - String mobilePage = read(sourcePath("mobile", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "fragment", "SettingPlayerFragment.java"))); - String leanbackPage = read(sourcePath("leanback", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "SettingPlayerActivity.java"))); - for (String source : new String[]{mobilePage, leanbackPage}) { - assertTrue("设置页必须显示开关当前值", source.contains("mBinding.deferFlagSwitchText.setText(getSwitch(PlayerSetting.isDeferFlagSwitchPlayback()));")); - assertTrue("设置页必须绑定开关点击", source.contains("mBinding.deferFlagSwitch.setOnClickListener(this::setDeferFlagSwitch);")); - assertTrue("设置页必须持久化开关", source.contains("PlayerSetting.putDeferFlagSwitchPlayback(!PlayerSetting.isDeferFlagSwitchPlayback());")); - } - - String mobileLayout = read(sourcePath("mobile", "res").resolve(Path.of("layout", "fragment_setting_player.xml"))); - String leanbackLayout = read(sourcePath("leanback", "res").resolve(Path.of("layout", "activity_setting_player.xml"))); - for (String layout : new String[]{mobileLayout, leanbackLayout}) { - assertTrue("布局必须提供开关行", layout.contains("@+id/deferFlagSwitch")); - assertTrue("布局必须提供开关取值文本", layout.contains("@+id/deferFlagSwitchText")); - assertTrue("布局必须引用开关文案", layout.contains("@string/player_defer_flag_switch")); - } - } - - @Test - public void everyLocaleCarriesTheToggleLabel() throws Exception { - for (String locale : new String[]{"values", "values-zh-rCN", "values-zh-rTW"}) { - String strings = read(sourcePath("main", "res").resolve(Path.of(locale, "strings.xml"))); - assertTrue(locale + " 缺少开关文案", strings.contains("")); - } - } - - private static void assertProgrammatic(String source, String path, String method, String click) { - String body = methodBody(source, method); - int call = body.indexOf(click); - assertTrue(path + " 的 " + method + " 必须仍调用 " + click, call >= 0); - int arm = body.lastIndexOf("mProgrammaticFlagSwitch = true;", call); - int reset = body.indexOf("mProgrammaticFlagSwitch = false;", call); - assertTrue(path + " 的 " + method + " 必须在调用前置位程序化标记", arm >= 0); - assertTrue(path + " 的 " + method + " 必须在调用后复位程序化标记", reset > call); - } - - - @Test - public void bothPlayersIdentifyTheCurrentStreamByTheAppliedFlagKey() throws Exception { - for (String path : new String[]{MOBILE, LEANBACK}) { - String source = read(path); - assertTrue(path + " 必须记住实际已应用的线路稳定键", - source.contains("private String mPlaybackFlagKey = \"\";") - && source.contains("private String playbackFlagKey(Flag flag) {") - && source.contains("private boolean isPlaybackFlag(Flag flag) {") - && source.contains("private boolean isPlayingContent(Episode episode) {")); - String invalidate = methodBody(source, "private void invalidatePlayerContent()"); - assertTrue(path + " 作废在途取流时必须同时作废已应用线路键", - invalidate.contains("mPlaybackFlagKey = \"\";")); - String getPlayer = methodBody(source, "private void getPlayer(Flag flag, Episode episode)"); - int begin = getPlayer.indexOf("beginPlayerContentRequest(getKey(), playFlag, episode.getUrl());"); - int remember = getPlayer.indexOf("mPlaybackFlagKey = playbackFlagKey(flag);"); - assertTrue(path + " 起播时必须记录线路键", begin >= 0 && remember > begin); - assertTrue(path + " 只按标记不能认定该集正在播放", - methodBody(source, "private boolean shouldEnterFullscreen(Episode item)") - .contains("isPlayingContent(item)")); - assertTrue(path + " 平滑换线路必须确认当前流确实属于该线路", - methodBody(source, "private boolean seamless(Flag flag)") - .contains("(isPlaybackFlag(flag) && episode.isSelected())")); - assertTrue(path + " 线路点击早退必须以当前线路身份为前提", - methodBody(source, "public void onItemClick(Flag item)").contains("isPlaybackFlag(resolved)")); - } - } - - /** - * 线路切换自身会重设选中位并刷新整行,Leanback 因此会把「同一条线路」再派发一次; - * 若这次回声回到 onItemClick,就会形成 200ms 级自激切换循环(实测日志 934 次 - * line-switch 中 918 次 from==to,且循环窗口内没有任何用户输入事件)。 - * - *

行为层({@code FlagSelectionListenerTest})测得到「回声被拦」与「换线路/切回仍派发」, - * 但测不到「窗口在刷新途中不被重开」——该差别只在 {@code run()} 自身派发路径上才可观测。 - * 因此这里补最小结构不变式,每条断言都先要求 token 存在({@code indexOf} 的 −1 不得 - * 被当成「顺序成立」),再要求相对顺序,避免「漏写关键字反而变绿」。 - */ - @Test - public void leanbackFlagRowSuppressesItsOwnSelectionEcho() throws Exception { - String source = read(LEANBACK_LISTENER); - String run = methodBody(source, "public void run()"); - String selected = methodBody(source, "public void onChildViewHolderSelected("); - assertTrue("必须保留选中回调", !selected.isEmpty()); - - int stale = run.indexOf("adapter.get(position) == item)"); - int guard = run.indexOf("if (item == dispatchedItem) return;"); - int remember = run.indexOf("dispatchedItem = item;"); - int dispatch = run.indexOf("listener.onItemClick(item);"); - assertTrue("回声拦截必须发生在 stale 校验之后、派发之前", - stale >= 0 && guard >= 0 && dispatch >= 0 && stale < guard && guard < dispatch); - assertTrue("拦截后必须记住本次派发,否则回声仍会重复进入", - remember >= 0 && remember > guard); - - // 窗口条件:只有「真的选中了另一条线路」才结束窗口;NO_POSITION(item == null)属于 - // 刷新中间态,不得清窗口,否则回声会在下一次同项选中时重新接上。 - assertTrue("改选另一条线路必须重新打开回声窗口,否则用户再也切不回原线路", - selected.contains("if (item != null && item != dispatchedItem) dispatchedItem = null;")); - - // 取消旧回调若被顺手删掉,功能测试不会红(pendingItem 仍会被覆盖),故在此单独钉住。 - int cancel = selected.indexOf("view.removeCallbacks(this);"); - int pending = selected.indexOf("pendingItem = item;"); - int post = selected.indexOf("view.post(this)"); - assertTrue("必须保留取消旧回调,且发生在记录新 pendingItem 之前", - cancel >= 0 && pending >= 0 && cancel < pending); - assertTrue("必须仍然申请派发那一帧", post >= 0 && post > pending); - - // 点击线路走 FlagAdapter 的 OnClickListener,不经过本监听器;回声去重一旦下沉进 - // onItemClick,点击也会被吞掉。此处钉住「守卫只活在选中监听器里」这条结构边界。 - String flagClick = methodBody(read(LEANBACK), "public void onItemClick(Flag item)"); - assertTrue("回声去重不得下沉进 onItemClick,否则点击线路会被静默吞掉", - !flagClick.contains("dispatchedItem")); - } - - @Test - public void detailInlinePlaybackAlsoComparesTheAppliedFlagKey() throws Exception { - String source = read(DETAIL); - assertTrue("详情页必须记住内联播放的线路稳定键", - source.contains("private String inlinePlaybackFlagKey = \"\";")); - String start = methodBody(source, "private void startInlinePlayer(Result result, long resumePosition)"); - assertTrue("内联起播必须记录线路稳定键", - start.contains("inlinePlaybackFlagKey = selectedFlag == null ? \"\" : selectedSeasonFlagKey();")); - String stop = methodBody(source, "private void stopInlinePlayerForReload()"); - assertTrue("内联停止必须清空线路稳定键", stop.contains("inlinePlaybackFlagKey = \"\";")); - assertTrue("同名线路必须靠稳定键区分是否仍是当前播放", - source.contains("&& TextUtils.equals(selectedSeasonFlagKey(), inlinePlaybackFlagKey)")); - assertTrue("所有内联停止点都必须清空线路稳定键", occurrences(source, "inlinePlaybackFlagKey = \"\";") >= 2); - } - - private static int occurrences(String source, String token) { - int count = 0; - int index = 0; - while ((index = source.indexOf(token, index)) >= 0) { - count++; - index += token.length(); - } - return count; - } - - private static final String DETAIL = "app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"; - private static final String LEANBACK_LISTENER = "app/src/leanback/java/com/fongmi/android/tv/ui/custom/FlagSelectionListener.java"; - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("missing method " + signature, start >= 0); - int open = source.indexOf('{', start); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return source.substring(start, i + 1); - } - throw new AssertionError("unbalanced method " + signature); - } - - private static Path sourcePath(String sourceSet, String kind) { - return root().resolve(Path.of("app", "src", sourceSet, kind)); - } - - /** Gradle 单测的工作目录可能是仓库根或 app/,两种都要能解析。 */ - private static Path root() { - return Files.exists(Path.of("app", "src", "main")) ? Path.of(".") : Path.of(".."); - } - - private static String read(String path) throws Exception { - return read(root().resolve(path)); - } - - private static String read(Path path) throws Exception { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/FullscreenMenuKeyTest.java b/app/src/test/java/com/fongmi/android/tv/setting/FullscreenMenuKeyTest.java deleted file mode 100644 index 6fe2c7fe6b..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/FullscreenMenuKeyTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.fongmi.android.tv.setting; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 「全屏菜单键」新增「下一集」取值的源契约回归:三语言数组必须同长, - * 播放页与内嵌播放器都必须把新取值接到既有的下一集链路,存量值 0/1 语义不变。 - */ -public class FullscreenMenuKeyTest { - - @Test - public void everyLocaleOffersTheSameThreeOptions() throws Exception { - String[] locales = {"values", "values-zh-rCN", "values-zh-rTW"}; - String[] expected = {"Next episode", "下一集", "下一集"}; - for (int i = 0; i < locales.length; i++) { - String block = array(read(res(locales[i], "strings.xml")), "select_fullscreen_menu_key"); - assertEquals(locales[i] + " must offer exactly 3 fullscreen menu key options", - 3, count(block, "")); - assertTrue(locales[i] + " must keep the new option last so stored values 0/1 keep their meaning", - block.contains("" + expected[i] + "")); - } - } - - @Test - public void storedValueIsClampedToKnownOptions() throws Exception { - String setting = read(main("java", "com", "fongmi", "android", "tv", "setting", "Setting.java")); - - assertTrue(setting.contains("public static final int FULLSCREEN_MENU_KEY_CONTROL = 0;")); - assertTrue(setting.contains("public static final int FULLSCREEN_MENU_KEY_EPISODES = 1;")); - assertTrue(setting.contains("public static final int FULLSCREEN_MENU_KEY_NEXT_EPISODE = 2;")); - assertTrue("an unknown stored value must fall back to the control bar instead of indexing past the array", - setting.contains("menuKey > FULLSCREEN_MENU_KEY_NEXT_EPISODE ? FULLSCREEN_MENU_KEY_CONTROL : menuKey")); - } - - @Test - public void leanbackMenuKeyAdvancesThroughTheExistingNextEpisodePath() throws Exception { - String source = read(leanback("java", "com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); - - String branch = window(source, "if (isFullscreen() && KeyUtil.isMenuKey(event)) {", 500); - assertTrue("the menu key must dispatch the new option", branch.contains("FULLSCREEN_MENU_KEY_NEXT_EPISODE")); - assertTrue("the new option must use its own entry point", branch.contains("onMenuNextEpisode()")); - assertTrue("the existing options must keep working", branch.contains("FULLSCREEN_MENU_KEY_EPISODES) onEpisodes()") - && branch.contains("else onToggle();")); - - String helper = window(source, "private void onMenuNextEpisode() {", 500); - assertTrue("advancing must reuse the control-bar/auto-advance path", helper.contains("checkNext();")); - assertTrue("a detail-less/fast playback page must not crash on a null flag", helper.contains("flag == null")); - assertTrue("an empty episode list must not fake a next episode", helper.contains("flag.getEpisodes().isEmpty()")); - assertFalse("advancing must go through checkNext() so reverse play still picks the previous index", helper.contains("onNext(")); - } - - @Test - public void inlinePlayerHonoursTheSameOption() throws Exception { - String source = read(main("java", "com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); - - String branch = window(source, - "if (KeyUtil.isMenuKey(event) && (inlineFullscreen || isInlineControlsVisible())) {", 600); - assertTrue("the inline player must dispatch the new option", branch.contains("FULLSCREEN_MENU_KEY_NEXT_EPISODE")); - assertTrue("the inline player must reuse its null-safe next-episode path", branch.contains("checkInlineNext();")); - } - - private static String array(String strings, String name) { - String start = ""; - int from = strings.indexOf(start); - int to = strings.indexOf("", from); - assertTrue(name + " must stay a string-array", from >= 0 && to > from); - return strings.substring(from, to); - } - - private static int count(String source, String needle) { - int total = 0; - for (int at = source.indexOf(needle); at >= 0; at = source.indexOf(needle, at + 1)) total++; - return total; - } - - private static String window(String source, String marker, int size) { - int from = source.indexOf(marker); - assertTrue("missing source marker: " + marker, from >= 0); - return source.substring(from, Math.min(source.length(), from + size)); - } - - private static Path res(String values, String name) { - return root().resolve(Path.of("src", "main", "res", values, name)); - } - - private static Path main(String... rest) { - return root().resolve(Path.of("src", "main", String.join("/", rest))); - } - - private static Path leanback(String... rest) { - return root().resolve(Path.of("src", "leanback", String.join("/", rest))); - } - - private static String read(Path path) throws Exception { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - private static Path root() { - return Files.exists(Path.of("src", "main", "java")) ? Path.of(".") : Path.of("app"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/InterfaceFailoverPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/setting/InterfaceFailoverPolicyTest.java deleted file mode 100644 index e97ed44fd1..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/InterfaceFailoverPolicyTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.fongmi.android.tv.setting; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class InterfaceFailoverPolicyTest { - - @Test - public void invalidModeFallsBackToDefault() { - assertEquals(InterfaceFailoverPolicy.AUTO, InterfaceFailoverPolicy.clampMode(-1)); - assertEquals(InterfaceFailoverPolicy.AUTO, InterfaceFailoverPolicy.clampMode(99)); - } - - @Test - public void autoAndConfirmTriggerFailover() { - assertTrue(InterfaceFailoverPolicy.shouldFailover(InterfaceFailoverPolicy.AUTO)); - assertTrue(InterfaceFailoverPolicy.shouldFailover(InterfaceFailoverPolicy.CONFIRM)); - assertFalse(InterfaceFailoverPolicy.shouldFailover(InterfaceFailoverPolicy.OFF)); - } - - @Test - public void attemptLimitNeverExceedsThree() { - assertEquals(0, InterfaceFailoverPolicy.attemptLimit(0)); - assertEquals(1, InterfaceFailoverPolicy.attemptLimit(1)); - assertEquals(3, InterfaceFailoverPolicy.attemptLimit(8)); - } - - @Test - public void invalidModesFallBackToDefault() { - assertTrue(InterfaceFailoverPolicy.shouldFailover(-1)); - assertTrue(InterfaceFailoverPolicy.shouldFailover(99)); - } - - @Test - public void fallbackLimitLeavesRoomForTheOriginAttempt() { - assertEquals(0, InterfaceFailoverPolicy.fallbackLimit(0)); - assertEquals(0, InterfaceFailoverPolicy.fallbackLimit(1)); - assertEquals(2, InterfaceFailoverPolicy.fallbackLimit(3)); - assertEquals(2, InterfaceFailoverPolicy.fallbackLimit(8)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/InterfaceFailoverStateTest.java b/app/src/test/java/com/fongmi/android/tv/setting/InterfaceFailoverStateTest.java deleted file mode 100644 index 0f6118a2c9..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/InterfaceFailoverStateTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.fongmi.android.tv.setting; - -import org.junit.Test; - -import java.util.Arrays; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -public class InterfaceFailoverStateTest { - - @Test - public void automaticRoundUsesEachCandidateOnceAndStopsAtThreeAttempts() { - InterfaceFailoverState state = new InterfaceFailoverState( - InterfaceFailoverPolicy.AUTO, "a", Arrays.asList("b", "b", "c", "d")); - - assertEquals("b", state.nextAutomatic()); - assertTrue(state.shouldContinueAfterFailure()); - assertEquals("c", state.nextAutomatic()); - assertFalse(state.shouldContinueAfterFailure()); - assertNull(state.nextAutomatic()); - } - - @Test - public void confirmRoundWaitsForSelectionAndAllowsOnlyOneAttempt() { - InterfaceFailoverState state = new InterfaceFailoverState( - InterfaceFailoverPolicy.CONFIRM, "a", Arrays.asList("b", "c")); - - assertNull(state.nextAutomatic()); - assertEquals("c", state.select(1)); - assertNull(state.select(0)); - assertFalse(state.shouldContinueAfterFailure()); - } - - @Test - public void cancelledRoundNeverStartsAnAttempt() { - InterfaceFailoverState state = new InterfaceFailoverState( - InterfaceFailoverPolicy.AUTO, "a", Arrays.asList("b", "c")); - - state.cancel(); - - assertNull(state.nextAutomatic()); - assertFalse(state.shouldContinueAfterFailure()); - assertNull(state.select(0)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/InterfaceOrderStoreTest.java b/app/src/test/java/com/fongmi/android/tv/setting/InterfaceOrderStoreTest.java deleted file mode 100644 index 368e4145ee..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/InterfaceOrderStoreTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.fongmi.android.tv.setting; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -public class InterfaceOrderStoreTest { - - @Test - public void normalizeRemovesBlankAndDuplicateUrlsInOrder() { - assertEquals( - Arrays.asList("a", "b", "c"), - InterfaceOrderStore.normalize(Arrays.asList("a", "b", "a", "", "c", "b"))); - } - - @Test - public void normalizePreservesEmptyListContract() { - assertTrue(InterfaceOrderStore.normalize(new ArrayList<>()).isEmpty()); - } - - @Test - public void sortUsesSavedOrderThenAppendsNewUrls() { - assertEquals( - Arrays.asList("c", "a", "b"), - InterfaceOrderStore.sortUrls( - Arrays.asList("a", "b", "c"), - Arrays.asList("c", "deleted", "a"))); - } - - @Test - public void sortDeduplicatesAvailableAndSavedUrls() { - assertEquals( - Arrays.asList("b", "a"), - InterfaceOrderStore.sortUrls( - Arrays.asList("a", "b", "a"), - Arrays.asList("b", "b", "missing"))); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/MpvDv7HandlingSettingTest.java b/app/src/test/java/com/fongmi/android/tv/setting/MpvDv7HandlingSettingTest.java deleted file mode 100644 index 75ae146b82..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/MpvDv7HandlingSettingTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.fongmi.android.tv.setting; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -public class MpvDv7HandlingSettingTest { - - @Test - public void oldStoredValuesAndDefaultRemainUnchanged() { - assertEquals(0, PlaybackPerformanceSetting.DV7_HANDLING_P81); - assertEquals(1, PlaybackPerformanceSetting.DV7_HANDLING_HDR10); - assertEquals(0, PlaybackPerformanceSetting.clampMpvDv7Handling(-1)); - assertEquals(0, PlaybackPerformanceSetting.clampMpvDv7Handling(3)); - assertEquals(0, PlaybackPerformanceSetting.clampMpvDv7Handling(Integer.MAX_VALUE)); - } - - @Test - public void thirdValueIsReachableOnlyByExplicitSelection() { - assertEquals(1, PlaybackPerformanceSetting.nextMpvDv7HandlingMode(0)); - assertEquals(2, PlaybackPerformanceSetting.nextMpvDv7HandlingMode(1)); - assertEquals(0, PlaybackPerformanceSetting.nextMpvDv7HandlingMode(2)); - assertEquals(2, PlaybackPerformanceSetting.clampMpvDv7Handling(2)); - } - - @Test - public void labelMatchesTheApprovedName() { - assertEquals("升级P8.1", PlaybackPerformanceSetting.mpvDv7HandlingText(0)); - assertEquals("降级HDR10", PlaybackPerformanceSetting.mpvDv7HandlingText(1)); - assertEquals("FEL 双层重建", PlaybackPerformanceSetting.mpvDv7HandlingText(2)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/MpvOutputModePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/setting/MpvOutputModePolicyTest.java index f3fb0c626b..708e46cb04 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/MpvOutputModePolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/MpvOutputModePolicyTest.java @@ -25,20 +25,10 @@ public class MpvOutputModePolicyTest { } @Test - public void autoModeEntersSurfaceDirectOnEligibleTv() { - assertTrue(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, true, true, true, false)); - } - - @Test - public void autoModeStillRefusesWhenAutoEligibilityOrTvOrHardwareIsMissing() { + public void autoModeStaysOnGpuDuringSurfaceStabilityGuard() { + assertFalse(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, true, true, true, false)); assertFalse(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, false, true, true, false)); assertFalse(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, true, false, true, false)); - assertFalse(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, true, true, false, false)); - } - - @Test - public void zeroCopyDeviceGuardStillBlocksAutoMode() { - assertFalse(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, true, true, true, true)); } @Test diff --git a/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceSettingSourceTest.java b/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceSettingSourceTest.java index 105cf6128c..521ee2d81d 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceSettingSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceSettingSourceTest.java @@ -110,99 +110,6 @@ public class PlaybackPerformanceSettingSourceTest { assertFalse(buildPlayer.contains("buildLoadControl()")); } - @Test - public void lateDropThresholdSwitchCanActuallyDisableDropping() throws Exception { - // media3 defaults lateThresholdToDropDecoderInputUs to 15 ms, which is exactly the value - // this build passes when the switch is on. If the wiring skipped the setter when the - // switch is off, the default would keep dropping input frames and the switch would lie. - String util = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoUtil.java")); - String renderer = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoRuntimeAwareVideoRenderer.java")); - - assertContainsAll(util, - "ENHANCED_LATE_THRESHOLD_TO_DROP_INPUT_US = 15_000L", - "PlaybackPerformanceSetting.isLateDropInputEnabled()", - "C.TIME_UNSET"); - // Both renderer paths must read the one shared accessor so they cannot drift apart. - assertContainsAll(renderer, "ExoUtil.effectiveLateDropThresholdUs()"); - assertDoesNotContainAny(renderer, "isLateDropInputEnabled()"); - } - - @Test - public void frameSchedulingConfigurationReachesTheDiagnosticLog() throws Exception { - // A dropped-frame report is unattributable while exported logs carry no configuration, - // so the player build must emit one line naming what actually reached the factory. - String util = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoUtil.java")); - String diagnostics = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoPlaybackDiagnostics.java")); - String settings = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoFrameSchedulingRendererSettings.java")); - - assertContainsAll(util, - "long lateDropThresholdUs = effectiveLateDropThresholdUs();", - "ExoPlaybackDiagnostics.logFrameScheduling(", - "frameSchedulingSettings.codecQueueModeLabel()", - "frameSchedulingSettings.dynamicSchedulingEnabled()"); - assertContainsAll(diagnostics, "static void logFrameScheduling(", "ExoFrameSchedulingRendererSettings.describe("); - assertContainsAll(settings, - "static String describe(", - "durationToProgress=", - "earlySchedulingUs=", - "lateDropInputUs=", - "\"unset\"", - "metricScope=settings-applied-only"); - } - - @Test - public void inputSideDropCountersReachTheDiagnosticLog() throws Exception { - // The 「输入丢帧阈值」 switch drops decoder input frames before they are decoded. Reporting - // only the output counters let that happen invisibly, which is the one question a - // dropped-frame report has to answer. - String collector = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoDiagnosticCollector.java")); - - assertContainsAll(collector, - "droppedInputBufferCount", - "skippedInputBufferCount", - "droppedToKeyframeCount", - "droppedInputBuffers are pre-decoder drops"); - } - - @Test - public void frameLatenessHistogramDoesNotRequireExperimentEnrollment() throws Exception { - // The per-frame lateness distribution used to be collected only for devices enrolled in - // the frame-scheduling A/B experiment, so an ordinary report could not say how late frames - // were. Diagnostics-on must be enough, and stop-only delivery must not be the only trigger: - // media3 reports the offset from onStopped() alone. - String listener = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "PlaybackAnalyticsListener.java")); - String collector = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoDiagnosticCollector.java")); - - assertContainsAll(listener, - "frameSchedulingExperimentActive || SpiderDebug.isEnabled()", - "static void logFrameTimingIfDue()", - "\"exo-frame-timing\"", - "FRAME_TIMING_LOG_INTERVAL_MS"); - assertContainsAll(collector, "PlaybackAnalyticsListener.logFrameTimingIfDue();"); - } - - @Test - public void telemetryNamesTheRenderedFpsScope() throws Exception { - // 58.824 in the telemetry line is a presentation-timestamp cadence, not rendered - // throughput; a stuttering 60 fps stream still reports it. Name the scope in the line. - String mapper = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "PlaybackTelemetryMapper.java")); - - assertContainsAll(mapper, "renderedFpsScope=pts-cadence-estimate;not-throughput"); - } - - @Test - public void durationProgressKeepsUpstreamDefaultUntilMigrated() throws Exception { - String source = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "setting", "PlaybackPerformanceSetting.java")); - String getter = methodBody(source, "public static boolean isVideoDurationProgressEnabled()", "public static void putVideoDurationProgressEnabled"); - String recommended = methodBody(source, "private static void putRecommendedFlags()", "private static int clampProfile(int profile)"); - String init = methodBody(source, "public static void ensureInitialized()", "public static int getProfile()"); - - assertTrue(getter.contains("Prefers.getBoolean(KEY_VIDEO_DURATION_PROGRESS, false)")); - assertFalse(recommended.contains("put(KEY_VIDEO_DURATION_PROGRESS, true)")); - assertTrue(recommended.contains("put(KEY_VIDEO_DURATION_PROGRESS, false)")); - assertTrue(init.contains("migrateExoFrameSchedulingParity();")); - } - private static void assertContainsAll(String source, String... values) { for (String value : values) assertTrue("Missing: " + value, source.contains(value)); } diff --git a/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicyTest.java index 7b8c17a429..7f486cae5b 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceUiPolicyTest.java @@ -13,16 +13,6 @@ import static org.junit.Assert.assertTrue; public class PlaybackPerformanceUiPolicyTest { - @Test - public void felIsDescribedOnlyInTheMpvCatalog() { - for (int kernel : new int[]{PlayerSetting.MPV, PlayerSetting.EXO}) { - PlaybackPerformanceOption option = PlaybackPerformanceCatalog.forKernel(kernel, false) - .stream().filter(item -> item.id().equals(PlaybackPerformanceCatalog.DV7_HDR10_FALLBACK)) - .findFirst().orElseThrow(); - assertEquals(kernel == PlayerSetting.MPV, option.description().contains("FEL 双层重建")); - } - } - @Test public void everyKernelSeparatesProfileCommonAndAdvancedWithoutLoss() { for (int kernel : new int[]{ @@ -64,8 +54,7 @@ public class PlaybackPerformanceUiPolicyTest { PlaybackPerformanceCatalog.MPV_HWDEC, PlaybackPerformanceCatalog.MPV_FRAME_RATE, PlaybackPerformanceCatalog.MPV_OPTION_PRIORITY, - PlaybackPerformanceCatalog.AUDIO_PASSTHROUGH, - PlaybackPerformanceCatalog.MPV_MULTICHANNEL_AUDIO), + PlaybackPerformanceCatalog.AUDIO_PASSTHROUGH), ids(PlaybackPerformanceUiPolicy .splitForKernel(PlayerSetting.MPV, false).common())); assertEquals(Set.of( diff --git a/app/src/test/java/com/fongmi/android/tv/setting/PlayerDisplaySettingSyncTest.java b/app/src/test/java/com/fongmi/android/tv/setting/PlayerDisplaySettingSyncTest.java index 43d205102a..e40e0185a9 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/PlayerDisplaySettingSyncTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/PlayerDisplaySettingSyncTest.java @@ -24,20 +24,6 @@ public class PlayerDisplaySettingSyncTest { assertPlayerOsdArrayIncludesAllDisplayOptions(read(mainResPath().resolve(Path.of("values-zh-rTW", "strings.xml")))); } - @Test - public void tvScreenDisplayDialogRowsUseTheThemeAwareContrastWiring() throws Exception { - for (String flavor : new String[]{"leanback", "mobile"}) { - String layout = read(sourcePath(flavor, "res").resolve(Path.of("layout", "adapter_player_osd.xml"))); - String dialog = read(sourcePath(flavor, "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "dialog", "PlayerOsdDialog.java"))); - assertTrue(flavor + " 屏显行必须使用主题化行背景", layout.contains("@drawable/selector_player_settings_row")); - assertTrue(flavor + " 屏显眼睛按钮必须使用主题化按钮背景", layout.contains("@drawable/selector_player_settings_switch")); - assertTrue(flavor + " 屏显眼睛图标必须使用主题化前景", layout.contains("@color/player_settings_icon_tint")); - assertFalse(flavor + " 屏显行不得回退到固定浅色卡片", layout.contains("selector_git_cloud_card")); - assertTrue(flavor + " 屏显对话框必须应用活动主题行颜色", dialog.contains("PlayerSettingsRowTheme.apply(holder.binding.root, holder.binding.name")); - assertTrue(flavor + " 屏显对话框必须应用活动主题图标颜色", dialog.contains("PlayerSettingsRowTheme.applySwitch(holder.binding.visible")); - } - } - @Test public void playerOsdControllerHonorsResolutionDisplayToggle() throws Exception { String source = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "custom", "PlayerOsdController.java"))); @@ -77,308 +63,10 @@ public class PlayerDisplaySettingSyncTest { && source.indexOf("setDiagnosticsPanel(player);", render) > render); } - /** - * 诊断面板的重缓冲计数必须按「有没有 Exo analytics 快照」分派,而不是按是否 MPV。 - * - *

非 Exo 时 {@code snapshot} 是 {@code Snapshot.empty()},重缓冲恒为 0;若只对 MPV 分支, - * IJK 会落到快照分支而永远读到 0,排查时会被误导。 - */ @Test - public void playerOsdDiagnosticsReadRebufferCountPerEngine() throws Exception { - String source = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "custom", "PlayerOsdController.java"))); - - int count = source.indexOf("int rebufferCount ="); - int total = source.indexOf("long rebufferTotalMs ="); - assertTrue("the diagnostics must compute a re-buffer count and total", count >= 0 && total > count); - String countExpr = source.substring(count, source.indexOf(';', count)); - String totalExpr = source.substring(total, source.indexOf(';', total)); - - assertTrue("re-buffer count must read the engine-agnostic tracker for every non-Exo engine, not only MPV", - countExpr.contains("player.isExo()") - && countExpr.contains("snapshot.rebufferCount()") - && countExpr.contains("player.getRebufferCount()")); - assertTrue("re-buffer total must read the engine-agnostic tracker for every non-Exo engine, not only MPV", - totalExpr.contains("player.isExo()") - && totalExpr.contains("snapshot.rebufferTotalMs()") - && totalExpr.contains("player.getRebufferTotalMs()")); - } - - /** - * 媒体信息徽标的唯一开关是「屏显设置 → 媒体信息」(默认关闭): - * 关闭时控制栏唤醒也不再渲染徽标(非全屏唤醒的控制栏本就拥挤), - * 打开时控制栏隐藏同样作为常驻项展示,与标题 / 时间 / 进度 / 码率同级。 - */ - @Test - public void mediaBadgesAreGatedByTheMediaInfoSwitchOnEveryPath() throws Exception { - String source = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "custom", "PlayerOsdController.java"))); - int method = source.indexOf("private void setMediaBadges(PlayerManager player)"); - assertTrue("PlayerOsdController is missing setMediaBadges", method >= 0); - int methodEnd = source.indexOf("\n }", method); - String body = source.substring(method, methodEnd); - - assertTrue("media badges must stay off every non-fullscreen surface", - body.contains("!source.isFullscreen()")); - assertTrue("the media info master switch must gate every path, including a woken control bar", - body.contains("!PlayerSetting.isDisplayMediaBadges()")); - assertTrue("the persistent flag may only gate the control-bar-hidden path", - body.contains("!controlsVisible && !PlayerSetting.isDisplayMediaBadgesPersistent()")); - - int only = source.indexOf("private boolean renderMediaBadgesOnly()"); - assertTrue("PlayerOsdController is missing renderMediaBadgesOnly", only >= 0); - String onlyBody = source.substring(only, source.indexOf("\n }", only)); - assertTrue("the suppressed mobile control-bar path must honor fullscreen and the master switch", - onlyBody.contains("if (!source.isFullscreen() || !PlayerSetting.isDisplayMediaBadges())")); - - String persistent = source.substring(source.indexOf("// 控制栏隐藏时,按用户设置显示常驻屏显")); - String renderEnd = "private boolean renderMediaBadgesOnly()"; - int end = persistent.indexOf(renderEnd); - assertTrue("the persistent render branch must exist before renderMediaBadgesOnly", end > 0); - String persistentBody = persistent.substring(0, end); - assertTrue("persistent OSD must render media badges like title/time/progress/traffic", - persistentBody.contains("setMediaBadges(player);")); - assertTrue("the persistent path must not force the badge strip hidden", - !persistentBody.contains("mediaBadges.setVisibility(View.GONE)")); - assertTrue("badges must clear the real control bar height, never a hardcoded one", - source.contains("bottomMargin = controlsVisible ? barTop : (stacked ? clear : edgeBottom);") - && source.contains("private int controlBarTopInset(View bar, int edgeGap)")); - assertTrue("the control bar height must be re-measured once its layout settles", - source.contains("private void observeControlBar(View bar)") - && source.contains("addOnLayoutChangeListener") - && source.contains("root.post(this::applyMediaBadgePosition)")); - assertTrue("badges must hug the screen edge when nothing else occupies their corner", - source.contains("private View persistentNeighbour(int position)") - && source.contains("Math.max(0, gap - (badgeParent == null ? 0 : badgeParent.getPaddingBottom()))")); - assertTrue("one 16dp breathing gap must serve phone and TV, control bar visible or not", - source.contains("private int badgeGap() {") - && source.contains("return dp(16);") - && source.contains("int barTop = controlBarTopInset(bar, edgeBottom);") - && source.contains("bottomMargin = controlsVisible ? barTop : (stacked ? clear : edgeBottom);") - && !source.contains("edgeInset")); - } - - /** - * 「媒体信息位置」单选新增关闭项,且位置键始终保留用户最近一次选的四角: - * 关闭只关 {@code player_osd_media_badges},不改位置;再次开启回到那个角, - * 从未设置过(默认关闭)时则回到默认的右下角(index 3)。 - */ - @Test - public void mediaBadgePositionChoiceKeepsTheLastCornerAcrossOff() throws Exception { - String setting = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "setting", "PlayerSetting.java"))); - assertTrue("the Off choice must be the 5th item of the position list", - setting.contains("MEDIA_BADGE_POSITION_OFF = 4")); - - int choice = setting.indexOf("public static int getMediaBadgeChoice()"); - assertTrue("PlayerSetting is missing getMediaBadgeChoice", choice >= 0); - String choiceExpr = setting.substring(choice, setting.indexOf(';', choice)); - assertTrue("the selected choice must be Off while media info is disabled", - choiceExpr.contains("isDisplayMediaBadges()") && choiceExpr.contains("MEDIA_BADGE_POSITION_OFF")); - assertTrue("the selected choice must be the stored corner while media info is enabled", - choiceExpr.contains("getMediaBadgePosition()")); - assertTrue("an unset position key must still fall back to the bottom-right corner", setting.contains("KEY_MEDIA_BADGE_POSITION, 3")); - - int apply = setting.indexOf("public static void putMediaBadgeChoice(int which)"); - assertTrue("PlayerSetting is missing putMediaBadgeChoice", apply >= 0); - String applyBody = setting.substring(apply, setting.indexOf("\n }", apply)); - assertTrue("choosing Off must only switch media info off and keep the remembered corner", - applyBody.contains("putDisplayMediaBadges(false)") - && !applyBody.contains("putMediaBadgePosition(MEDIA_BADGE_POSITION_OFF)")); - assertTrue("choosing a corner must turn the media info master switch on so the woken control bar shows badges", - applyBody.contains("putMediaBadgePosition(which)") && applyBody.contains("putDisplayMediaBadges(true)")); - - Path mobileSettingPage = Path.of("com", "fongmi", "android", "tv", "ui", "fragment", "SettingPlayerFragment.java"); - Path leanbackSettingPage = Path.of("com", "fongmi", "android", "tv", "ui", "activity", "SettingPlayerActivity.java"); - String[] flavors = {"mobile", "leanback"}; - Path[] settingPages = {mobileSettingPage, leanbackSettingPage}; - for (int i = 0; i < flavors.length; i++) { - String flavor = flavors[i]; - String page = read(sourcePath(flavor, "java").resolve(settingPages[i])); - assertTrue(flavor + " 媒体信息位置单选必须显示当前选项(含关闭)", - page.contains("PlayerSetting.getMediaBadgeChoice()")); - assertTrue(flavor + " 媒体信息位置单选必须写回开启/关闭", - page.contains("PlayerSetting.putMediaBadgeChoice(which);")); - assertFalse(flavor + " 媒体信息位置单选不得再用只改角位的旧入口", - page.contains("PlayerSetting.putMediaBadgePosition(which);")); - } - - for (String locale : new String[]{"values", "values-zh-rCN", "values-zh-rTW"}) { - String strings = locale.equals("values") - ? read(mainResPath().resolve(Path.of("values", "strings.xml"))) - : read(mainResPath().resolve(Path.of(locale, "strings.xml"))); - int arrayStart = strings.indexOf(""); - assertTrue(locale + " must keep the media info position list", arrayStart >= 0); - String array = strings.substring(arrayStart, strings.indexOf("", arrayStart)); - int items = array.split("", -1).length - 1; - assertTrue(locale + " media info position list must offer four corners plus Off, was " + items, items == 5); - } - } - - /** - * 需求:「只有全屏播放才显示媒体信息」。徽标开关与全屏判定是两条独立约束: - * Source 需要暴露 isFullscreen(),四个宿主分别接上(两个 VideoActivity 读自己的 fullscreen 字段、 - * 详情页内嵌小窗读 inlineFullscreen),未覆盖的宿主默认 false。 - */ - @Test - public void mediaBadgesOnlyShowInFullscreenPlayback() throws Exception { - String controller = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "custom", "PlayerOsdController.java"))); - int sourceIface = controller.indexOf("public interface Source"); - String sourceBody = controller.substring(sourceIface, controller.indexOf("private static volatile", sourceIface)); - assertTrue("the OSD source must expose the fullscreen state", sourceBody.contains("default boolean isFullscreen()")); - - String host = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "player", "VodPlayerUiHost.java"))); - assertTrue("the shared player host must forward the fullscreen state", host.contains("default boolean isFullscreen()")); - - String ui = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "player", "VodPlayerUiController.java"))); - assertTrue("the OSD controller must be fed the host fullscreen state", - ui.contains("public boolean isFullscreen()") && ui.contains("return host.isFullscreen();")); - - Path videoActivity = Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"); - for (String flavor : new String[]{"mobile", "leanback"}) { - String page = read(sourcePath(flavor, "java").resolve(videoActivity)); - assertTrue(flavor + " 全屏状态必须传给 OSD", page.contains("return VideoActivity.this.isFullscreen();")); - } - - String tmdb = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"))); - assertTrue("详情页内嵌小窗只有真正全屏后才显示媒体信息", tmdb.contains("return inlineFullscreen;")); - } - - /** - * 「媒体信息位置」是总开关,「屏显设置 / 播放控件芯片」只控制控制栏隐藏时是否常驻。 - * 两者必须分开存:关掉屏显常驻时,全屏唤醒控制栏仍应能看到媒体信息。 - */ - @Test - public void mediaInfoMasterSwitchIsIndependentFromThePersistentToggle() throws Exception { - String setting = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "setting", "PlayerSetting.java"))); - assertTrue("the persistent toggle needs its own key", - setting.contains("player_osd_media_badges_persist")); - assertTrue("the persistent toggle must have its own accessors", - setting.contains("isDisplayMediaBadgesPersistent()") && setting.contains("putDisplayMediaBadgesPersistent(")); - - int checked = setting.indexOf("public static boolean[] getDisplayChecked()"); - String checkedExpr = setting.substring(checked, setting.indexOf(';', checked)); - int masterSwitch = checkedExpr.indexOf("isDisplayMediaBadges()"); - int persistentToggle = checkedExpr.indexOf("isDisplayMediaBadgesPersistent()"); - assertTrue("屏显设置第 8 位必须是「媒体信息总开关 AND 常驻开关」:两者以 && 相连且总开关在前," - + "实际 masterSwitch=" + masterSwitch + "、persistentToggle=" + persistentToggle, - masterSwitch >= 0 && persistentToggle >= 0 && masterSwitch < persistentToggle - && checkedExpr.contains("isDisplayMediaBadges() && isDisplayMediaBadgesPersistent()")); - - int putChecked = setting.indexOf("public static void putDisplayChecked(boolean[] checked)"); - String putBody = setting.substring(putChecked, setting.indexOf("\n }", putChecked)); - assertTrue("ticking the display item must also turn the master switch on", - putBody.contains("if (persistent) putDisplayMediaBadges(true)")); - - String dialog = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "dialog", "DisplayDialog.java"))); - assertTrue("the chip must write the persistent toggle", dialog.contains("putDisplayMediaBadgesPersistent(selected)")); - assertTrue("the chip must also enable the master switch when ticked", dialog.contains("if (selected) PlayerSetting.putDisplayMediaBadges(true)")); - - String backup = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "bean", "Backup.java"))); - assertTrue("the persistent toggle must be part of the backup set", backup.contains("player_osd_media_badges_persist")); - } - - /** - * 拆分前 {@code player_osd_media_badges} 就是「常驻显示」的唯一键:老用户升级后新键缺失, - * 必须在读到新键之前把旧键值抄一次,否则常驻会静默降级成「只在控制栏唤醒时出现」。 - * 同时锁住控制栏布局监听必须在 release() 时注销,以及徽标方法对空视图的判空。 - */ - @Test - public void legacyBadgeUpgradeKeepsPersistentDisplayAndReleasesTheBarListener() throws Exception { - String setting = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "setting", "PlayerSetting.java"))); - int persistent = setting.indexOf("public static boolean isDisplayMediaBadgesPersistent()"); - assertTrue("PlayerSetting 缺少 isDisplayMediaBadgesPersistent()", persistent >= 0); - String persistentBody = setting.substring(persistent, setting.indexOf("\n }", persistent)); - int call = persistentBody.indexOf("migrateMediaBadgePersist();"); - String callStatement = call < 0 ? "" : persistentBody.substring(persistentBody.lastIndexOf('\n', call) + 1, persistentBody.indexOf('\n', call)).trim(); - assertTrue("常驻开关的方法体必须真的执行迁移语句,被注释掉或无人调用的迁移都算退回原样", - "migrateMediaBadgePersist();".equals(callStatement)); - - int migrate = setting.indexOf("private static void migrateMediaBadgePersist()"); - assertTrue("PlayerSetting 缺少 migrateMediaBadgePersist()", migrate >= 0); - String migrateBody = setting.substring(migrate, setting.indexOf("\n }", migrate)); - int containsKey = migrateBody.indexOf("Prefers.contains(KEY_DISPLAY_MEDIA_BADGES_PERSIST)"); - int putKey = migrateBody.indexOf("Prefers.put(KEY_DISPLAY_MEDIA_BADGES_PERSIST"); - assertTrue("迁移必须先用 contains 判空再写入,反过来会让每次读取都覆盖用户值", - containsKey > 0 && putKey > containsKey); - assertTrue("迁移必须把旧键值抄到新键", - migrateBody.contains("Prefers.put(KEY_DISPLAY_MEDIA_BADGES_PERSIST, Prefers.getBoolean(KEY_DISPLAY_MEDIA_BADGES))")); - - Path prefers = catvodJavaPath().resolve(Path.of("com", "github", "catvod", "utils", "Prefers.java")); - assertTrue("catvod Prefers 源码路径不存在:" + prefers.toAbsolutePath(), Files.exists(prefers)); - String prefs = read(prefers); - assertTrue("迁移需要「键是否存在」的只读判定", prefs.contains("public static boolean contains(String key)")); - - String controller = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "custom", "PlayerOsdController.java"))); - assertTrue("控制栏布局监听必须可注销", controller.contains("removeOnLayoutChangeListener")); - int release = controller.indexOf("public void release()"); - assertTrue("PlayerOsdController 缺少 release()", release >= 0); - String releaseBody = controller.substring(release, controller.indexOf("\n }", release)); - assertTrue("release() 必须注销控制栏布局监听", releaseBody.contains("stopObservingControlBar()")); - int stopObserving = controller.indexOf("private void stopObservingControlBar()"); - assertTrue("PlayerOsdController 缺少 stopObservingControlBar", stopObserving >= 0); - String stopObservingBody = controller.substring(stopObserving, controller.indexOf("\n }", stopObserving)); - assertTrue("stopObservingControlBar() 必须真正调用 removeOnLayoutChangeListener", - stopObservingBody.contains("removeOnLayoutChangeListener")); - assertTrue("stopObservingControlBar() 必须把两个持有引用清空,否则 OSD 释放后仍被控制栏布局回调持有", - stopObservingBody.contains("observedControlBar = null;") - && stopObservingBody.contains("observedControlBarListener = null;")); - - int firstVisible = controller.indexOf("private static View firstVisible(View... candidates)"); - assertTrue("PlayerOsdController 缺少 firstVisible", firstVisible >= 0); - String firstVisibleBody = controller.substring(firstVisible, controller.indexOf("\n }", firstVisible)); - int visibleLoop = firstVisibleBody.indexOf("for ("); - int visiblePick = firstVisibleBody.indexOf("View.VISIBLE"); - int fallbackLoop = visibleLoop < 0 ? -1 : firstVisibleBody.indexOf("for (", visibleLoop + 1); - assertTrue("firstVisible 必须先挑可见项再退回第一个非 null,否则会选到 GONE 的容器而错误回退 dp(80)", - visibleLoop > 0 && visiblePick > visibleLoop && fallbackLoop > visiblePick); - - int controlBar = controller.indexOf("private View controlBar()"); - assertTrue("PlayerOsdController 缺少 controlBar()", controlBar >= 0); - String controlBarBody = controller.substring(controlBar, controller.indexOf("\n }", controlBar)); - int barControl = controlBarBody.indexOf("R.id.control"); - int barDetail = controlBarBody.indexOf("R.id.detailControlHost"); - int barTv = controlBarBody.indexOf("R.id.playerControls"); - assertTrue("详情页内嵌播放器没有 @+id/control,控制栏候选顺序必须是 control -> detailControlHost -> playerControls", - barControl > 0 && barDetail > barControl && barTv > barDetail); - assertTrue("详情页内嵌播放器的底部条是 @+id/playerBottom,量高度必须二级回退", - controller.contains("findViewById(R.id.bottom)") - && controller.contains("findViewById(R.id.playerBottom)")); - - int apply = controller.indexOf("private void applyMediaBadgePosition()"); - assertTrue("PlayerOsdController 缺少 applyMediaBadgePosition", apply >= 0); - assertTrue("徽标容器缺失时必须先判空再取 LayoutParams", - controller.substring(apply, controller.indexOf("\n }", apply)).contains("if (mediaBadges == null) return;")); - } - - /** - * 手机端竖屏内嵌直播窗格不是全屏:OSD 的 Source 必须覆写 isFullscreen(), - * 否则继承默认 true,媒体徽标会跟着出现在窗格上。电视端直播恒全屏,不需要覆写。 - */ - @Test - public void mobileLiveOsdSourceGatesMediaBadgesOnTheEmbeddedShelf() throws Exception { - Path liveActivity = Path.of("com", "fongmi", "android", "tv", "ui", "activity", "LiveActivity.java"); - - String mobileLive = read(sourcePath("mobile", "java").resolve(liveActivity)); - assertTrue("手机直播的 OSD Source 必须覆写 isFullscreen()", mobileLive.contains("public boolean isFullscreen()")); - assertTrue("手机直播的内嵌窗格与 PiP 小窗都必须被判为非全屏", - mobileLive.contains("return !isEmbeddedLiveUi() && !isInPictureInPictureMode();")); - int overrideAt = mobileLive.indexOf("public boolean isFullscreen()"); - String overrideBody = mobileLive.substring(overrideAt, mobileLive.indexOf("}", overrideAt)); - assertTrue("手机直播的 isFullscreen() 必须排除 PiP:PiP 小窗不算全屏", - overrideBody.contains("!isInPictureInPictureMode()")); - assertTrue("isFullscreen() 覆写必须落在 PlayerOsdController.Source 匿名类体内", - mobileLive.indexOf("public boolean isFullscreen()") > mobileLive.indexOf("new PlayerOsdController.Source()")); - - String leanbackLive = read(sourcePath("leanback", "java").resolve(liveActivity)); - assertFalse("电视直播恒全屏,不应覆写 isFullscreen()", leanbackLive.contains("public boolean isFullscreen()")); - } - - /** catvod 是仓库内的兄弟模块,测试工作目录可能是 app 模块根,也可能是仓库根。 */ - private static Path catvodJavaPath() { - Path moduleSibling = Path.of("..", "catvod", "src", "main", "java"); - if (Files.exists(moduleSibling)) return moduleSibling; - Path repoRelative = Path.of("catvod", "src", "main", "java"); - assertTrue("catvod 源码目录定位失败:既不在 " + moduleSibling.toAbsolutePath() + " 也不在 " + repoRelative.toAbsolutePath(), - Files.exists(repoRelative)); - return repoRelative; + public void ffmpegModeIsVisibleOnlyForExoKernel() throws Exception { + assertFfmpegModeVisibility(read(sourcePath("mobile", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "fragment", "SettingPlayerFragment.java")))); + assertFfmpegModeVisibility(read(sourcePath("leanback", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "SettingPlayerActivity.java")))); } @Test @@ -392,6 +80,26 @@ public class PlayerDisplaySettingSyncTest { assertTrue(source.contains("\"display_title\"")); } + @Test + public void backupIncludesFfmpegModePreference() throws Exception { + String source = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "bean", "Backup.java"))); + assertTrue(source.contains("\"ffmpeg_mode\"")); + } + + private static void assertFfmpegModeVisibility(String source) { + String visibility = "private void setFfmpegModeVisibility()"; + assertTrue(source.contains(visibility)); + assertTrue(source.contains("PlayerSetting.getPlayer() == PlayerSetting.EXO")); + assertTrue(source.contains("mBinding.ffmpegMode.setVisibility(visible ? View.VISIBLE : View.GONE)")); + assertTrue(count(source, "setFfmpegModeVisibility();") >= 2); + } + + private static int count(String source, String token) { + int count = 0; + for (int from = 0; (from = source.indexOf(token, from)) >= 0; from += token.length()) count++; + return count; + } + private static void assertSettingPageUsesDisplayPreferences(String source) { assertTrue(source.contains("PlayerSetting.getDisplayChecked()")); assertTrue(source.contains("PlayerSetting.putDisplayChecked(checked)")); diff --git a/app/src/test/java/com/fongmi/android/tv/setting/PlayerSettingTest.java b/app/src/test/java/com/fongmi/android/tv/setting/PlayerSettingTest.java index 935d2551d4..4aad47659c 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/PlayerSettingTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/PlayerSettingTest.java @@ -102,4 +102,17 @@ public class PlayerSettingTest { assertEquals(PlayerSetting.IJK, PlayerSetting.firstUntriedPlayer(fromExo)); } + @Test + public void sanitizeFFmpegMode_allowsKnownModes() { + assertEquals(PlayerSetting.FFMPEG_MODE_NEXTLIB, PlayerSetting.sanitizeFFmpegMode(PlayerSetting.FFMPEG_MODE_NEXTLIB, PlayerSetting.FFMPEG_MODE_SIMPLE)); + assertEquals(PlayerSetting.FFMPEG_MODE_OFFICIAL, PlayerSetting.sanitizeFFmpegMode(PlayerSetting.FFMPEG_MODE_OFFICIAL, PlayerSetting.FFMPEG_MODE_SIMPLE)); + assertEquals(PlayerSetting.FFMPEG_MODE_SIMPLE, PlayerSetting.sanitizeFFmpegMode(PlayerSetting.FFMPEG_MODE_SIMPLE, PlayerSetting.FFMPEG_MODE_NEXTLIB)); + assertEquals(PlayerSetting.FFMPEG_MODE_AUTO, PlayerSetting.sanitizeFFmpegMode(PlayerSetting.FFMPEG_MODE_AUTO, PlayerSetting.FFMPEG_MODE_SIMPLE)); + } + + @Test + public void sanitizeFFmpegMode_fallsBackForUnknownMode() { + assertEquals(PlayerSetting.FFMPEG_MODE_NEXTLIB, PlayerSetting.sanitizeFFmpegMode(-1, PlayerSetting.FFMPEG_MODE_NEXTLIB)); + assertEquals(PlayerSetting.FFMPEG_MODE_SIMPLE, PlayerSetting.sanitizeFFmpegMode(4, PlayerSetting.FFMPEG_MODE_SIMPLE)); + } } diff --git a/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackDefaultsTest.java b/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackDefaultsTest.java index 7abb7d4705..3056243143 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackDefaultsTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackDefaultsTest.java @@ -170,6 +170,8 @@ public class SettingPlaybackDefaultsTest { String leanbackPlayer = read(root.resolve(Path.of("src", "leanback", "res", "layout", "activity_setting_player.xml"))); for (String id : new String[]{"adblock", "aiAdDetection", "adRuleManage", "adBlockStats", + "adAudioFingerprint", "adAudioAutoSkip", "probeRuleSource", "probeRuleRefresh", + "speechAdEnabled", "speechAdKeywords", "speechAdSkipSeconds", "speechAdSkipMode", "autoSkipIntroOutro"}) { assertTrue(id, mobileAd.contains("@+id/" + id)); assertTrue(id, leanbackAd.contains("@+id/" + id)); diff --git a/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackOverlayTest.java b/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackOverlayTest.java deleted file mode 100644 index 98bb7613c9..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackOverlayTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.fongmi.android.tv.setting; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class SettingPlaybackOverlayTest { - - @Test - public void playbackOverlayDefaultsOnAndPersistsChanges() throws Exception { - Path root = moduleRoot(); - String settingSource = read(root.resolve(Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "setting", "Setting.java"))); - - assertTrue(settingSource.contains("Prefers.getBoolean(\"playback_overlay_enabled\", true)")); - assertTrue(settingSource.contains("Prefers.put(\"playback_overlay_enabled\", enabled)")); - } - - @Test - public void mobileSettingsAndPlayersSharePlaybackOverlayPreference() throws Exception { - Path root = moduleRoot(); - String personalLayout = read(root.resolve(Path.of("src", "mobile", "res", "layout", "fragment_setting_personal.xml"))); - String personalSource = read(root.resolve(Path.of("src", "mobile", "java", "com", "fongmi", "android", "tv", "ui", "fragment", "SettingPersonalFragment.java"))); - String videoSource = read(root.resolve(Path.of("src", "mobile", "java", "com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"))); - String liveSource = read(root.resolve(Path.of("src", "mobile", "java", "com", "fongmi", "android", "tv", "ui", "activity", "LiveActivity.java"))); - - assertTrue(personalLayout.contains("@+id/playbackOverlay")); - assertTrue(personalLayout.contains("@string/setting_playback_overlay")); - assertTrue(personalSource.contains("mBinding.playbackOverlay.setOnClickListener(this::setPlaybackOverlay)")); - assertTrue(personalSource.contains("Setting.putPlaybackOverlayEnabled(!Setting.isPlaybackOverlayEnabled())")); - assertPlayerAppliesOverlay(videoSource); - assertPlayerAppliesOverlay(liveSource); - - for (String values : new String[]{"values", "values-zh-rCN", "values-zh-rTW"}) { - String strings = read(root.resolve(Path.of("src", "main", "res", values, "strings.xml"))); - assertTrue(strings.contains("")); - } - } - - @Test - public void leanbackSettingsAndPlayersSharePlaybackOverlayPreference() throws Exception { - Path root = moduleRoot(); - String personalLayout = read(root.resolve(Path.of("src", "leanback", "res", "layout", "activity_setting_personal.xml"))); - String personalSource = read(root.resolve(Path.of("src", "leanback", "java", "com", "fongmi", "android", "tv", "ui", "activity", "SettingPersonalActivity.java"))); - String videoSource = read(root.resolve(Path.of("src", "leanback", "java", "com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"))); - String liveSource = read(root.resolve(Path.of("src", "leanback", "java", "com", "fongmi", "android", "tv", "ui", "activity", "LiveActivity.java"))); - - assertTrue(personalLayout.contains("@+id/playbackOverlay")); - assertTrue(personalLayout.contains("@string/setting_playback_overlay")); - assertTrue(personalSource.contains("mBinding.playbackOverlay.setOnClickListener(this::setPlaybackOverlay)")); - assertTrue(personalSource.contains("Setting.putPlaybackOverlayEnabled(!Setting.isPlaybackOverlayEnabled())")); - assertLeanbackPlayerAppliesOverlay(videoSource); - assertLeanbackPlayerAppliesOverlay(liveSource); - - for (String name : new String[]{"view_control_vod.xml", "view_control_live.xml", "view_control_cast.xml"}) { - String layout = read(root.resolve(Path.of("src", "leanback", "res", "layout", name))); - assertTrue(layout.contains("android:background=\"@color/transparent\"")); - } - } - - private static void assertLeanbackPlayerAppliesOverlay(String source) { - assertTrue(source.contains("applyPlaybackOverlay();")); - assertTrue(source.contains("mBinding.control.getRoot().setBackground(PlaybackMenuScrim.create(this))")); - assertTrue(source.contains("mBinding.control.getRoot().setBackgroundResource(R.color.transparent)")); - } - - private static void assertPlayerAppliesOverlay(String source) { - assertTrue(source.contains("mBinding.control.getRoot().setBackgroundResource(R.color.transparent)")); - assertTrue(source.contains("mBinding.control.bottom.setBackground(PlaybackMenuScrim.create(this))")); - assertTrue(source.contains("mBinding.control.bottom.setBackgroundResource(R.color.transparent)")); - } - - private static String read(Path path) throws Exception { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - private static Path sourcePath() { - Path moduleRelative = Path.of("src", "main", "java"); - if (Files.exists(moduleRelative)) return moduleRelative; - return Path.of("app", "src", "main", "java"); - } - - private static Path moduleRoot() { - Path moduleRelative = Path.of("src", "main", "java"); - if (Files.exists(moduleRelative)) return Path.of("."); - return Path.of("app"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/SettingTmdbSourceOnlyWiringTest.java b/app/src/test/java/com/fongmi/android/tv/setting/SettingTmdbSourceOnlyWiringTest.java deleted file mode 100644 index 93a027ef7f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/SettingTmdbSourceOnlyWiringTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.fongmi.android.tv.setting; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class SettingTmdbSourceOnlyWiringTest { - - @Test - public void configuredModeDoesNotRequireOnlineTmdbKey() throws Exception { - String source = readMain(Path.of("com", "fongmi", "android", "tv", "setting", "Setting.java")); - int methodStart = source.indexOf("public static int getDetailOpenMode()"); - int methodEnd = source.indexOf("public static void putDetailOpenMode", methodStart); - String method = source.substring(methodStart, methodEnd); - - assertTrue(method.contains("return mode;")); - assertFalse(method.contains("isTmdbReady()")); - assertTrue(source.contains("public static boolean isTmdbDetailModeConfigured()") - && source.contains("return isTmdbMode(getDetailOpenMode()) && getTmdbModel() == TMDB_MODEL_NATIVE;")); - assertTrue(source.contains("public static boolean isTmdbDetailPage()") - && source.contains("TmdbConfig.objectFrom(getTmdbConfig()).isReady()")); - } - - @Test - public void mobileSettingsSavesTmdbModeWithoutKeyPrompt() throws Exception { - String source = readFlavor("mobile", Path.of("com", "fongmi", "android", "tv", "ui", "fragment", "SettingTmdbFragment.java")); - String method = method(source, "private void setDetailOpenMode(View view)", "private int getDetailOpenModeIndex()"); - - assertTrue(method.contains("Setting.putDetailOpenMode(mode);")); - assertFalse(method.contains("isTmdbReady()")); - assertFalse(method.contains("detail_tmdb_need_key")); - assertFalse(method.contains("TmdbSourceDialog")); - } - - @Test - public void leanbackSettingsSavesTmdbModeWithoutKeyPrompt() throws Exception { - String source = readFlavor("leanback", Path.of("com", "fongmi", "android", "tv", "ui", "activity", "SettingTmdbActivity.java")); - String method = method(source, "private void setDetailOpenMode(View view)", "private int getDetailOpenModeIndex()"); - - assertTrue(method.contains("Setting.putDetailOpenMode(mode);")); - assertFalse(method.contains("isTmdbReady()")); - assertFalse(method.contains("detail_tmdb_need_key")); - assertFalse(method.contains("TmdbSourceDialog")); - } - - private static String method(String source, String startMarker, String endMarker) { - int start = source.indexOf(startMarker); - int end = source.indexOf(endMarker, start); - assertTrue(start >= 0 && end > start); - return source.substring(start, end); - } - - private static String readMain(Path relative) throws Exception { - return Files.readString(root().resolve("app").resolve("src").resolve("main").resolve("java").resolve(relative), StandardCharsets.UTF_8); - } - - private static String readFlavor(String flavor, Path relative) throws Exception { - return Files.readString(root().resolve("app").resolve("src").resolve(flavor).resolve("java").resolve(relative), StandardCharsets.UTF_8); - } - - private static Path root() { - return Files.exists(Path.of("app", "src", "main")) ? Path.of(".") : Path.of(".."); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/SiteHealthReportSourceTest.java b/app/src/test/java/com/fongmi/android/tv/setting/SiteHealthReportSourceTest.java index a38ffe6a3a..1b8281f21a 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/SiteHealthReportSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/SiteHealthReportSourceTest.java @@ -6,7 +6,6 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -16,13 +15,6 @@ public class SiteHealthReportSourceTest { public void siteHealthStoreExposesFourStageReportWithoutChangingSortScore() throws Exception { String source = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "setting", "SiteHealthStore.java"))); - assertTrue(source.contains("public static void recordHome")); - assertTrue(source.contains("public static void recordCategory")); - String api = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "api", "SiteApi.java"))); - assertTrue(api.contains("SiteHealthStore.recordHome(site, true")); - assertTrue(api.contains("SiteHealthStore.recordHome(site, false")); - assertTrue(api.contains("SiteHealthStore.recordCategory(key, true")); - assertTrue(api.contains("SiteHealthStore.recordCategory(key, false")); assertTrue(source.contains("public static void recordSearch")); assertTrue(source.contains("public static void recordDetail")); assertTrue(source.contains("public static void recordParse")); @@ -37,9 +29,6 @@ public class SiteHealthReportSourceTest { assertTrue(source.contains("detailReasons")); assertTrue(source.contains("parseReasons")); assertTrue(source.contains("playReasons")); - assertTrue(source.contains("AdBlockStatsStore.getStats()")); - assertTrue(source.contains("public final long adBlockedTotal")); - assertTrue(source.contains("public final Map adBlockedByPipeline")); String score = methodBody(source, "private double score()"); assertFalse("Sort score should not depend on parse metrics in the report-only slice", score.contains("parseSuccess")); @@ -74,44 +63,13 @@ public class SiteHealthReportSourceTest { assertTrue(reportSource.contains("binding.sortRecent.setOnClickListener")); assertTrue(reportSource.contains("binding.sortRate.setOnClickListener")); assertTrue(reportSource.contains("binding.sortSamples.setOnClickListener")); - assertTrue(reportSource.contains("binding.clearAll.setOnClickListener")); assertTrue(reportSource.contains("root.setOnClickListener")); assertTrue(reportSource.contains("reasonLabel(")); assertTrue(reportSource.contains("recentErrors(")); assertTrue(reportSource.contains("confirmClearSite(")); assertTrue(reportSource.contains("SiteHealthStore.clear(row.siteKey)")); - assertTrue(reportSource.contains("confirmClearAll()")); - assertTrue(reportSource.contains("SiteHealthStore.clear()")); - String m3u8Source = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "server", "process", "M3u8.java"))); - String mpvProxySource = read(mainJavaPath().resolve(Path.of("androidx", "media3", "mpvplayer", "MpvHlsProxy.java"))); - assertTrue(m3u8Source.contains("AdBlockStatsStore.recordBlocks(")); - assertTrue(m3u8Source.contains("clean.ruleCounts()")); - assertTrue(m3u8Source.contains("HlsAdblockNotice.shouldNotify(")); - assertTrue(mpvProxySource.contains("AdBlockStatsStore.recordBlocks(")); - assertTrue(mpvProxySource.contains("HlsAdblockNotice.shouldNotify(")); - assertTrue(mpvProxySource.contains("Notify.show(")); - assertTrue(m3u8Source.contains("Notify.show(")); - String statsStoreSource = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "api", "config", "AdBlockStatsStore.java"))); - assertTrue(statsStoreSource.contains("HlsRuleConfig.getEntries()")); - assertTrue(statsStoreSource.contains("hls.legacy-fallback")); - assertTrue(statsStoreSource.contains("内置兜底规则")); - String clearConfirmationBody = methodBody(reportSource, "private void showClearConfirmation("); - assertTrue(clearConfirmationBody.contains("R.style.Theme_WebHTV_Dialog")); - assertTrue(clearConfirmationBody.contains("LightDialog.apply(dialog)")); - String refreshBody = methodBody(reportSource, "private void refreshReport()"); - assertTrue(refreshBody.indexOf("report = SiteHealthStore.report()") < refreshBody.indexOf("render()")); - assertTrue(methodBody(reportSource, "private void confirmClearSite(").contains("binding.root.post(this::refreshReport)")); - assertTrue(methodBody(reportSource, "private void confirmClearAll()").contains("binding.root.post(this::refreshReport)")); assertTrue(dialogLayout.contains("@+id/report")); assertTrue(dialogLayout.contains("@string/site_health_report_view")); - assertTrue(reportLayout.contains("@+id/search")); - assertTrue(reportLayout.contains("@string/site_health_report_search_hint")); - assertTrue(reportSource.contains("binding.search.addTextChangedListener")); - assertTrue(reportSource.contains("row.siteName.toLowerCase(Locale.ROOT).contains(query)")); - assertTrue(reportSource.contains("R.string.site_health_stage_home, row.home")); - assertTrue(reportSource.contains("R.string.site_health_stage_category, row.category")); - assertTrue(reportSource.contains("row.home.lastFailAt")); - assertTrue(reportSource.contains("row.category.lastFailAt")); assertTrue(reportLayout.contains("@+id/filterAll")); assertTrue(reportLayout.contains("@+id/filterBad")); assertTrue(reportLayout.contains("@+id/filterWarn")); @@ -119,135 +77,16 @@ public class SiteHealthReportSourceTest { assertTrue(reportLayout.contains("@+id/sortRecent")); assertTrue(reportLayout.contains("@+id/sortRate")); assertTrue(reportLayout.contains("@+id/sortSamples")); - assertTrue(reportLayout.contains("@+id/clearAll")); assertTrue(reportLayout.contains("@+id/rows")); assertTrue(strings.contains("name=\"site_health_report_title\"")); assertTrue(strings.contains("name=\"site_health_filter_all\"")); assertTrue(strings.contains("name=\"site_health_sort_failures\"")); assertTrue(strings.contains("name=\"site_health_report_recent_errors\"")); assertTrue(strings.contains("name=\"site_health_clear_site\"")); - assertTrue(strings.contains("name=\"site_health_clear_all\"")); - assertTrue(strings.contains("name=\"site_health_clear_all_title\"")); - assertTrue(strings.contains("name=\"site_health_clear_all_message\"")); assertTrue(strings.contains("name=\"site_health_reason_timeout\"")); assertTrue(strings.contains("name=\"site_health_stage_parse\"")); } - @Test - public void healthReportUsesAdStatsStyleNearFullScreenSizing() throws Exception { - String dialog = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "dialog", "SiteHealthReportDialog.java"))); - - assertTrue(dialog.contains("int margin = ResUtil.dp2px(ResUtil.isLand(requireContext()) ? 24 : 16);")); - // The window is sized from the system-bar-safe area, not from the raw display: sizing from - // the raw display put this dialog's cancel button under the navigation bar on phones. - assertTrue(dialog.contains("DialogSafeArea.area(requireActivity(), window)")); - assertTrue(dialog.contains("ThemeDialogLayout.width(area.width(), margin)")); - assertTrue(dialog.contains("ThemeDialogLayout.height(area.height(), margin)")); - assertTrue(dialog.contains("window.getDecorView().setPadding(0, 0, 0, 0)")); - assertTrue(!dialog.contains("ResUtil.getScreenWidth(requireContext()) * (ResUtil.isLand(requireContext()) ? 0.62f : 0.94f)")); - assertTrue(!dialog.contains("ResUtil.getScreenHeight(requireContext()) * (ResUtil.isLand(requireContext()) ? 0.78f : 0.82f)")); - } - - @Test - public void healthReportExposesSiteRuleAndPipelineAdDimensions() throws Exception { - String store = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "setting", "SiteHealthStore.java"))); - String dialog = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "dialog", "SiteHealthReportDialog.java"))); - - assertTrue(store.contains("public final Map adBlockedBySite;")); - assertTrue(store.contains("public final Map adBlockedByRule;")); - assertTrue(store.contains("public final Map adBlockedByPipeline;")); - assertTrue(dialog.contains("R.string.ad_site_rank")); - assertTrue(dialog.contains("R.string.ad_rule_rank")); - assertTrue(dialog.contains("R.string.ad_pipeline_rank")); - assertTrue(dialog.contains("int playCount = row.playAttempts")); - assertTrue(dialog.contains("site_health_report_ad_blocked, playCount, blocked")); - } - - @Test - public void playbackActivityBridgesExoFirstFrameToGenericFirstFrameHook() throws Exception { - String playback = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "PlaybackActivity.java"))); - String exoFirstFrame = methodBody(playback, "public void onExoFirstFrame()"); - - assertTrue(exoFirstFrame.contains("PlaybackActivity.this.onExoFirstFrame()")); - assertTrue(exoFirstFrame.contains("PlaybackActivity.this.onFirstFrameRendered()")); - } - - @Test - public void playbackHealthCountsSuccessOnlyAfterFirstFrame() throws Exception { - String leanback = read(sourcePath("leanback", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"))); - String mobile = read(sourcePath("mobile", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"))); - - for (String source : new String[] {leanback, mobile}) { - String firstFrame = methodBody(source, "protected void onFirstFrameRendered()"); - String stateChanged = methodBody(source, "protected void onStateChanged(int state)"); - assertTrue(firstFrame.contains("recordPlayHealth(true, \"\")")); - assertFalse(stateChanged.contains("recordPlayHealth(true, \"\")")); - } - } - - @Test - public void playbackHealthRecordsTerminalErrorsAndReloadFailures() throws Exception { - String leanback = read(sourcePath("leanback", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"))); - String mobile = read(sourcePath("mobile", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"))); - - for (String source : new String[] {leanback, mobile}) { - String error = methodBody(source, "protected void onError(String msg)"); - String reload = methodBody(source, "protected void onReload(String msg)"); - assertTrue(error.contains("recordPlayHealth(false, msg)")); - assertTrue(reload.contains("recordPlayHealth(false, msg)")); - } - } - - @Test - public void terminalPlayerFailureReachesHostAfterFallbacksAreExhausted() throws Exception { - String player = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "player", "PlayerManager.java"))); - String onPlayerError = methodBody(player, "public void onPlayerError(@NonNull PlaybackException e)"); - - int fallback = onPlayerError.indexOf("if (fallbackPlayback(e)) return;"); - int terminalCallback = onPlayerError.indexOf("callback.onError(getPlaybackErrorMessage(failure))", fallback); - assertTrue("fallback handling is missing", fallback >= 0); - assertTrue("terminal errors must reach the host after fallback exhaustion", terminalCallback > fallback); - } - - @Test - public void playbackAttemptsUseIndependentCounterForAdBlockRatio() throws Exception { - String store = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "setting", "SiteHealthStore.java"))); - String dialog = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "dialog", "SiteHealthReportDialog.java"))); - String leanback = read(sourcePath("leanback", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"))); - String mobile = read(sourcePath("mobile", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java"))); - - assertTrue(store.contains("public static void recordPlayAttempt(String key)")); - assertTrue(store.contains("public final int playAttempts")); - assertTrue(dialog.contains("int playCount = row.playAttempts")); - for (String source : new String[] {leanback, mobile}) { - String begin = methodBody(source, "private void beginPlayHealth()"); - assertTrue(begin.contains("SiteHealthStore.recordPlayAttempt(playHealthKey)")); - assertEquals(1, count(begin, "recordPlayAttempt(")); - } - } - - @Test - public void tmdbDetailInlinePlaybackRecordsAttemptsAndTerminalHealth() throws Exception { - String source = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"))); - String start = methodBody(source, "private void startInlinePlayer(Result result, long resumePosition)"); - String firstFrame = methodBody(source, "protected void onFirstFrameRendered()"); - String error = methodBody(source, "protected void onError(String msg)"); - String recorder = methodBody(source, "private void recordInlinePlayHealth(boolean success, String error)"); - - assertTrue(start.contains("SiteHealthStore.recordPlayAttempt(inlinePlayHealthKey)")); - assertEquals(1, count(start, "recordPlayAttempt(")); - assertTrue(firstFrame.contains("recordInlinePlayHealth(true, \"\")")); - assertTrue(error.contains("recordInlinePlayHealth(false, msg)")); - assertTrue(recorder.contains("if (inlinePlayHealthRecorded) return")); - assertTrue(recorder.contains("SiteHealthStore.recordPlay(")); - } - - private static int count(String source, String needle) { - int count = 0; - for (int offset = 0; (offset = source.indexOf(needle, offset)) >= 0; offset += needle.length()) count++; - return count; - } - private static String methodBody(String source, String signature) { int start = source.indexOf(signature); assertTrue(signature + " is missing", start >= 0); diff --git a/app/src/test/java/com/fongmi/android/tv/setting/UiScaleContractTest.java b/app/src/test/java/com/fongmi/android/tv/setting/UiScaleContractTest.java deleted file mode 100644 index ad8808a664..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/UiScaleContractTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.fongmi.android.tv.setting; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -public class UiScaleContractTest { - - @Test - public void scaleOptionsSupportCompactStandardAndLargerTouchLayouts() throws Exception { - String setting = read("app/src/main/java/com/fongmi/android/tv/setting/Setting.java"); - assertTrue(setting.contains("UI_SCALE_SMALLER -> 0.8f")); - assertTrue(setting.contains("UI_SCALE_STANDARD -> 1.0f")); - assertTrue(setting.contains("UI_SCALE_LARGE -> 1.1f")); - assertTrue(setting.contains("UI_SCALE_LARGER -> 1.2f")); - assertTrue(setting.contains("UI_SCALE_LARGEST -> 1.3f")); - assertTrue(setting.contains("int baseDensity = metrics.densityDpi;")); - assertFalse(setting.contains("stableDensity * factor")); - assertTrue(setting.contains("UI_SCALE_SMALLER, UI_SCALE_MORE_COMPACT")); - assertTrue(setting.contains("UI_SCALE_STANDARD, UI_SCALE_LARGE, UI_SCALE_LARGER, UI_SCALE_LARGEST")); - } - - @Test - public void everyLocaleExposesTheSameNineUiScaleChoices() throws Exception { - assertScaleArray(read("app/src/main/res/values/strings.xml"), "Follow system", "Largest"); - assertScaleArray(read("app/src/main/res/values-zh-rCN/strings.xml"), "跟随系统", "特大"); - assertScaleArray(read("app/src/main/res/values-zh-rTW/strings.xml"), "跟隨系統", "特大"); - } - - private static void assertScaleArray(String xml, String first, String last) { - int start = xml.indexOf(""); - int end = xml.indexOf("", start); - String array = xml.substring(start, end); - assertTrue(array.contains("" + first + "")); - assertTrue(array.contains("" + last + "")); - assertTrue(array.split("", -1).length - 1 == 9); - } - - private static String read(String name) throws Exception { - Path path = Path.of(name); - if (!Files.exists(path)) path = Path.of("..").resolve(name).normalize(); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/WallRestorePreferenceTest.java b/app/src/test/java/com/fongmi/android/tv/setting/WallRestorePreferenceTest.java deleted file mode 100644 index 0ea6dc67da..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/setting/WallRestorePreferenceTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.fongmi.android.tv.setting; - -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -public class WallRestorePreferenceTest { - @Test - public void appBackupRestoreDoesNotOverwriteSelectedBuiltinWall() throws Exception { - Path root = Path.of("src/main/java/com/fongmi/android/tv"); - String config = read(root, "api/config", "WallConfig.java"); - assertTrue(config.contains("initPreservingSelection()")); - assertTrue(config.contains("preserveSelection")); - assertTrue(config.contains("if (!preserveSelection) {")); - assertTrue(config.contains("Setting.putWall(0);")); - assertTrue(config.contains("Setting.putWallType(type);")); - - String backup = read(root, "utils/AppBackup.java"); - assertTrue(backup.contains("WallConfig.get().initPreservingSelection().load();")); - - String selective = read(root, "bean/Backup.java"); - assertTrue(selective.contains("WallConfig.get().initPreservingSelection().load();")); - } - - private static String read(Path root, String... segments) throws Exception { - return new String(Files.readAllBytes(root.resolve(String.join("/", segments))), - StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizerTest.java b/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizerTest.java index 7092ecc7be..a45fc03ca2 100644 --- a/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizerTest.java +++ b/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizerTest.java @@ -6,12 +6,6 @@ import static org.junit.Assert.assertTrue; import org.junit.Test; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - public class RealtimeSubtitleRecognizerTest { @Test @@ -37,45 +31,4 @@ public class RealtimeSubtitleRecognizerTest { assertEquals(35_200, RealtimeSubtitleRecognizer.offlineFlushSamples(RealtimeSubtitleModelCatalog.find("yue"))); assertEquals(35_200, RealtimeSubtitleRecognizer.offlineFlushSamples(RealtimeSubtitleModelCatalog.find("ja"))); } - @Test(timeout = 10_000) - public void interruptedReleaseWaitsForTheActualDecodeWorkerToExit() throws Exception { - ExecutorService decode = Executors.newSingleThreadExecutor(); - CountDownLatch entered = new CountDownLatch(1); - CountDownLatch allowDecode = new CountDownLatch(1); - CountDownLatch waitEntered = new CountDownLatch(1); - AtomicBoolean released = new AtomicBoolean(); - AtomicBoolean interruptPreserved = new AtomicBoolean(); - decode.execute(() -> { - entered.countDown(); - try { - allowDecode.await(); - } catch (InterruptedException error) { - throw new AssertionError("decode must not be interrupted to fake completion", error); - } - }); - Thread owner = new Thread(() -> { - Thread.currentThread().interrupt(); - waitEntered.countDown(); - RealtimeSubtitleRecognizer.awaitRecognitionTermination(decode); - released.set(true); - interruptPreserved.set(Thread.currentThread().isInterrupted()); - }); - try { - assertTrue(entered.await(2, TimeUnit.SECONDS)); - owner.start(); - assertTrue(waitEntered.await(2, TimeUnit.SECONDS)); - assertFalse(released.get()); // JNI is still blocked, even though owner was interrupted. - allowDecode.countDown(); - owner.join(2_000L); - assertFalse(owner.isAlive()); - assertTrue(decode.isTerminated()); - assertTrue(released.get()); - assertTrue(interruptPreserved.get()); - } finally { - allowDecode.countDown(); - decode.shutdown(); - owner.join(2_000L); - } - } - } diff --git a/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactoryTest.java b/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactoryTest.java new file mode 100644 index 0000000000..491bc5005a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactoryTest.java @@ -0,0 +1,28 @@ +package com.fongmi.android.tv.subtitle; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class RealtimeSubtitleSpeechRecognitionFactoryTest { + + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void missingModelIsReportedWithoutCreatingRecognizer() throws Exception { + RealtimeSubtitleSpeechRecognitionFactory factory = + RealtimeSubtitleSpeechRecognitionFactory.forRoot(temporary.newFolder()); + + assertFalse(factory.isReady()); + } + + @Test + public void contractExposesResetAcceptAndClose() { + assertNotNull(SpeechRecognitionFactory.Session.class); + assertNotNull(SpeechRecognitionFactory.Listener.class); + } +} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/subtitle/provider/SubtitleProviderRegistryLazyInitTest.java b/app/src/test/java/com/fongmi/android/tv/subtitle/provider/SubtitleProviderRegistryLazyInitTest.java deleted file mode 100644 index c001faf3b3..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/subtitle/provider/SubtitleProviderRegistryLazyInitTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.fongmi.android.tv.subtitle.provider; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * 字幕提供方注册表的首次初始化会同步启动 CPython 并导入 webhtv_logging / app 模块 - * (设备实测约 0.9s,debuggerd 抓栈显示主线程正停在 libpython3.10.so)。 - * 这份成本过去由 SubtitleMatchService 的默认构造器 eager 触发,而该构造器处在 - * VideoActivity / TmdbDetailActivity -> SubtitlePlaybackSession -> DefaultController - * 的字段初始化链上,于是每次冷启动播放页都要在首帧窗口里付掉它。 - * 这些测试锁住「注册表延迟到后台线程解析」这条约束,同时保证真正的工作没被删掉。 - */ -public class SubtitleProviderRegistryLazyInitTest { - - @Test - public void theDefaultConstructorNoLongerResolvesTheRegistryEagerly() throws Exception { - String body = methodBody(readSource(), "public SubtitleMatchService()"); - assertFalse("the default constructor must not touch the registry singleton", - body.contains("SubtitleProviderRegistry.get()")); - assertTrue("the registry must start unresolved and be injected later", - body.contains("null,")); - } - - @Test - public void everyProviderUseGoesThroughTheLazyAccessor() throws Exception { - String source = readSource(); - String accessor = methodBody(source, "private SubtitleProviderRegistry registry()"); - assertTrue("the lazy accessor must resolve the singleton", accessor.contains("SubtitleProviderRegistry.get()")); - assertTrue("the lazy accessor must double check under a lock", accessor.contains("synchronized (this)")); - assertTrue("the cached field must be safe to publish across threads", - source.contains("private volatile SubtitleProviderRegistry registry;")); - String withoutAccessorCalls = source.replace("registry()", ""); - assertFalse("no call site may read the raw field any more", - withoutAccessorCalls.contains("registry.")); - } - - @Test - public void providerAvailabilityChecksMovedIntoTheBackgroundTasks() throws Exception { - String source = readSource(); - String autoMatch = methodBody(source, "public void autoMatch(SubtitleRequest request, Listener listener)"); - assertFalse("autoMatch runs on the scheduler thread, so it must not probe the registry", - autoMatch.contains("enabledProviders()")); - assertTrue("the probe must move into the submitted background task", - methodBody(source, "private void runAutoMatch(").contains("registry().enabledProviders()")); - String manualSearch = methodBody(source, "public void manualSearch(SubtitleRequest request, String keyword, Listener listener)"); - int submit = manualSearch.indexOf("Task.searchExecutor().submit("); - int probe = manualSearch.indexOf("registry().enabledProviders()"); - assertTrue("manualSearch must submit its task before probing providers", submit >= 0 && probe > submit); - } - - @Test - public void theInjectedConstructorStillAcceptsAnExplicitRegistry() throws Exception { - String body = methodBody(readSource(), "SubtitleMatchService(SubtitleContextBuilder contextBuilder"); - assertTrue("tests and callers must still be able to inject a registry", body.contains("this.registry = registry;")); - } - - private static String readSource() throws Exception { - Path moduleRelative = Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "subtitle", "SubtitleMatchService.java"); - Path path = Files.exists(moduleRelative) ? moduleRelative : Path.of("app").resolve(moduleRelative); - return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("source is missing " + signature, start >= 0); - int open = source.indexOf('{', start + signature.length()); - assertTrue("source is missing the body of " + signature, open > start); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char current = source.charAt(i); - if (current == '{') depth++; - else if (current == '}' && --depth == 0) return source.substring(start, i + 1); - } - throw new AssertionError("unclosed body for " + signature); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleProtocolMapperTest.java b/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleProtocolMapperTest.java deleted file mode 100644 index ee6ba62201..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleProtocolMapperTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.subtitle.model.SubtitleAsset; -import com.fongmi.android.tv.subtitle.model.SubtitleCandidate; -import com.fongmi.android.tv.subtitle.model.SubtitleContext; -import com.fongmi.android.tv.subtitle.model.SubtitleQuery; -import com.fongmi.android.tv.subtitle.model.SubtitleQuerySource; -import com.fongmi.android.tv.subtitle.model.SubtitleStrictness; -import com.google.gson.Gson; -import com.google.gson.JsonObject; - -import org.junit.Test; - -import java.util.List; - -public class SubtitleProtocolMapperTest { - - private final SubtitleSourceConfig source = new Gson().fromJson("{\"key\":\"test_py\",\"type\":3,\"api\":\"py\",\"ext\":\"test.py\"}", SubtitleSourceConfig.class); - private final SubtitleQuery query = new SubtitleQuery("Movie", "movie", "zh", SubtitleQuerySource.SOURCE_TITLE, SubtitleStrictness.NORMAL, 2024, 1, 2); - - @Test - public void createsSearchRequestWithSourceQueryAndContext() { - JsonObject request = new Gson().fromJson(SubtitleProtocolMapper.createSearchRequest(source, query, - SubtitleContext.builder().canonicalTitle("Movie").mediaPath("/cache/movie.mkv").year(2024).seasonNumber(1).episodeNumber(2).build()), JsonObject.class); - assertEquals("search", request.get("action").getAsString()); - assertEquals("test_py", request.getAsJsonObject("source").get("key").getAsString()); - assertEquals("movie", request.getAsJsonObject("query").get("text").getAsString()); - assertEquals(2024, request.getAsJsonObject("context").get("year").getAsInt()); - assertEquals("/cache/movie.mkv", request.getAsJsonObject("context").get("mediaPath").getAsString()); - } - - @Test - public void mapsCandidatesAndResolveAsset() { - List candidates = SubtitleProtocolMapper.parseCandidates(source, query, - "{\"code\":0,\"data\":{\"items\":[{\"id\":\"42\",\"name\":\"Movie.ass\",\"language\":\"zh\",\"format\":\"ass\",\"score\":90,\"requiresResolve\":true,\"payload\":{\"fileId\":42}}]}}"); - assertEquals(1, candidates.size()); - assertEquals("test_py", candidates.get(0).getProvider()); - assertEquals("42", candidates.get(0).getCandidateId()); - assertTrue(candidates.get(0).isRequiresResolve()); - SubtitleAsset asset = SubtitleProtocolMapper.parseAsset(source, candidates.get(0), - "{\"code\":0,\"data\":{\"url\":\"https://example.com/42.ass\",\"fileName\":\"Movie.ass\",\"language\":\"zh\",\"format\":\"ass\"}}"); - assertEquals("https://example.com/42.ass", asset.getUri()); - assertEquals("Movie.ass", asset.getDisplayName()); - } - - @Test - public void rejectsProtocolErrors() { - IllegalStateException error = assertThrows(IllegalStateException.class, - () -> SubtitleProtocolMapper.requireSuccess("{\"code\":7,\"message\":\"denied\"}")); - assertEquals("denied", error.getMessage()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceEnvironmentTest.java b/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceEnvironmentTest.java deleted file mode 100644 index a7e8701d38..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceEnvironmentTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import static org.junit.Assert.assertEquals; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import org.junit.Test; - -public class SubtitleSourceEnvironmentTest { - - @Test - public void resolvesWhitelistedPlaceholderWithoutMutatingManifestParams() { - JsonObject params = object("{\"token\":\"${ASSRT_TOKEN}\",\"literal\":\"keep\"}"); - JsonObject environment = object("{\"ASSRT_TOKEN\":\"secret\"}"); - - JsonObject resolved = SubtitleSourceEnvironment.resolve(params, environment); - - assertEquals("secret", resolved.get("token").getAsString()); - assertEquals("keep", resolved.get("literal").getAsString()); - assertEquals("${ASSRT_TOKEN}", params.get("token").getAsString()); - } - - @Test - public void classInitializationAndPlaceholderParsingDoNotDependOnPlatformRegex() { - JsonObject params = object("{\"token\":\"${ASSRT_TOKEN}\"}"); - JsonObject environment = object("{\"ASSRT_TOKEN\":\"secret\"}"); - - assertEquals("secret", SubtitleSourceEnvironment.resolve(params, environment).get("token").getAsString()); - } - - @Test - public void replacesMissingPlaceholderWithEmptyStringAndIgnoresInvalidNames() { - JsonObject params = object("{\"missing\":\"${MISSING_TOKEN}\",\"invalid\":\"${lowercase}\"}"); - - JsonObject resolved = SubtitleSourceEnvironment.resolve(params, new JsonObject()); - - assertEquals("", resolved.get("missing").getAsString()); - assertEquals("${lowercase}", resolved.get("invalid").getAsString()); - } - - @Test - public void resolvingAgainFromTemplateUsesLatestEnvironment() { - JsonObject template = object("{\"token\":\"${ASSRT_TOKEN}\"}"); - - assertEquals("first", SubtitleSourceEnvironment.resolve(template, object("{\"ASSRT_TOKEN\":\"first\"}")).get("token").getAsString()); - assertEquals("second", SubtitleSourceEnvironment.resolve(template, object("{\"ASSRT_TOKEN\":\"second\"}")).get("token").getAsString()); - assertEquals("${ASSRT_TOKEN}", template.get("token").getAsString()); - } - - private static JsonObject object(String json) { - return JsonParser.parseString(json).getAsJsonObject(); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceManagerTest.java b/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceManagerTest.java deleted file mode 100644 index a5efc45ffb..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleSourceManagerTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.subtitle.provider.SubtitleProviderRegistry; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; - -public class SubtitleSourceManagerTest { - - @Test - public void replacesAndRemovesPreviouslyRegisteredProviders() { - RecordingRuntime runtime = new RecordingRuntime(); - SubtitleProviderRegistry registry = new SubtitleProviderRegistry(); - SubtitleSourceManager manager = new SubtitleSourceManager(registry, new SubtitleScriptRuntimeFactory(runtime)); - - manager.install("demo", subscription("first", "first.py"), "https://example.com/subtitles/manifest.json"); - assertTrue(providerNames(registry).contains("first")); - assertEquals("https://example.com/subtitles/first.py", runtime.paths.get(0)); - - manager.install("demo", subscription("second", "second.py"), "https://example.com/subtitles/manifest.json"); - assertFalse(providerNames(registry).contains("first")); - assertTrue(providerNames(registry).contains("second")); - assertTrue(runtime.destroyed.contains("demo:first")); - - manager.destroy(); - assertFalse(providerNames(registry).contains("second")); - assertTrue(runtime.destroyed.contains("demo:second")); - } - - private static String subscription(String key, String ext) { - return "{\"version\":1,\"subtitles\":[{\"key\":\"" + key + "\",\"name\":\"" + key - + "\",\"type\":3,\"api\":\"py\",\"ext\":\"" + ext + "\"}]}"; - } - - private static List providerNames(SubtitleProviderRegistry registry) { - return registry.providerNames(); - } - - private static final class RecordingRuntime implements SubtitleScriptRuntime { - private final List paths = new ArrayList<>(); - private final List destroyed = new ArrayList<>(); - - @Override - public String init(String sourceKey, String scriptPath, String config) { - paths.add(scriptPath); - return "{\"code\":0,\"data\":{}}"; - } - - @Override - public String search(String sourceKey, String request) { - return "{\"code\":0,\"data\":{\"items\":[]}}"; - } - - @Override - public String resolve(String sourceKey, String request) { - return "{\"code\":0,\"data\":{\"url\":\"https://example.com/subtitle.srt\"}}"; - } - - @Override - public void destroy(String sourceKey) { - destroyed.add(sourceKey); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleSubscriptionTest.java b/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleSubscriptionTest.java deleted file mode 100644 index 3ebb21b7f8..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/subtitle/source/SubtitleSubscriptionTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.fongmi.android.tv.subtitle.source; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; - -import org.junit.Test; - -public class SubtitleSubscriptionTest { - - @Test - public void removesInvalidAndDuplicateSourcesAndSortsPriority() { - SubtitleSubscription value = SubtitleSubscription.parse("{\"version\":1,\"subtitles\":[" + - "{\"key\":\"low\",\"name\":\"Low\",\"type\":3,\"api\":\"py\",\"ext\":\"low.py\",\"priority\":1}," + - "{\"key\":\"high\",\"name\":\"High\",\"type\":3,\"api\":\"py\",\"ext\":\"high.py\",\"priority\":9}," + - "{\"key\":\"low\",\"name\":\"Duplicate Low\",\"type\":3,\"api\":\"py\",\"ext\":\"duplicate.py\"}," + - "{\"key\":\"broken\",\"name\":\"Broken\",\"type\":3,\"api\":\"py\"}]}", "https://example.com/sub/"); - assertEquals(2, value.getSubtitles().size()); - assertEquals("high", value.getSubtitles().get(0).getKey()); - assertEquals("low", value.getSubtitles().get(1).getKey()); - } - - @Test - public void preservesAssetUrisWhenResolvingScriptAddresses() { - assertEquals("assets://subtitle_sources/builtin/xunlei.py", - SubtitleSubscription.resolve("assets://subtitle_sources/builtin/manifest.json", "assets://subtitle_sources/builtin/xunlei.py")); - } - - @Test - public void rejectsUnsupportedVersion() { - assertThrows(IllegalArgumentException.class, - () -> SubtitleSubscription.parse("{\"version\":2,\"subtitles\":[]}", "https://example.com/")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/AppearanceRowThemeTest.java b/app/src/test/java/com/fongmi/android/tv/theme/AppearanceRowThemeTest.java deleted file mode 100644 index 44c306eb0d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/AppearanceRowThemeTest.java +++ /dev/null @@ -1,196 +0,0 @@ -package com.fongmi.android.tv.theme; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * The “Appearance & language” row must be readable on every shipped palette and - * must follow a user theme profile. - * - *

Regression under test (measured on device, {@code 192.168.50.3:5559}): the row kept - * the fixed light {@code selector_git_cloud_card} fill {@code #F8F9FA} while its text came - * from {@link ThemeController#current()}. The TV flavour compiles the dark token table, so - * the label rendered {@code #E2E2E9} at 1.16:1 and the value {@code #C4C6D0} at - * 1.36:1 - the reported "text is unreadable" in the TV appearance dialog. - * - *

These assertions are pure arithmetic on the shipped token tables plus source - * contracts, so they fail on the old wiring and pass on the fix without an Android device. - */ -public class AppearanceRowThemeTest { - - /** The role {@code AppearanceRowTheme} paints the resting row with. */ - private static int restingFill(ThemeTokens tokens) { - return tokens.colorSurfaceContainerHigh(); - } - - @Test - public void restingRowClearsWcagAaForBothTextRolesOnEveryShippedPalette() { - assertRowIsReadable(ThemeTokens.dark(), "dark"); - assertRowIsReadable(ThemeTokens.light(), "light"); - } - - private static void assertRowIsReadable(ThemeTokens tokens, String name) { - int fill = restingFill(tokens); - assertTrue(name + " label on the resting row", - ThemeContrast.ratio(tokens.colorOnSurface(), fill) >= 4.5); - assertTrue(name + " value on the resting row", - ThemeContrast.ratio(tokens.colorOnSurfaceVariant(), fill) >= 4.5); - // The row must not collapse into the dialog panel either, otherwise the inset list - // reads as one undifferentiated block. - assertNotEquals(name + " row fill must be distinguishable from the panel", - tokens.colorSurfaceContainerHigh(), tokens.colorSurfaceContainer()); - } - - @Test - public void focusedRowKeepsItsOwnTextReadableAndShowsARing() { - for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { - // Focused rows keep their on-surface text (the row text is not restyled on - // focus), so the focus fill must still clear AA against that text. - assertTrue("focused row text on primaryContainer", - ThemeContrast.ratio(tokens.colorOnSurface(), tokens.colorPrimaryContainer()) >= 4.5); - // The focus indicator itself must be visible against both the row fill and the - // surrounding panel (WCAG non-text contrast floor of 3:1). - assertTrue("focus ring on the focused fill", - ThemeContrast.ratio(tokens.colorPrimary(), tokens.colorPrimaryContainer()) >= 3.0); - assertTrue("focus ring on the dialog panel", - ThemeContrast.ratio(tokens.colorPrimary(), tokens.colorSurfaceContainerHigh()) >= 3.0); - } - } - - /** - * The old fixed fill is exactly what the device screenshot measured, so this pins the - * regression numerically rather than only by source inspection. - */ - @Test - public void theRemovedFixedFillIsExactlyTheMeasuredUnreadableOne() { - int legacyFill = 0xFFF8F9FA; - assertEquals("the fixed selector fill the row used to paint", legacyFill, 0xFFF8F9FA); - assertTrue("label was unreadable on it", - ThemeContrast.ratio(ThemeTokens.dark().colorOnSurface(), legacyFill) < 2.0); - assertTrue("value was unreadable on it", - ThemeContrast.ratio(ThemeTokens.dark().colorOnSurfaceVariant(), legacyFill) < 2.0); - assertTrue("the fix must clear AA where the old fill did not", - ThemeContrast.ratio(ThemeTokens.dark().colorOnSurface(), restingFill(ThemeTokens.dark())) >= 4.5); - } - - @Test - public void bothFlavoursRouteTheRowThroughTheSharedPalette() throws Exception { - for (String flavour : new String[]{"mobile", "leanback"}) { - String dialog = codeOnly(read("src/" + flavour + "/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java")); - assertTrue(flavour + " must paint the row from the active palette", - dialog.contains("AppearanceRowTheme.apply(row, title, summary, ThemeController.current());")); - assertFalse(flavour + " must not keep the fixed light row selector", - dialog.contains("selector_git_cloud_card")); - assertFalse(flavour + " must not colour row text inline any more", - dialog.contains("setTextColor(ThemeController.current()")); - // A profile applied while the dialog is open must reach the rows. - assertTrue(flavour + " must re-apply the palette on show", - dialog.contains("applyRowTheme();")); - } - } - - @Test - public void theSharedRowRendererUsesOnlySemanticRoles() throws Exception { - String source = codeOnly(read("src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java")); - assertTrue(source.contains("safe.colorSurfaceContainerHigh()")); - assertTrue(source.contains("safe.colorOnSurface()")); - assertTrue(source.contains("safe.colorOnSurfaceVariant()")); - assertTrue(source.contains("safe.colorPrimaryContainer()")); - assertTrue(source.contains("safe.colorPrimary()")); - assertTrue(source.contains("safe.colorOutlineVariant()")); - // No fixed colours may creep back in. - assertFalse("the row renderer must not hard-code a fill", source.contains("#F8F9FA")); - assertFalse(source.contains("#DADCE0")); - assertFalse(source.contains("#E8F0FE")); - } - - @Test - public void theRowOwnsThePaletteItIsPaintedWith() throws Exception { - // The regression was a mismatch between a fixed row fill and token-driven text. - // Guard the invariant directly: every colour the row paints comes from the same - // ThemeTokens instance the row text is set from. - String source = codeOnly(read("src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java")); - assertTrue(source.contains("public static void apply(View row, TextView title, TextView summary, ThemeTokens tokens)")); - int apply = source.indexOf("public static void apply("); - String body = source.substring(apply, source.indexOf("private static GradientDrawable shape(")); - assertTrue("the row fill comes from the passed tokens", body.contains("background(row.getContext(), safe)")); - assertTrue("the title colour comes from the same tokens", body.contains("title.setTextColor(safe.colorOnSurface())")); - assertTrue("the value colour comes from the same tokens", body.contains("summary.setTextColor(safe.colorOnSurfaceVariant())")); - } - - /** - * The picker opened from these rows paints its title on the same dialog panel, so it - * had the identical defect: a fixed {@code #202124} title on the dark panel measured - * 1.00:1 on device. It must use the active palette too. - */ - @Test - public void thePickerOpenedFromTheseRowsAlsoUsesTheActivePalette() throws Exception { - String dialog = codeOnly(read("src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java")); - assertTrue(dialog.contains("titleView.setTextColor(ThemeController.current().colorOnSurface())")); - assertTrue(dialog.contains("messageView.setTextColor(ThemeController.current().colorOnSurfaceVariant())")); - assertTrue(dialog.contains("import com.fongmi.android.tv.theme.ThemeController;")); - assertTrue(dialog.contains("ThemeController.bindDialog(dialog)")); - // The title/message sit directly on the dialog panel, which is the part that was - // invisible. Item rows keep their own opaque card, so their colours are a separate - // concern and stay untouched here. - int title = dialog.indexOf("titleView.setTextColor("); - int message = dialog.indexOf("messageView.setTextColor("); - assertTrue(title > 0 && message > 0); - assertFalse("the panel-drawn title must not be a fixed colour", - dialog.substring(title, dialog.indexOf(";", title)).contains("parseColor")); - assertFalse("the panel-drawn message must not be a fixed colour", - dialog.substring(message, dialog.indexOf(";", message)).contains("parseColor")); - } - - @Test - public void choiceItemsUseTheActivePaletteInBothModes() throws Exception { - String dialog = codeOnly(read("src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java")); - assertTrue(dialog.contains("ThemeTokens tokens = ThemeController.current()")); - assertTrue(dialog.contains("tokens.colorSurfaceContainerHigh()")); - assertTrue(dialog.contains("tokens.colorOnPrimaryContainer()")); - assertTrue(dialog.contains("tokens.colorSurfaceContainer()")); - assertTrue(dialog.contains("tokens.colorOnSurface()")); - assertTrue(dialog.contains("ThemeEditorUi.shape(requireContext(), tokens.colorSurfaceContainerHigh(), 0, 0, 22)")); - assertFalse(dialog.contains("#9AA0A6")); - assertFalse(dialog.contains("#F1F3F4")); - assertFalse(dialog.contains("#202124")); - assertFalse(dialog.contains("#1A73E8")); - } - - /** - * Strips comments so a source contract matches executable code, not the explanatory - * prose that names the very colours and selectors the contract forbids. - */ - private static String codeOnly(String source) { - StringBuilder out = new StringBuilder(source.length()); - int index = 0; - while (index < source.length()) { - char current = source.charAt(index); - char next = index + 1 < source.length() ? source.charAt(index + 1) : '\0'; - if (current == '/' && next == '/') { - while (index < source.length() && source.charAt(index) != '\n') index++; - } else if (current == '/' && next == '*') { - index += 2; - while (index + 1 < source.length() && !(source.charAt(index) == '*' && source.charAt(index + 1) == '/')) index++; - index = Math.min(source.length(), index + 2); - } else { - out.append(current); - index++; - } - } - return out.toString(); - } - - private static String read(String path) throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/DialogForegroundTokenTest.java b/app/src/test/java/com/fongmi/android/tv/theme/DialogForegroundTokenTest.java deleted file mode 100644 index f497353b2f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/DialogForegroundTokenTest.java +++ /dev/null @@ -1,401 +0,0 @@ -package com.fongmi.android.tv.theme; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Java-built dialogs must not pin their foreground to a fixed colour. - * - *

The dialogs below draw onto {@code shape_shell_proxy_dialog}, whose fill is - * {@code ?attr/colorSurfaceContainerHigh} - light in the day table - * ({@code #E7E8EF}) and dark in the night table ({@code #2A2F34}). A hard-coded - * foreground can therefore only be right in one of the two modes. - * - *

This is the Java-side twin of the layout guard in - * {@link ThemeBaseWiringTest#darkGlassSheetsUseAConstantLightForeground}. Layouts pin - * their colour in XML and can be checked by reading the file; these dialogs build their - * views in code and assign the colour at runtime, so the check is on the assignment - * source instead. The two guards cover the two ways a fixed colour gets in. - * - *

Measured on the day/night panel pair, the pinned literals these classes used were: - * {@code #5F6368} 4.95:1 day but 2.23:1 night, {@code #202124} 13.18:1 day but 1.19:1 - * night. The semantic replacements resolve to 5.23:1 or better in both tables. - */ -public class DialogForegroundTokenTest { - - /** The dialogs this guard was originally opened for. */ - private static final String[] BASE_DIALOG_SOURCES = { - "DebugLogDialog.java", - "MpvConfigDialog.java", - "PlaybackPerformanceDialog.java", - }; - - /** - * The viewing-record sync family: the top-level dialog and its two second-level managers. - * - *

They build their rows in code, so they are covered by the same foreground contract as - * the dialogs above, plus the per-method row/badge surface contract below. - */ - private static final String[] VIEWING_RECORD_SYNC_SOURCES = { - "ViewingRecordSyncDialog.java", - "PlaybackRemoteSyncDialog.java", - "PlaybackWebhookDialog.java", - }; - - /** Java-built dialogs that paint onto the palette-following dialog panel. */ - private static final String[] DIALOG_SOURCES = concat(BASE_DIALOG_SOURCES, VIEWING_RECORD_SYNC_SOURCES); - - /** - * Fixed foregrounds that must not appear: the legacy Material greys and blues these - * dialogs used to pin, plus the plain white/black literals that would equally break - * the other mode. - */ - private static final String[] FORBIDDEN_LITERALS = { - "#5F6368", "#202124", "#3C4043", "#174EA6", "#1A73E8", - "#8AB4F8", "#E8F0FE", "#C4C7C5", "#C5221F", - }; - - /** - * The only colour resources a guarded dialog may name directly. - * - *

All five are selector resources under {@code res/color/} that resolve to palette roles - - * four items per selector, naming {@code webhtv_color_*} roles plus a final mode-independent - * {@code @color/transparent} ({@code #00000000}) where the shape supplies the fill - so they - * already follow the palette and naming them is not a fixed colour. Anything else, such as a - * {@code res/values} literal like the legacy greys, would pin one mode and is rejected. - */ - private static final String[] ALLOWED_COLOR_RESOURCES = { - "dialog_tonal_button_bg", - "dialog_tonal_button_text", - "dialog_outlined_button_bg", - "dialog_outlined_button_text", - "dialog_outlined_button_stroke", - }; - - /** - * Every row/text surface the viewing-record sync family builds must take its colour from the - * token that belongs to that specific call site. - * - *

These are per-method contracts, not whole-file presence checks, because the defect this - * guard exists for is a single wrong assignment: {file, declaration, then one or more - * {@code ThemeController.current()} token suffixes the body must contain}. A whole-file check - * would stay green when one of several call sites in the same file silently went back to a - * literal, which is exactly how the original bug shipped. - */ - private static final String[][] REQUIRED_SITES = { - // ViewingRecordSyncDialog: the top-level list. - {"ViewingRecordSyncDialog.java", "private View sectionTitle(int text) {", - "colorOnSurfaceVariant()"}, - {"ViewingRecordSyncDialog.java", - "private View switchRow(String title, String detail, boolean enabled", - "colorOnSurface()"}, - {"ViewingRecordSyncDialog.java", - "private View entryRow(String title, String detail, int actionText", - "colorOnSurface()"}, - {"ViewingRecordSyncDialog.java", "private void addDetail(LinearLayoutCompat root, String value) {", - "colorOnSurfaceVariant()"}, - {"ViewingRecordSyncDialog.java", "private GradientDrawable rowBackground() {", - "colorSurfaceContainerLow()", "colorOutlineVariant()"}, - {"ViewingRecordSyncDialog.java", - "private MaterialButton actionButton(int text, boolean tonal, boolean danger) {", - "colorError()"}, - - // PlaybackRemoteSyncDialog: the remote-sync managers, each rendered as a card row. - {"PlaybackRemoteSyncDialog.java", "private View row(RemoteSyncConfig config) {", - "colorOnSurface()", "colorError()"}, - {"PlaybackRemoteSyncDialog.java", "private void addDetail(LinearLayoutCompat root, String value) {", - "colorOnSurfaceVariant()"}, - {"PlaybackRemoteSyncDialog.java", "private int statusColor(RemoteSyncConfig config) {", - "colorSuccess()", "colorOnSurfaceVariant()"}, - {"PlaybackRemoteSyncDialog.java", - "private MaterialButton actionButton(int text, boolean tonal, boolean danger) {", - "colorError()"}, - {"PlaybackRemoteSyncDialog.java", "private MaterialTextView badge(String value, int color) {", - "colorSurfaceContainerLowest()"}, - {"PlaybackRemoteSyncDialog.java", "private GradientDrawable rowBackground() {", - "colorSurfaceContainerLow()", "colorOutlineVariant()"}, - - // PlaybackWebhookDialog: adds the suspended row, which keeps an error fill and stroke. - {"PlaybackWebhookDialog.java", "private View row(WebhookConfig config) {", - "colorOnSurface()", "colorError()"}, - {"PlaybackWebhookDialog.java", "private void addDetail(LinearLayoutCompat root, String value) {", - "colorOnSurfaceVariant()"}, - {"PlaybackWebhookDialog.java", "private int statusColor(WebhookConfig config) {", - "colorError()", "colorSuccess()", "colorOnSurfaceVariant()"}, - {"PlaybackWebhookDialog.java", - "private MaterialButton actionButton(int text, boolean tonal, boolean danger) {", - "colorError()"}, - {"PlaybackWebhookDialog.java", "private MaterialTextView badge(String value, int color) {", - "colorSurfaceContainerLowest()"}, - {"PlaybackWebhookDialog.java", "private GradientDrawable rowBackground(WebhookConfig config) {", - "colorErrorContainer()", "colorError()", - "colorSurfaceContainerLow()", "colorOutlineVariant()"}, - }; - - @Test - public void javaBuiltDialogsDoNotPinAFixedForeground() throws Exception { - for (String name : DIALOG_SOURCES) { - String source = stripComments(read(dialogPath(name))); - for (String literal : FORBIDDEN_LITERALS) { - assertFalse(name + " pins the fixed foreground " + literal - + ", which cannot be legible in both the day and night palettes", - source.contains("\"" + literal + "\"")); - } - assertFalse(name + " must not pin a foreground through Color.parseColor", - source.contains("Color.parseColor(\"#")); - assertNoBareColorConstant(name, source); - assertNoColorConstructor(name, source); - assertNoIntegerHexLiteral(name, source); - assertNoFixedColorResource(name, source); - } - } - - /** - * The dialogs must take their foreground from the active semantic snapshot. - * - *

{@code Color.TRANSPARENT} is deliberately still allowed: it is not a foreground - * and carries no palette assumption (window backgrounds, tab ripples). - */ - @Test - public void javaBuiltDialogsReadTheActiveTokens() throws Exception { - for (String name : DIALOG_SOURCES) { - String source = stripComments(read(dialogPath(name))); - assertTrue(name + " must resolve its foreground from ThemeController.current()", - source.contains("ThemeController.current()")); - assertTrue(name + " must import ThemeController", - source.contains("import com.fongmi.android.tv.theme.ThemeController;")); - } - } - - /** - * The MPV action popup must keep its fill and its foreground in the same palette. - * - *

{@code MpvConfigDialog.actionItem} now paints its label with - * {@code colorOnSurface} / {@code colorError}. The popup background it sits on was a - * fixed white shape, so it had to migrate in the same change - otherwise night mode - * would render a near-black label on a still-white popup. Both halves are pinned so a - * later edit cannot move one without the other. - */ - @Test - public void mpvActionPopupFollowsTheSamePaletteAsItsForeground() throws Exception { - String popup = stripComments(read("src/main/res/drawable/shape_mpv_action_menu.xml")); - assertTrue("the popup fill must follow the palette", - popup.contains("?attr/colorSurfaceContainerLowest")); - assertTrue("the popup stroke must follow the palette", - popup.contains("?attr/colorOutlineVariant")); - assertFalse("the popup must not keep a fixed fill", popup.contains("#FFFFFF")); - assertFalse("the popup must not keep a fixed stroke", popup.contains("#E1E5EA")); - } - - /** - * Every row, badge and status surface of the viewing-record sync family follows the palette. - * - *

These rows are built in code and were filled with a fixed light card - * ({@code #F5F6F7}) on a fixed light hairline ({@code #DADCE0} / {@code #E2E5E8}), with the - * row title pinned to {@code Color.BLACK} and the badge to {@code #FFFFFF}. None of those - * values equals a {@code webhtv_color_*} role, so {@code ThemeBinder}'s exact-match rewrite - * skipped them by design: under the night table the dialog panel went dark while every row - * stayed a near-white card carrying black text, and the section labels/details stayed the - * fixed {@code #5F6368} grey that measures 2.23:1 on that panel. - * - *

Measured after the change against the real token values, every pair clears WCAG AA in - * both tables: row title 15.43:1 day / 13.29:1 night, detail text 8.39:1 / 10.06:1, - * badge text on its chip 6.53:1 / 11.78:1, and the suspended row's error text on its error - * container 5.14:1 / 5.51:1. - */ - @Test - public void viewingRecordSyncRowsUseTheActivePalette() throws Exception { - for (String[] site : REQUIRED_SITES) { - String name = site[0]; - String method = site[1]; - String body = methodBody(name, stripComments(read(dialogPath(name))), method); - for (int i = 2; i < site.length; i++) { - assertTrue(name + " " + method + " must take its colour from " - + "ThemeController.current()." + site[i] - + ", not a literal that can only be right in one palette", - body.contains("ThemeController.current()." + site[i])); - } - } - } - - /** - * The badge chip and the suspended row keep their own semantic surfaces. - * - *

The badge was a fixed {@code #FFFFFF} fill, which under the night table drew a white - * chip carrying the night success/error text. The suspended row was a fixed {@code #FFF7F7} - * pink that only read as "attention" in the day table, and its fill alone is a hue cue - the - * day/night error container differs from the normal card by only 1.15:1 / 1.83:1 - so it also - * keeps the paired {@code colorError} stroke. Both halves are pinned so a later edit cannot - * move one without the other. - */ - @Test - public void viewingRecordSyncBadgeAndSuspendedRowFollowThePalette() throws Exception { - String remote = stripComments( - read(dialogPath("PlaybackRemoteSyncDialog.java"))); - String remoteBadge = methodBody("PlaybackRemoteSyncDialog.java", remote, - "private MaterialTextView badge(String value, int color) {"); - assertTrue("the remote-sync badge fill must follow the palette", - remoteBadge.contains("ThemeController.current().colorSurfaceContainerLowest()")); - - String webhook = stripComments( - read(dialogPath("PlaybackWebhookDialog.java"))); - String webhookBadge = methodBody("PlaybackWebhookDialog.java", webhook, - "private MaterialTextView badge(String value, int color) {"); - assertTrue("the webhook badge fill must follow the palette", - webhookBadge.contains("ThemeController.current().colorSurfaceContainerLowest()")); - - String suspendedRow = methodBody("PlaybackWebhookDialog.java", webhook, - "private GradientDrawable rowBackground(WebhookConfig config) {"); - assertTrue("the suspended webhook row must use the semantic error container", - suspendedRow.contains("ThemeController.current().colorErrorContainer()")); - assertTrue("the suspended webhook row must keep an error stroke, so the cue is not hue-only", - suspendedRow.contains("ThemeController.current().colorError()")); - } - - /** - * Rejects {@code Color.BLACK}, {@code Color.WHITE}, {@code Color.GRAY} and friends. - * - *

The literal scan above only catches {@code "#RRGGBB"} strings, so a bare - * {@link android.graphics.Color} constant was a second way back to a pinned foreground. - * {@code Color.TRANSPARENT} is exempt: it is not a foreground. - */ - private static void assertNoBareColorConstant(String name, String source) { - Matcher matcher = Pattern.compile("Color\\.([A-Z][A-Z0-9_]*)").matcher(source); - while (matcher.find()) { - String constant = matcher.group(1); - if (!"TRANSPARENT".equals(constant)) { - fail(name + " must not pin a foreground through Color." + constant - + "; use ThemeController.current(), or Color.TRANSPARENT if it is not a colour"); - } - } - } - - /** - * Rejects {@code Color.rgb(32, 33, 36)} and friends - the fourth way to pin a literal. - * - *

This is not hypothetical: {@code MpvCustomButtonDialog} and - * {@code PlaybackPerformanceModal} pin exactly this way, and {@code Color.rgb(32, 33, 36)} - * is literally the forbidden {@code #202124}. Only the constructors that take numeric - * channel literals are rejected; the channel readers and {@code Color.valueOf} are - * conversions of an existing colour, not a way to introduce one. - */ - private static void assertNoColorConstructor(String name, String source) { - Matcher matcher = Pattern.compile("Color\\.(rgb|argb|HSVToColor)\\s*\\(").matcher(source); - if (matcher.find()) { - fail(name + " must not build a colour through Color." + matcher.group(1) - + "(...); use ThemeController.current() so both palettes stay legible"); - } - } - - /** - * Rejects {@code 0xFF5F6368}-style integer colours, a third way to pin a literal. - * - *

An 8-digit {@code 0x} literal is either an {@code AARRGGBB} colour or an Android - * resource id ({@code 0x7f......}); only the former is a pinned colour, so the package-id - * prefix is excluded to keep the rule from rejecting an unrelated packed constant. - */ - private static void assertNoIntegerHexLiteral(String name, String source) { - Matcher matcher = Pattern.compile("0x(?!7[fF])[0-9A-Fa-f]{8}").matcher(source); - if (matcher.find()) { - fail(name + " must not pin a foreground through the integer colour " + matcher.group() - + "; use ThemeController.current()"); - } - } - - /** Rejects {@code R.color.} except the palette-following selector resources. */ - private static void assertNoFixedColorResource(String name, String source) { - Matcher matcher = Pattern.compile("R\\.color\\.([A-Za-z0-9_]+)").matcher(source); - while (matcher.find()) { - String resource = matcher.group(1); - if (!List.of(ALLOWED_COLOR_RESOURCES).contains(resource)) { - fail(name + " must not name the colour resource R.color." + resource - + "; it is not one of the palette-following selectors " - + List.of(ALLOWED_COLOR_RESOURCES)); - } - } - } - - /** - * The body of the single method whose declaration contains {@code signature}. - * - *

Braces are balanced from the declaration's opening brace, so the returned text is only - * that method - which is what makes a per-call-site assertion possible. A missing or - * duplicated declaration fails loudly instead of silently matching the wrong body. - * - *

Assumption: this counts raw braces, so it is only correct while the guarded - * dialogs contain no {@code '{'} / {@code '}'} inside a string or character literal. A - * literal brace would mis-balance the scan and could silently return a truncated body, - * weakening every contract in {@link #REQUIRED_SITES}. {@link #assertNoBraceInLiteral} - * therefore rejects that shape outright rather than letting it corrupt the result. - */ - private static String methodBody(String name, String source, String signature) { - assertNoBraceInLiteral(name, source); - int start = source.indexOf(signature); - if (start < 0) { - fail(name + " no longer declares \"" + signature - + "\"; update this contract if the method was renamed or removed"); - } - if (source.indexOf(signature, start + 1) >= 0) { - fail(name + " declares \"" + signature + "\" more than once; the contract needs a" - + " signature that identifies one method"); - } - int open = source.indexOf('{', start); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return source.substring(open, i + 1); - } - throw new AssertionError(name + " declares \"" + signature + "\" without a balanced body"); - } - - /** - * Rejects a brace inside a string or character literal, which {@link #methodBody} cannot - * count correctly. - * - *

This turns a silent weakening into a loud failure: without it, adding - * {@code text("{")} to a guarded dialog would mis-balance the scan, and the contracts below - * would start asserting against a truncated body while still reporting green. - */ - private static void assertNoBraceInLiteral(String name, String source) { - Matcher matcher = Pattern.compile("\"[^\"\\n]*[{}][^\"\\n]*\"|'[{}]'").matcher(source); - if (matcher.find()) { - fail(name + " contains a brace inside a string or character literal (" - + matcher.group() + "), which methodBody cannot brace-balance; move the brace" - + " out of the literal or replace the scanner with a real parser"); - } - } - - private static String[] concat(String[] first, String[] second) { - List all = new ArrayList<>(List.of(first)); - all.addAll(List.of(second)); - return all.toArray(new String[0]); - } - - private static String dialogPath(String name) { - return "src/main/java/com/fongmi/android/tv/ui/dialog/" + name; - } - - private static String stripComments(String source) { - String withoutXml = source.replaceAll("(?s)", ""); - return withoutXml - .replaceAll("(?s)/\\*.*?\\*/", "") - .replaceAll("(?m)//.*$", ""); - } - - private static String read(String path) throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/LeanbackForegroundContrastTest.java b/app/src/test/java/com/fongmi/android/tv/theme/LeanbackForegroundContrastTest.java deleted file mode 100644 index 8151f73d26..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/LeanbackForegroundContrastTest.java +++ /dev/null @@ -1,444 +0,0 @@ -package com.fongmi.android.tv.theme; - -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Deque; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * The leanback counterpart of {@link ThemeBaseWiringTest}'s dark-glass-sheet guard. - * - *

TV resolves the same day/night token tables as mobile now that the flavour no longer - * ships a permanent dark {@code values/webhtv_tokens.xml}. That makes a - * palette-independent light foreground ({@code ?attr/webhtvColorOnWallpaper}, - * {@code @color/webhtv_color_player_control_muted}) correct only where the surface behind - * it is dark in both tables. On the app wallpaper, on the translucent glass - * sheets, on the {@code shape_vod_*} badges and on the video surface that holds; on a - * {@code ?attr/colorSurface*} panel or on one of the light card fills - * ({@code selector_git_cloud_card} {@code #F8F9FA}, {@code selector_light_dialog_item} - * {@code #FFFFFF}…) it turns into white-on-white in day mode. - * - *

That is exactly the mistake the first TV-light pass made: four leanback layouts were - * migrated wholesale to the wallpaper role although their own rows sit on a light card. - * Measured on {@code adapter_player_osd.xml} (over {@code #F8F9FA}) the pair was - * 1.05:1, and on {@code adapter_tmdb_item.xml} (over the focused - * {@code #D3E3FD}) 1.16:1 — both below the 3:1 floor for non-text UI and far below - * the 4.5:1 body-text floor. - * - *

The check is structural rather than a per-file list: for every leanback layout it - * walks the tag tree, and for each constant-light foreground it resolves the nearest - * ancestor (or self) that paints a background, then decides whether that background is - * light in the day palette. Palette-following backgrounds are rejected outright, because - * the day table is the light one. - */ -public class LeanbackForegroundContrastTest { - - /** Foregrounds that are light in every palette, with the value they resolve to. */ - private static final Map CONSTANT_LIGHT_FOREGROUNDS = new LinkedHashMap<>(); - - static { - CONSTANT_LIGHT_FOREGROUNDS.put("?attr/webhtvColorOnWallpaper", "#FFFFFF"); - CONSTANT_LIGHT_FOREGROUNDS.put("@color/webhtv_color_player_control_muted", "#CCFFFFFF"); - // 壁纸上的次级前景。与上面两个同族:两张表里都是浅色,因此只允许落在深色宿主上 - // (卡片信息条带、设置行色带),落在浅色日间表面会白到看不见。 - CONSTANT_LIGHT_FOREGROUNDS.put("?attr/webhtvColorOnWallpaperVariant", "#CCFFFFFF"); - } - - /** Luminance above which a day-mode fill can no longer carry a light foreground. */ - private static final double LIGHT_SURFACE_LUMINANCE = 0.6; - - /** Minimum contrast a light foreground needs against the fill behind it. */ - private static final double MIN_CONTRAST = 3.0; - - private static final Pattern TAG = Pattern.compile("<(/?)([A-Za-z0-9_.]+)((?:\"[^\"]*\"|[^>\"])*?)(/?)>", Pattern.DOTALL); - private static final Pattern BACKGROUND = Pattern.compile("android:background=\"([^\"]+)\""); - private static final Pattern TEXT_COLOR = Pattern.compile("android:textColor=\"([^\"]+)\""); - private static final Pattern SOLID = Pattern.compile("([^<]+)"); - private static final Pattern SELECTOR_COLOR = Pattern.compile("android:color=\"([^\"]+)\""); - private static final Pattern COMMENT = Pattern.compile("", Pattern.DOTALL); - private static final Pattern STROKE = Pattern.compile("]*/>|", Pattern.DOTALL); - private static final Pattern PADDING = Pattern.compile("]*/>|", Pattern.DOTALL); - private static final Pattern MASK_ITEM = Pattern.compile("]*@android:id/mask[^>]*>.*?", Pattern.DOTALL); - private static final Pattern RIPPLE_COLOR = Pattern.compile("]*android:color=\"[^\"]*\"[^>]*>"); - - /** Day-palette {@code webhtv_*} values; the night table is not needed to decide "light". */ - private static final Map DAY_TOKENS = new HashMap<>(); - - /** Alias table for the literal colours, so {@code @color/x -> @color/webhtv_...} resolves. */ - private static final Map LITERALS = new HashMap<>(); - - private static final List LAYOUTS = new ArrayList<>(); - - // ------------------------------------------------------------------ palette loading - - private static String readIfExists(String relative) { - try { - Path path = root().resolve(relative); - return Files.exists(path) ? Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n") : null; - } catch (Exception error) { - return null; - } - } - - private static Path root() { - return Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - } - - private static void loadPalette() { - if (!DAY_TOKENS.isEmpty()) return; - for (String file : new String[]{ - "src/main/res/values/webhtv_tokens.xml", - "src/main/res/values/colors.xml", - "src/leanback/res/values/colors.xml"}) { - String source = readIfExists(file); - if (source == null) continue; - Matcher matcher = COLOR_ENTRY.matcher(withoutComments(source)); - while (matcher.find()) { - String name = matcher.group(1); - String value = matcher.group(2).trim(); - if (file.contains("webhtv_tokens")) DAY_TOKENS.put(name, value); - LITERALS.put(name, value); - } - } - } - - private static void loadLayouts() throws Exception { - if (!LAYOUTS.isEmpty()) return; - Path directory = root().resolve("src/leanback/res/layout"); - try (var paths = Files.walk(directory)) { - paths.filter(Files::isRegularFile) - .filter(path -> path.getFileName().toString().endsWith(".xml")) - .sorted() - .forEach(LAYOUTS::add); - } - } - - private static String withoutComments(String source) { - return COMMENT.matcher(source).replaceAll(""); - } - - /** Literal {@code #RRGGBB}/{@code #AARRGGBB} a {@code @color/} resolves to, or null when it follows the palette. */ - private static String literalOf(String name, Map seen) { - if (seen.containsKey(name)) return null; - seen.put(name, name); - String value = LITERALS.get(name); - if (value != null) { - if (value.startsWith("#")) return value; - if (value.startsWith("@color/")) return literalOf(value.substring("@color/".length()), seen); - } - // res/color/.xml selectors may be the only definition. - for (String directory : new String[]{"src/main/res/color", "src/leanback/res/color"}) { - String source = readIfExists(directory + "/" + name + ".xml"); - if (source == null) continue; - if (withoutComments(source).contains("?attr/")) return null; - Matcher matcher = SELECTOR_COLOR.matcher(withoutComments(source)); - while (matcher.find()) { - String entry = matcher.group(1); - if (entry.startsWith("#")) return entry; - if (entry.startsWith("@color/")) { - String resolved = literalOf(entry.substring("@color/".length()), seen); - if (resolved != null) return resolved; - } - } - } - return null; - } - - /** Relative luminance of {@code #RRGGBB}/{@code #AARRGGBB}; alpha is dropped. */ - private static double luminance(String hex) { - String digits = hex.startsWith("#") ? hex.substring(1) : hex; - if (digits.length() == 8) digits = digits.substring(2); - double[] channels = new double[3]; - for (int i = 0; i < 3; i++) { - double value = Integer.parseInt(digits.substring(i * 2, i * 2 + 2), 16) / 255.0; - channels[i] = value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); - } - return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; - } - - private static int alpha(String hex) { - String digits = hex.startsWith("#") ? hex.substring(1) : hex; - return digits.length() == 8 ? Integer.parseInt(digits.substring(0, 2), 16) : 0xFF; - } - - private static double contrast(String first, String second) { - double a = luminance(first); - double b = luminance(second); - return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); - } - - // ------------------------------------------------------------------ background resolution - - /** Outcome of resolving one background: how it behaves in the day palette. */ - private enum Surface {DARK, LIGHT, PALETTE, UNKNOWN} - - private static Surface surfaceOf(String background) { - if (background == null) return Surface.UNKNOWN; - if (background.startsWith("?attr/colorSurface") || background.startsWith("?attr/colorBackground") - || background.startsWith("?attr/android:colorBackground")) { - return Surface.PALETTE; // the day table is the light one - } - if (background.startsWith("?attr/")) return Surface.UNKNOWN; - if (background.startsWith("@color/")) return surfaceOfColor(background.substring("@color/".length())); - if (background.startsWith("@drawable/")) return surfaceOfDrawable(background.substring("@drawable/".length())); - return Surface.UNKNOWN; - } - - private static Surface surfaceOfColor(String name) { - String literal = literalOf(name, new HashMap<>()); - if (literal == null) return Surface.PALETTE; // palette-following selector - return luminance(literal) > LIGHT_SURFACE_LUMINANCE ? Surface.LIGHT : Surface.DARK; - } - - /** - * The day-mode fills of a drawable. - * - *

Strokes, padding and ripple mask items are dropped first: only a fill can sit - * behind text. A {@code ?attr/} fill or an unresolvable {@code @color/} makes the whole - * drawable palette-following. - */ - private static Surface surfaceOfDrawable(String name) { - boolean palette = false; - List fills = new ArrayList<>(); - for (String directory : new String[]{"src/main/res/drawable", "src/leanback/res/drawable"}) { - String source = readIfExists(directory + "/" + name + ".xml"); - if (source == null) continue; - String body = RIPPLE_COLOR.matcher(MASK_ITEM.matcher(PADDING.matcher(STROKE.matcher(withoutComments(source)) - .replaceAll("")).replaceAll("")).replaceAll("")).replaceAll(""); - palette |= body.contains("?attr/"); - Matcher solid = SOLID.matcher(body); - while (solid.find()) { - String value = solid.group(1); - if (value.startsWith("#")) fills.add(value); - else if (value.startsWith("@color/")) { - String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); - if (literal == null) palette = true; - else fills.add(literal); - } else if (!value.contains("transparent")) { - palette = true; - } - } - Matcher gradient = GRADIENT.matcher(body); - while (gradient.find()) { - String value = gradient.group(1); - if (value.startsWith("#")) fills.add(value); - else if (value.startsWith("@color/")) { - String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); - if (literal == null) palette = true; - else fills.add(literal); - } else { - palette = true; - } - } - } - if (palette) return Surface.PALETTE; - List visible = fills.stream().filter(fill -> alpha(fill) >= 0x66).toList(); - if (visible.isEmpty()) return Surface.UNKNOWN; // fully transparent: keep walking out - boolean light = visible.stream().allMatch(fill -> luminance(fill) > LIGHT_SURFACE_LUMINANCE); - return light ? Surface.LIGHT : Surface.DARK; - } - - // ------------------------------------------------------------------ the guard - - /** - * A palette-independent light foreground must not sit on a light day-mode surface. - * - *

Reported per file so a failure names the layout to fix; the message carries the - * offending tag, the background that was resolved and the contrast measured for the - * worst day-mode fill. - */ - @Test - public void constantLightForegroundsStayOnDarkSurfacesInDayMode() throws Exception { - loadPalette(); - loadLayouts(); - List failures = new ArrayList<>(); - for (Path layout : LAYOUTS) { - String source = Files.readString(layout, StandardCharsets.UTF_8).replace("\r\n", "\n"); - for (String failure : inspect(source)) { - failures.add(layout.getFileName() + ": " + failure); - } - } - if (!failures.isEmpty()) { - fail("leanback layouts paint a light foreground on a light day-mode surface, which is" - + " white-on-white in day mode:\n " + String.join("\n ", failures)); - } - } - - private static List inspect(String source) { - List failures = new ArrayList<>(); - Deque backgrounds = new ArrayDeque<>(); - Matcher matcher = TAG.matcher(source); - while (matcher.find()) { - boolean closing = !matcher.group(1).isEmpty(); - String tag = matcher.group(2); - String attributes = matcher.group(3); - boolean selfClosing = !matcher.group(4).isEmpty(); - if (closing) { - if (!backgrounds.isEmpty()) backgrounds.pop(); - continue; - } - Matcher backgroundMatcher = BACKGROUND.matcher(attributes); - String own = backgroundMatcher.find() ? backgroundMatcher.group(1) : null; - if (!selfClosing) backgrounds.push(own == null ? "" : own); - - Matcher colorMatcher = TEXT_COLOR.matcher(attributes); - if (!colorMatcher.find()) continue; - String foreground = CONSTANT_LIGHT_FOREGROUNDS.get(colorMatcher.group(1)); - if (foreground == null) continue; - - String nearest = own; - for (String ancestor : backgrounds) { - if (nearest == null || nearest.isEmpty()) nearest = ancestor; - if (nearest != null && !nearest.isEmpty()) break; - } - Surface surface = surfaceOf(nearest); - if (surface == Surface.DARK || surface == Surface.UNKNOWN) continue; - if (surface == Surface.PALETTE) { - failures.add(tag + " textColor=" + colorMatcher.group(1) - + " sits on the palette-following background " + nearest - + ", whose day value is a light surface"); - continue; - } - double worst = worstDayContrast(nearest, foreground); - if (worst < MIN_CONTRAST) { - failures.add(tag + " textColor=" + colorMatcher.group(1) - + " sits on " + nearest + " with " + String.format(Locale.ROOT, "%.2f:1", worst) - + " contrast in day mode"); - } - } - return failures; - } - - /** Worst day-mode contrast of a light foreground over the fills of a background. */ - private static double worstDayContrast(String background, String foreground) { - if (background == null) return Double.MAX_VALUE; - List fills = new ArrayList<>(); - if (background.startsWith("@color/")) { - String literal = literalOf(background.substring("@color/".length()), new HashMap<>()); - if (literal != null) fills.add(literal); - } else if (background.startsWith("@drawable/")) { - String name = background.substring("@drawable/".length()); - for (String directory : new String[]{"src/main/res/drawable", "src/leanback/res/drawable"}) { - String source = readIfExists(directory + "/" + name + ".xml"); - if (source == null) continue; - String body = RIPPLE_COLOR.matcher(MASK_ITEM.matcher(PADDING.matcher(STROKE.matcher(withoutComments(source)) - .replaceAll("")).replaceAll("")).replaceAll("")).replaceAll(""); - Matcher solid = SOLID.matcher(body); - while (solid.find()) { - String value = solid.group(1); - if (value.startsWith("#")) fills.add(value); - else if (value.startsWith("@color/")) { - String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); - if (literal != null) fills.add(literal); - } - } - Matcher gradient = GRADIENT.matcher(body); - while (gradient.find()) { - String value = gradient.group(1); - if (value.startsWith("#")) fills.add(value); - else if (value.startsWith("@color/")) { - String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); - if (literal != null) fills.add(literal); - } - } - } - } - return fills.stream().filter(fill -> alpha(fill) >= 0x66) - .mapToDouble(fill -> contrast(foreground, fill)) - .min().orElse(Double.MAX_VALUE); - } - - /** - * The four layouts that host their rows on a light card must keep the palette-following - * role, so a future "migrate the remaining TV foregrounds" sweep cannot put the light - * constant back on them. - * - *

Note this pins the day behaviour only. Those cards are fixed-light in both - * palettes, so their palette-following foreground is unreadable in night mode - * ({@code #E2E2E9} over {@code #F8F9FA} = 1.22:1). That is a pre-existing defect shared - * with mobile and present on {@code beta}, not something the TV-light pass introduced; - * it is deliberately not asserted here so it stays visible instead of being enshrined. - */ - @Test - public void lightCardRowsKeepThePaletteFollowingRole() throws Exception { - String[][] expectations = { - {"adapter_device.xml", "?attr/colorOnSurface", "?attr/colorOnSurfaceVariant"}, - {"adapter_player_osd.xml", "?attr/colorOnSurface"}, - {"adapter_recommendation_feedback.xml", "?attr/colorOnSurface", "?attr/colorOnSurfaceVariant"}, - {"adapter_tmdb_item.xml", "?attr/colorOnSurface", "?attr/colorOnSurfaceVariant"}, - }; - for (String[] expectation : expectations) { - String source = readIfExists("src/leanback/res/layout/" + expectation[0]); - assertTrue(expectation[0] + " is missing", source != null); - for (int i = 1; i < expectation.length; i++) { - assertTrue(expectation[0] + " must keep " + expectation[i] - + " for its rows over a light card", - source.contains("android:textColor=\"" + expectation[i] + "\"")); - } - for (String constant : CONSTANT_LIGHT_FOREGROUNDS.keySet()) { - assertTrue(expectation[0] + " must not paint its light-card rows with " + constant, - !source.contains("android:textColor=\"" + constant + "\"")); - } - } - } - - /** - * The guard is only meaningful while those cards really are light in day mode. - * - *

Pins the two fills the migration got wrong, so moving the card onto a palette role - * (which would make the constant foreground correct again) is a deliberate, visible edit - * rather than something the first test silently stops covering. - * - *

It also pins the absence of a {@code drawable-night} variant. That is the fact which - * forces these rows onto the palette-following role: a fixed-light card cannot carry a - * fixed-light foreground in night mode. When a night variant is added, this assertion is - * the place that announces the coupling has changed. - */ - @Test - public void theLightCardsAreStillLightInDayMode() throws Exception { - loadPalette(); - for (String drawable : new String[]{ - "selector_git_cloud_card", "selector_light_dialog_item", "selector_tmdb_search_item"}) { - assertTrue(drawable + " must keep its light day fill", - lightestFill(drawable) > LIGHT_SURFACE_LUMINANCE); - assertTrue(drawable + " must have no night variant, which is why its rows follow the palette", - readIfExists("src/main/res/drawable-night/" + drawable + ".xml") == null - && readIfExists("src/leanback/res/drawable-night/" + drawable + ".xml") == null); - } - } - - private static double lightestFill(String drawable) { - double darkest = Double.MAX_VALUE; - for (String directory : new String[]{"src/main/res/drawable", "src/leanback/res/drawable"}) { - String source = readIfExists(directory + "/" + drawable + ".xml"); - if (source == null) continue; - String body = RIPPLE_COLOR.matcher(MASK_ITEM.matcher(PADDING.matcher(STROKE.matcher(withoutComments(source)) - .replaceAll("")).replaceAll("")).replaceAll("")).replaceAll(""); - Matcher matcher = SOLID.matcher(body); - while (matcher.find()) { - String value = matcher.group(1); - String literal = value.startsWith("#") ? value - : value.startsWith("@color/") ? literalOf(value.substring("@color/".length()), new HashMap<>()) : null; - if (literal != null && alpha(literal) >= 0x66) darkest = Math.min(darkest, luminance(literal)); - } - } - assertTrue(drawable + " has no visible fill", darkest != Double.MAX_VALUE); - return darkest; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/LiveListScrimTest.java b/app/src/test/java/com/fongmi/android/tv/theme/LiveListScrimTest.java deleted file mode 100644 index 025326ac28..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/LiveListScrimTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class LiveListScrimTest { - - @Test - public void defaultOpacityIsTheShippedLiveListBlack() { - // shape_live_list.xml is a flat black_80 (#CC000000), so an unset slot must stay - // byte-identical to what shipped: one uniform 80% black, not a left-to-right ramp. - assertEquals(0.80f, LiveListScrim.DEFAULT_OPACITY, 0.0001f); - assertEquals(0xCC000000, LiveListScrim.withAlpha(LiveListScrim.DEFAULT_OPACITY)); - } - - @Test - public void theSliderReachesAFullyOpaqueBlackAtOneHundredPercent() { - assertEquals(0xFF000000, LiveListScrim.withAlpha(1f)); - } - - @Test - public void aLowSliderValueDimsUniformlyWithoutTouchingTheHue() { - // 20% is the validator's floor and must be a plain linear reading of the slider. - int dim = LiveListScrim.withAlpha(0.20f); - assertEquals(0x33000000, dim); - // Only alpha may move: the scrim is pure black at every strength. - assertEquals(0, dim & 0x00FFFFFF); - assertEquals(0, LiveListScrim.withAlpha(1f) & 0x00FFFFFF); - assertEquals(0, LiveListScrim.withAlpha(0.80f) & 0x00FFFFFF); - } - - @Test - public void zeroOpacityHidesTheScrimCompletely() { - assertEquals(0x00000000, LiveListScrim.withAlpha(0f)); - } - - @Test - public void unsetSlotFallsBackToTheShippedDefault() { - assertEquals(0.80f, LiveListScrim.resolveOpacity(null), 0.0001f); - assertEquals(0.35f, LiveListScrim.resolveOpacity(0.35f), 0.0001f); - assertEquals(1f, LiveListScrim.resolveOpacity(2f), 0.0001f); - assertEquals(0f, LiveListScrim.resolveOpacity(-1f), 0.0001f); - } - - @Test - public void withAlphaScalesOnlyTheBlackAlpha() { - assertEquals(0xCC000000, LiveListScrim.withAlpha(0.80f)); - assertEquals(0xFF000000, LiveListScrim.withAlpha(1f)); - assertEquals(0x00000000, LiveListScrim.withAlpha(0f)); - } - - @Test - public void theScrimIsOneFlatColourThatFollowsTheThemeSlot() throws Exception { - String source = read("src/main/java/com/fongmi/android/tv/theme/LiveListScrim.java"); - assertTrue(source.contains("ThemeController.configuredLiveListScrimOpacity(")); - // A flat scrim, deliberately: the drawer must not fade out towards the right edge. - assertTrue(source.contains("new ColorDrawable(")); - assertFalse(source.contains("GradientDrawable")); - assertFalse(source.contains("BASE_ALPHAS")); - // The live drawer must never borrow the playback menu hue. - assertFalse(source.contains("colorPlayerScrim")); - } - - private static String read(String path) throws Exception { - Path root = Files.isDirectory(Path.of("src", "main", "java")) ? Path.of(".") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/LiveListScrimWiringTest.java b/app/src/test/java/com/fongmi/android/tv/theme/LiveListScrimWiringTest.java deleted file mode 100644 index 047b08391a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/LiveListScrimWiringTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Wiring contract for the live-page channel drawer scrim. - * - *

The drawer used to be a fixed {@code shape_live_list} gradient; it is now one flat - * black whose alpha follows the theme profile slot {@code liveListScrimOpacity} through - * {@link LiveListScrim}. The "unset slot == shipped look", "one uniform colour" and "the - * slider reaches 100%" requirements are protected by asserting the two hosts still read - * the slot and that the static XML fallback stayed flat too. These are source assertions - * on purpose: both activities need an Android runtime for their real behaviour. - */ -public class LiveListScrimWiringTest { - - @Test - public void theLeanbackLiveActivityPaintsItsChannelDrawerFromTheThemeSlot() throws Exception { - String activity = read("src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"); - assertTrue(activity.contains("import com.fongmi.android.tv.theme.LiveListScrim;")); - assertTrue(activity.contains("LiveListScrim.create(this)")); - assertTrue(activity.contains("mBinding.recycler.setBackground(")); - assertFalse("the playback overlay must not grow a drawer dependency", - bodyOf(activity, "private void applyPlaybackOverlay()").contains("LiveListScrim")); - } - - @Test - public void theMobileLiveActivityKeepsTheEmbeddedDrawerOnItsOwnDrawable() throws Exception { - String activity = read("src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"); - assertTrue(activity.contains("mBinding.recycler.setBackground(LiveListScrim.create(this));")); - assertTrue(activity.contains("R.drawable.shape_live_embedded_list")); - } - - @Test - public void theStaticDrawableFallbackIsOneFlatBlackInBothHosts() throws Exception { - // The layouts still name @drawable/shape_live_list as a fallback; if it stayed a - // gradient, any host that skipped the Java path would fade out again to the right. - for (String path : new String[]{ - "src/leanback/res/drawable/shape_live_list.xml", - "src/mobile/res/drawable/shape_live_list.xml"}) { - String drawable = read(path); - assertTrue(path + " must stay a flat solid", drawable.contains(" R.string.theme_editor_slot_live_list_scrim_opacity;")); - } - - @Test - public void everyLocaleNamesTheNewOpacitySlot() throws Exception { - String english = read("src/main/res/values/strings.xml"); - assertTrue(english.contains("theme_editor_slot_live_list_scrim_opacity")); - assertTrue(english.contains("Live list overlay opacity")); - String simplified = read("src/main/res/values-zh-rCN/strings.xml"); - assertTrue(simplified.contains("theme_editor_slot_live_list_scrim_opacity")); - assertTrue(simplified.contains("直播列表遮盖层不透明度")); - String traditional = read("src/main/res/values-zh-rTW/strings.xml"); - assertTrue(traditional.contains("theme_editor_slot_live_list_scrim_opacity")); - assertTrue(traditional.contains("直播列表遮罩不透明度")); - } - - private static String bodyOf(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("missing " + signature, start >= 0); - int depth = 0; - for (int index = source.indexOf('{', start); index < source.length(); index++) { - char current = source.charAt(index); - if (current == '{') depth++; - else if (current == '}' && --depth == 0) return source.substring(start, index + 1); - } - return ""; - } - - private static String read(String path) throws Exception { - Path root = Files.isDirectory(Path.of("src", "main", "java")) ? Path.of(".") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/PlaybackMenuScrimTest.java b/app/src/test/java/com/fongmi/android/tv/theme/PlaybackMenuScrimTest.java deleted file mode 100644 index 960edc1b2b..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/PlaybackMenuScrimTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class PlaybackMenuScrimTest { - - @Test - public void defaultStrengthKeepsTheMenuReadableOnBrightFrames() { - int[] colors = PlaybackMenuScrim.gradientColors(0x000000, PlaybackMenuScrim.DEFAULT_OPACITY); - assertEquals(0x99000000, colors[0]); - assertEquals(0xCC000000, colors[1]); - } - - @Test - public void theSliderScalesTheWholeGradientAndClamps() { - int[] full = PlaybackMenuScrim.gradientColors(0x000000, 1f); - assertEquals(0xBF000000, full[0]); - assertEquals(0xFF000000, full[1]); - int[] off = PlaybackMenuScrim.gradientColors(0x000000, 0f); - assertEquals(0x00000000, off[0]); - assertEquals(0x00000000, off[1]); - } - - @Test - public void unsetSlotFallsBackToTheShippedDefault() { - assertEquals(0.80f, PlaybackMenuScrim.resolveOpacity(null), 0.0001f); - assertEquals(0.35f, PlaybackMenuScrim.resolveOpacity(0.35f), 0.0001f); - assertEquals(1f, PlaybackMenuScrim.resolveOpacity(2f), 0.0001f); - assertEquals(0f, PlaybackMenuScrim.resolveOpacity(-1f), 0.0001f); - } - - @Test - public void theScrimFollowsTheThemeSlotInsteadOfAHardcodedDrawable() throws Exception { - String source = read("src/main/java/com/fongmi/android/tv/theme/PlaybackMenuScrim.java"); - assertTrue(source.contains("ThemeController.configuredPlayerScrimOpacity(")); - assertTrue(source.contains("ThemeController.current().colorPlayerScrim()")); - assertTrue(source.contains("GradientDrawable.Orientation.TOP_BOTTOM")); - assertFalse(source.contains("shape_controller_scrim")); - } - - @Test - public void theThemeControllerExposesTheBoundedSlot() throws Exception { - String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); - assertTrue(controller.contains("public static Float configuredPlayerScrimOpacity(boolean light)")); - String validator = read("src/main/java/com/fongmi/android/tv/theme/ThemeProfileValidator.java"); - assertTrue(validator.contains("MIN_PLAYER_SCRIM_OPACITY = 0.20f")); - assertTrue(validator.contains("MAX_PLAYER_SCRIM_OPACITY = 1f")); - } - - private static String read(String path) throws Exception { - Path root = Files.isDirectory(Path.of("src", "main", "java")) ? Path.of(".") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeBaseWiringTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeBaseWiringTest.java deleted file mode 100644 index bb90602991..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeBaseWiringTest.java +++ /dev/null @@ -1,653 +0,0 @@ -package com.fongmi.android.tv.theme; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * Layer 1 integration contract: both flavours inherit the WebHTV semantic theme and the - * Activity theme maps every Material color role used by layouts onto a webhtv token, so no - * screen silently falls back to the Material baseline palette. - */ -public class ThemeBaseWiringTest { - - private static final String[] ACTIVITY_ROLES = { - "colorPrimary", "colorOnPrimary", "colorPrimaryContainer", "colorOnPrimaryContainer", - "colorSecondary", "colorOnSecondary", "colorSecondaryContainer", "colorOnSecondaryContainer", - "colorTertiary", "colorOnTertiary", - "colorError", "colorOnError", "colorErrorContainer", "colorOnErrorContainer", - "colorSurface", "colorSurfaceDim", "colorSurfaceBright", - "colorSurfaceContainerLowest", "colorSurfaceContainerLow", "colorSurfaceContainer", - "colorSurfaceContainerHigh", "colorSurfaceContainerHighest", "colorSurfaceVariant", - "colorOnSurface", "colorOnSurfaceVariant", "colorOutline", "colorOutlineVariant", - "colorSurfaceInverse", "colorOnSurfaceInverse", "colorPrimaryInverse", - "colorControlNormal", "colorControlActivated", - }; - - private static final String[] DIALOG_ROLES = { - "colorPrimary", "colorOnPrimary", - "colorSecondaryContainer", "colorOnSecondaryContainer", - "colorError", "colorOnError", "colorErrorContainer", "colorOnErrorContainer", - "colorSurface", "colorSurfaceContainer", "colorSurfaceContainerHigh", "colorSurfaceContainerHighest", - "colorOnSurface", "colorOnSurfaceVariant", "colorOutline", "colorOutlineVariant", - "colorControlNormal", "colorControlActivated", - }; - - /** Layouts draw these over video, so they keep their own alpha instead of ?attr/colorOnSurface. */ - private static final String[] ON_SURFACE_ALPHA_ATTRS = { - "colorOnSurface_20", "colorOnSurface_70", "colorOnSurface_80", "colorOnSurface_90", - }; - - /** - * List/overlay layouts whose text is a flat colour over a fill that is dark in - * every state. - * - *

Upstream painted these with a flat light colour ({@code @color/white}, - * {@code @color/white_60/80}); the unification to {@code ?attr/colorOnSurface} - * resolved them to #1A1C1E in day mode and made them dark-on-dark over the - * wallpaper, the video surface and the translucent white glass panels. Upstream - * reused the very same item layouts inside {@code dialog_quick_search}, - * {@code dialog_episode_list} and {@code dialog_receive}, so a single always-light - * role is correct in both the page and the dialog host. - * - *

The fill of each of these is {@code shape_live} (#47000000 / #5C000000), - * {@code shape_vod_list} / {@code shape_vod_name} (#33000000), - * {@code shape_video_episode_grid} (#99000000), {@code shape_quick_search_item} or a - * {@code ?attr/colorSurfaceContainer*} panel - never a light one - so no state list - * is needed. Their adapters were checked for {@code setSelected}: none of them call - * it except through {@code shape_item}'s states, which this list excludes. - */ - private static final String[] WALLPAPER_ITEMS = { - "adapter_channel.xml", "adapter_epg_data.xml", "adapter_file.xml", - "adapter_group.xml", "adapter_search.xml", "adapter_search_hot_word.xml", - "adapter_search_record.xml", "adapter_search_word.xml", "adapter_vod.xml", - "adapter_vod_list.xml", "adapter_vod_oval.xml", "view_empty.xml", - "adapter_quick.xml", "view_progress.xml", - }; - - /** - * Selectable items whose fill turns light when selected, so a flat colour cannot - * serve both states. - * - *

{@code selector_item} is the shared fill of these items in the mobile flavour: - * {@code state_selected} paints {@code colorSecondaryContainer} at 0.65 alpha - a - * light grey in day mode (#DFE2E6 over the wallpaper measured #BAB9C5) - while the - * default entry paints 15% black over the wallpaper, which is dark. Measured on the - * home type chips: white text on the selected chip gave 1.94:1, dark text gave - * 8.82:1; on an unselected chip white gave 6.32:1 and dark 2.71:1. Neither a flat - * white nor a flat dark value can be right for both, which is why upstream shipped - * these as state lists and why dev3 carried them until the unification replaced them. - * - *

This is the exact set dev3 used before that change; restoring it is the fix. - */ - private static final String[][] WALLPAPER_STATE_ITEMS = { - // chips over shape_item / shape_item_round -> selector_item - {"adapter_type.xml", "@color/selector_text"}, - {"adapter_collect.xml", "@color/selector_text"}, - // episode / quality / flag rows over shape_video_item - {"adapter_episode_grid.xml", "@color/selector_video_text"}, - {"adapter_episode_group.xml", "@color/selector_video_text"}, - {"adapter_episode_hori.xml", "@color/selector_video_text"}, - {"adapter_flag.xml", "@color/selector_video_text"}, - {"adapter_quality.xml", "@color/selector_video_text"}, - }; - - /** Pages whose rows sit directly on the (dark) app wallpaper. */ - private static final String[] WALLPAPER_PAGES = { - "fragment_setting.xml", "fragment_setting_ad.xml", "fragment_setting_ai.xml", - "fragment_setting_danmaku.xml", "fragment_setting_enhance.xml", - "fragment_setting_personal.xml", "fragment_setting_player.xml", - "fragment_setting_subtitle.xml", "fragment_setting_tmdb.xml", - }; - - /** - * Player bottom sheets whose own root background is a dark translucent - * indigo glass panel, never a light one. - * - *

{@code shape_player_child_sheet_panel}, {@code shape_dialog_glass_panel}, - * {@code shape_dialog_control_glass_panel}, {@code shape_quick_search_panel}, - * {@code shape_danmaku_sheet_panel} and {@code shape_danmaku_setting_panel} are all - * {@code #E62F315E -> #D6282955 -> #CC303463} (or {@code #E61F1F22}); composited over - * any frame they stay dark, so their foreground must not follow the palette. - * - *

These are the mobile peers of the leanback sheets. Historically leanback used the - * identical drawables with {@code ?attr/colorOnSurface}, which worked on TV only because - * the leanback table was dark in every profile ({@code webhtv_color_on_surface} = - * {@code #E2E2E9}). Now that TV resolves the same day/night tables as mobile, the TV - * sheets carry the same palette-independent constants as these mobile ones, so both - * flavours stay readable in both modes. - * - *

{@code 0af2340d4} replaced the flat light values ({@code @color/white}, - * {@code white_70}, {@code white_90}, {@code white_50/60}) in exactly these files, and - * this list is that change's inverse. The fill is constant in every state, so a flat - * light foreground is correct and no state list is needed. - */ - private static final String[] DARK_GLASS_SHEETS = { - "dialog_danmaku.xml", "dialog_danmaku_search.xml", "dialog_danmaku_setting.xml", - "dialog_episode_list.xml", "dialog_live.xml", "dialog_live_epg.xml", - "dialog_offset.xml", "dialog_quick_search.xml", "dialog_timer.xml", - "dialog_title.xml", "dialog_track.xml", "dialog_video_content.xml", - }; - - /** - * Palette-independent foregrounds that are correct on a dark surface in every table. - * - *

{@code webhtv_on_wallpaper} and {@code webhtv_color_player_control_muted} are - * {@code #FFFFFF} / {@code #CCFFFFFF} in both token tables (the day and night files), - * which is what makes them safe on a surface whose darkness does not - * depend on the palette. {@code webhtv_color_overlay_light} is the matching constant - * ripple fill. They are semantic roles, not raw values, so the sheets stay inside the - * token system and need no allowlist exemption of their own. - */ - private static final String[] CONSTANT_LIGHT_FOREGROUNDS = { - "?attr/webhtvColorOnWallpaper", - "@color/webhtv_color_player_control_muted", - "@color/webhtv_color_overlay_light", - "@color/selector_control_sheet_text", - }; - - /** - * The sheet button state list must stay palette-independent too. - * - *

{@code dialog_danmaku_setting} and {@code dialog_timer} paint their option buttons - * with {@code @color/selector_control_sheet_text} over - * {@code selector_player_child_sheet_button}, a dark translucent fill. The list resolves - * to {@code @color/white} / {@code #B3FFFFFF} plus the black selected/activated pair - * that matches the light selected band, so it never follows the palette. Routing it - * through {@code ?attr/colorOnSurface} would put near-black text on the dark sheet in - * day mode, exactly like the layouts above. - */ - @Test - public void controlSheetButtonTextStaysPaletteIndependent() throws Exception { - String selector = withoutComments(read("src/mobile/res/color/selector_control_sheet_text.xml")); - assertFalse("the sheet button text must not follow the palette", - selector.contains("?attr/")); - assertTrue(selector.contains("@color/white")); - assertTrue(selector.contains("#B3FFFFFF")); - assertTrue("the selected state must stay dark on the light selected band", - selector.contains("#FF000000")); - } - - /** Attributes that paint a foreground onto the sheet's own dark panel. */ - private static final String[] SHEET_FOREGROUND_ATTRS = { - "android:textColor", "android:textColorHint", "app:tint", - "app:strokeColor", "app:rippleColor", "app:boxStrokeColor", - }; - - @Test - public void flavourBaseThemesInheritTheWebhtvSemanticTheme() throws Exception { - String mobile = read("src/mobile/res/values/styles.xml"); - assertTrue(mobile.contains("", start); - assertTrue(style + " is not closed", end > start); - return source.substring(start, end); - } - - private static String read(String path) throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeBinderContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeBinderContractTest.java deleted file mode 100644 index 7ca5c95547..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeBinderContractTest.java +++ /dev/null @@ -1,622 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Set; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -public class ThemeBinderContractTest { - - @Test - public void explicitRoleTagsAreParsedCaseInsensitivelyAndBounded() { - assertEquals(ThemeRole.PRIMARY, ThemeRole.fromTag("webhtv:primary")); - assertEquals(ThemeRole.PRIMARY, ThemeRole.fromTag(" webhtv:PRIMARY ")); - assertEquals(ThemeRole.SURFACE_CONTAINER_HIGH, ThemeRole.fromTag("webhtv:surface_container_high")); - assertNull(ThemeRole.fromTag("webhtv:")); - assertNull(ThemeRole.fromTag("webhtv:playerControl")); - assertNull(ThemeRole.fromTag("something-else")); - assertNull(ThemeRole.fromTag(null)); - assertNull(ThemeRole.fromTag(42)); - } - - @Test - public void rolesExposeOnlyUserSlotsAndTheirDerivedForegrounds() { - int userSlots = 0; - for (ThemeRole role : ThemeRole.values()) if (role.isUserSlot()) userSlots++; - assertEquals(13, userSlots); - for (ThemeRole role : ThemeRole.values()) { - assertTrue(role.name(), role.name().matches("[A-Z_]+")); - } - // Player, media, health and brand roles must not be representable. - assertNull(ThemeRole.fromTag("webhtv:player_control")); - assertNull(ThemeRole.fromTag("webhtv:health_good")); - } - - @Test - public void indexResolvesUniqueBaselineColorsOnly() { - ThemeTokens baseline = ThemeTokens.light(); - ThemeColorIndex index = ThemeColorIndex.of(baseline); - assertFalse(index.isEmpty()); - assertEquals(ThemeRole.ERROR, index.uniqueRoleFor(baseline.colorError())); - assertEquals(ThemeRole.SURFACE, index.uniqueRoleFor(baseline.colorSurface())); - // The light palette deliberately shares one blue between primary and focus, - // so that value is ambiguous and the binder requires both to agree. - assertTrue(index.rolesFor(baseline.colorFocus()).contains(ThemeRole.PRIMARY)); - assertTrue(index.rolesFor(baseline.colorFocus()).contains(ThemeRole.FOCUS)); - assertNull(index.uniqueRoleFor(0xFF123456)); - assertNull(ThemeColorIndex.of(null).uniqueRoleFor(0xFF123456)); - assertTrue(ThemeColorIndex.of(null).isEmpty()); - } - - @Test - public void sharedBaselineColorsAreReportedAsAmbiguous() { - ThemeColorIndex index = ThemeColorIndex.of(ThemeTokens.light()); - Set roles = index.rolesFor(0xFFFFFFFF); - assertTrue("white must be shared by several foreground roles", roles.size() > 1); - assertNull(index.uniqueRoleFor(0xFFFFFFFF)); - } - - @Test - public void replacementIsReturnedOnlyWhenEverySharingRoleAgrees() { - ThemeTokens baseline = ThemeTokens.light(); - ThemeColorIndex index = ThemeColorIndex.of(baseline); - - ThemeProfile outlineProfile = ThemeProfile.defaultProfile(); - outlineProfile.light.outline = "#000000"; - ThemeTokens outlineActive = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, outlineProfile, null, false); - assertEquals(Integer.valueOf(0xFF000000), index.replacementFor(baseline.colorOutline(), outlineActive)); - - // White is shared by onPrimary/onSecondary/onTertiary; overriding only the - // primary makes those roles disagree, so the binder must keep the static color. - ThemeProfile primaryProfile = ThemeProfile.defaultProfile(); - primaryProfile.light.primary = "#FFFFFF"; - ThemeTokens primaryActive = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, primaryProfile, null, false); - assertNull(index.replacementFor(baseline.colorOnPrimary(), primaryActive)); - } - - @Test - public void unknownAndUnchangedColorsAreNeverRewritten() { - ThemeTokens baseline = ThemeTokens.light(); - ThemeColorIndex index = ThemeColorIndex.of(baseline); - assertNull(index.replacementFor(0xFF123456, ThemeTokens.dark())); - assertNull(index.replacementFor(baseline.colorPrimary(), baseline)); - assertNull(index.replacementFor(baseline.colorPrimary(), null)); - } - - @Test - public void binderIsControlledMainThreadOnlyAndPlayerSafe() throws Exception { - String source = read("src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java"); - assertTrue(source.contains("Looper.myLooper() != Looper.getMainLooper()")); - assertTrue(source.contains("baseline.equals(active)")); - assertTrue(source.contains("webhtv:ignore")); - assertTrue(source.contains("addOnChildAttachStateChangeListener")); - assertTrue(source.contains("isStateful()")); - assertFalse("no Resources/AssetManager reflection", source.contains("AssetManager")); - assertFalse("no reflection bypass", source.contains("setAccessible")); - assertFalse("no style application bypass", source.contains("applyStyle")); - // Class#getRecordComponents does not exist on Android API < 33 and crashed - // the app on API 28; the signature must stay reflection-free. - assertFalse("no Java-16 record reflection on the runtime path", - source.contains("getRecordComponents()") || source.contains("getDeclaredConstructor(")); - assertTrue(source.contains("tokens.hashCode()")); - assertTrue(source.contains("WeakHashMap")); - } - - @Test - public void everyActivityBindsTheThemeTreeTwice() throws Exception { - for (String flavour : new String[]{"mobile", "leanback"}) { - String source = read("src/" + flavour + "/java/com/fongmi/android/tv/ui/base/BaseActivity.java"); - int content = source.indexOf("View content = getBinding().getRoot();"); - int setContent = source.indexOf("setContentView(content);", content); - int first = source.indexOf("ThemeController.bindTheme(content);", setContent); - int second = source.indexOf("ThemeController.bindTheme(content);", first + 1); - assertTrue(flavour + " must resolve one inflated binding as the content view", content > 0); - assertTrue(flavour + " must bind after setContentView", setContent > content && first > setContent); - assertTrue(flavour + " must re-bind after initView", second > first); - assertTrue(flavour + " must bind before initEvent", - source.indexOf("initEvent();", second) > second); - } - } - - @Test - public void presetSeedsMustResolveToTheirOwnPaletteInsteadOfFallingBack() { - for (int seed : new int[]{0xFF0B57D0, 0xFF00897B, 0xFF146C2E, 0xFFFB8C00, 0xFFB3261E, 0xFF8E24AA}) { - String hex = "#" + Integer.toHexString(seed).toUpperCase(java.util.Locale.ROOT); - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.EXPLICIT, seed, 0, null, null, false); - String diagnostic = ThemeResolver.lastDiagnostic(); - assertFalse("seed " + hex + " silently fell back: " + diagnostic, - diagnostic.startsWith("fallback")); - assertNotEquals("seed " + hex + " resolved to the frozen palette", - ThemeTokens.light().colorPrimary(), tokens.colorPrimary()); - } - } - - @Test - public void backgroundChannelCoversMaterialShapeAndInsetWrappers() throws Exception { - String source = read("src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java"); - assertTrue("must unwrap Material's inset window background", source.contains("instanceof InsetDrawable inset")); - assertTrue("must reopen the wrapped drawable", source.contains("bindDrawable(inset.getDrawable()")); - assertTrue("must cover Material shape panels", source.contains("instanceof MaterialShapeDrawable shape")); - assertTrue(source.contains("shape.getFillColor()")); - assertTrue(source.contains("shape.setFillColor(color)")); - // Still no reflection or hidden API on the widening path. - assertFalse(source.contains("setAccessible")); - assertFalse(source.contains("getDeclaredField")); - } - - /** - * Every Material alert dialog must be constructed through the themed builder, - * whose overridden {@code create()} binds the dialog window to the active tokens. - * Raw {@code MaterialAlertDialogBuilder} instances were never passed to - * {@code bindDialog}, which is why a custom theme used to leave nearly every - * dialog on the compiled palette. - * - /** - * A {@code themeResId} handed to a dialog builder must resolve {@code colorPrimary} - * to something concrete. {@code MaterialAlertDialogBuilder} validates the dialog - * context through {@code ThemeEnforcement.checkAppCompatTheme}, which only checks - * that {@code ?attr/colorPrimary} exists - so an overlay whose *nearest* definition - * of the role is the self-reference {@code ?attr/colorPrimary} throws inside the - * constructor. That is exactly how "cancel following" crashed with - * {@code ThemeOverlay.WebHTV.FollowingConfirmDialog}. - * - *

Inheritance is honoured: {@code ThemeOverlay.WebHTV.Dialog.NoInset} defines no - * primary of its own and safely inherits the concrete token from - * {@code ThemeOverlay.WebHTV.Dialog}. Only the nearest definition decides, because a - * child that re-declares the role as {@code ?attr/colorPrimary} shadows its ancestor - * and resolves to nothing. - */ - @Test - public void dialogThemeArgumentsResolveColorPrimaryConcretely() throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - java.util.Map body = new java.util.HashMap<>(); - java.util.Map parent = new java.util.HashMap<>(); - java.util.regex.Pattern stylePattern = java.util.regex.Pattern - .compile("", - java.util.regex.Pattern.DOTALL); - for (Path res : Files.walk(root.resolve("src")).filter(p -> p.toString().endsWith(".xml")).toList()) { - java.util.regex.Matcher m = stylePattern.matcher(Files.readString(res, StandardCharsets.UTF_8)); - while (m.find()) { - body.put(m.group(1), m.group(3)); - if (m.group(2) != null) parent.put(m.group(1), m.group(2)); - } - } - java.util.List offenders = new java.util.ArrayList<>(); - java.util.regex.Pattern usage = java.util.regex.Pattern - .compile("(?:WebHtvAlertDialogBuilder|MaterialAlertDialogBuilder)\\([^;]*?R\\.style\\.(ThemeOverlay_[A-Za-z_]+)", - java.util.regex.Pattern.DOTALL); - for (Path sourceFile : Files.walk(root.resolve("src")).filter(p -> p.toString().endsWith(".java")).toList()) { - if (sourceFile.toString().contains("graphify-out")) continue; - java.util.regex.Matcher m = usage.matcher(Files.readString(sourceFile, StandardCharsets.UTF_8)); - while (m.find()) { - String name = m.group(1).replace('_', '.'); - String nearest = null; - for (int hop = 0; hop < 24 && name != null; hop++) { - String block = body.get(name); - if (block != null) { - java.util.regex.Matcher item = java.util.regex.Pattern - .compile("([^<]+)").matcher(block); - if (item.find()) { nearest = item.group(1).trim(); break; } - } - name = parent.get(name); - } - if (nearest == null || !nearest.startsWith("@color/")) { - offenders.add(sourceFile.getFileName() + " -> " + m.group(1) + " resolves colorPrimary to " + nearest); - } - } - } - assertTrue("these dialog themes cannot satisfy ThemeEnforcement: " + offenders, offenders.isEmpty()); - } - - @Test - public void materialAlertDialogsAreBuiltThroughTheThemedBuilder() throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - // CrashActivity is deliberately exempt: the recovery screen runs in its own - // process with Theme.Crash and must render identically no matter what palette - // the user saved, so it must not depend on the theme contract at all. - Set exempt = Set.of("CrashActivity.java"); - // Matches `new AlertDialog.Builder(`, `new android.app.AlertDialog.Builder(` and - // `new androidx.appcompat.app.AlertDialog.Builder(` alike, but never the themed - // `new WebHtvAlertDialogBuilder(`. - java.util.regex.Pattern forbidden = java.util.regex.Pattern.compile( - "new\\s+(?:[A-Za-z_][\\w.]*\\.)?AlertDialog\\.Builder\\("); - // Same shape for the Material builder: the qualified `new com.google.android.material - // .dialog.MaterialAlertDialogBuilder(` must be caught too. The previous version used a - // literal `contains("new MaterialAlertDialogBuilder(")`, so every call site that spelled - // the class out in full slipped through - five of them in the two VideoActivity files - // were building dialogs that never reached ThemeController.bindDialog, which meant a - // user-chosen theme silently did not apply to them. - java.util.regex.Pattern forbiddenMaterial = java.util.regex.Pattern.compile( - "new\\s+(?:[A-Za-z_][\\w.]*\\.)?MaterialAlertDialogBuilder\\("); - java.util.List raw = new java.util.ArrayList<>(); - for (String sourceSet : new String[]{"main", "mobile", "leanback"}) { - Path base = root.resolve("src/" + sourceSet); - if (!Files.exists(base)) continue; - try (java.util.stream.Stream paths = Files.walk(base)) { - for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) { - if (path.toString().contains("graphify-out")) continue; - if (exempt.contains(path.getFileName().toString())) continue; - String text = Files.readString(path, StandardCharsets.UTF_8); - if (forbiddenMaterial.matcher(text).find()) { - raw.add(sourceSet + "/" + path.getFileName() + " -> MaterialAlertDialogBuilder"); - } - if (forbidden.matcher(text).find()) { - raw.add(sourceSet + "/" + path.getFileName() + " -> AlertDialog.Builder"); - } - } - } - } - assertTrue("build these through WebHtvAlertDialogBuilder instead: " + raw, raw.isEmpty()); - } - - /** - * The editor exposes a dialog-opacity slider, and the design record documents it as - * "the dialog/BottomSheet shell only, never the text alpha". It used to reach only the - * preview swatch and the Web snapshot, so on a real dialog the lower bound and the upper - * bound produced byte-identical screens - measured on device as 0 changed pixels. The - * window background is the shell, so it must carry the opacity. - */ - @Test - public void dialogOpacityReachesTheWindowShellAndNotTheText() throws Exception { - String binder = read("src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java"); - assertTrue("the window background must carry the shell opacity", - binder.contains("applyShellOpacity(drawable, active.dialogOpacity())")); - assertTrue("shell opacity must unwrap Material's inset wrapper", - binder.contains("applyShellOpacity(inset.getDrawable(), opacity)")); - // 1.0 is the shipped default: it must stay a strict no-op. - assertTrue("a fully opaque shell must be left untouched", binder.contains("if (drawable == null || opacity >= 1f) return false;")); - // Text alpha is governed by the view-tree pass, which this method never touches. - assertFalse("shell opacity must not be applied through the view lambda", - binder.contains("applyShellOpacity(view")); - - // Alpha arithmetic, including the documented 0.70..1.00 bounds. - assertEquals("default opacity must be a no-op", 0xFF7B1FA2, ThemeBinder.scaleAlpha(0xFF7B1FA2, 1.0f)); - assertEquals("lower bound", 0xB37B1FA2, ThemeBinder.scaleAlpha(0xFF7B1FA2, 0.70f)); - // 0.70 * 255 = 178.5, and Math.round takes it up to 179. - assertEquals("matches the clamped manual computation", 179, ThemeBinder.scaleAlpha(0xFF7B1FA2, 0.70f) >>> 24); - assertEquals("existing transparency must be preserved, not replaced", - 0x407B1FA2, ThemeBinder.scaleAlpha(0x807B1FA2, 0.50f)); - assertEquals("out-of-range values must clamp, never wrap", - 0xFF7B1FA2, ThemeBinder.scaleAlpha(0xFF7B1FA2, 2.0f)); - } - - @Test - public void dialogWindowBackgroundIsRecolouredOutsideTheViewTree() throws Exception { - String binder = read("src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java"); - assertTrue(binder.contains("public static boolean bindWindowBackground(Drawable drawable, ThemeTokens baseline, ThemeTokens active)")); - assertTrue("must reuse the shared drawable pass", - binder.contains("bindDrawable(drawable, null, ThemeColorIndex.of(baseline), active)")); - - String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); - assertTrue(controller.contains("public static void bindWindowBackground(Drawable background)")); - - String builder = read("src/main/java/com/fongmi/android/tv/theme/WebHtvAlertDialogBuilder.java"); - // The panel is the builder's own background field, which is the exact instance - // handed to Window.setBackgroundDrawable by MaterialAlertDialogBuilder. - assertTrue(builder.contains("ThemeController.bindWindowBackground(getBackground())")); - assertTrue(builder.contains("ThemeController.bindDialog(dialog)")); - } - - @Test - public void followingPageOptsIntoTheSharedAppearanceContract() throws Exception { - String source = read("src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); - int apply = source.indexOf("ThemeController.applyFromPreferences(this);"); - int setContent = source.indexOf("setContentView(binding.getRoot());"); - int first = source.indexOf("ThemeController.bindTheme(binding.getRoot());"); - int initView = source.indexOf("initView();"); - int second = source.indexOf("ThemeController.bindTheme(binding.getRoot());", first + 1); - assertTrue("must resolve tokens before the first content view", apply > 0 && apply < setContent); - assertTrue("must bind right after setContentView", first > setContent); - assertTrue("must re-bind after initView", second > first && initView > first && second > initView); - } - - /** - * Every Activity that hosts native views must participate in the theme contract. - * Activities outside BaseActivity are the exception and are pinned here so a new - * one cannot silently ship with a statically coloured tree. - */ - @Test - public void nativeActivitiesOutsideBaseActivityAreExplicitlyAccountedFor() throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - Set exempt = Set.of( - "LabActivity", "LabDetailActivity", "LabOutputActivity", "LabTerminalActivity", - "CatWebActivity", "GameWebActivity", "WebReaderActivity"); - java.util.List unbound = new java.util.ArrayList<>(); - try (java.util.stream.Stream paths = Files.walk(root.resolve("src/main/java/com/fongmi/android/tv"))) { - for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) { - String source = Files.readString(path, StandardCharsets.UTF_8); - if (!source.contains("extends AppCompatActivity")) continue; - String name = path.getFileName().toString().replace(".java", ""); - if (exempt.contains(name)) continue; - if (!source.contains("ThemeController.bindTheme(")) unbound.add(name); - } - } - assertTrue("these activities host native views but never bind the theme: " + unbound, unbound.isEmpty()); - } - - @Test - public void binderRewritesTheMaterialButtonAndBackgroundTintChannels() throws Exception { - String source = read("src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java"); - assertTrue(source.contains("instanceof MaterialButton button")); - assertTrue(source.contains("view.getBackgroundTintList()")); - assertTrue(source.contains("view.setBackgroundTintList(tint)")); - assertTrue(source.contains("view.setStrokeColor(stroke)")); - assertTrue(source.contains("view.setIconTint(icon)")); - // The visible contract behind this: native pages fill their primary - // buttons through app:backgroundTint, not through a GradientDrawable. - String following = read("src/main/res/layout/activity_following.xml"); - assertTrue(following.contains("app:backgroundTint=\"?attr/colorPrimary\"")); - } - - @Test - public void alertDialogFamiliesBindThroughTheSharedDialogChannel() throws Exception { - String light = read("src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java"); - assertEquals("both LightDialog entry points must bind", - 2, light.split("ThemeController\\.bindDialog\\(dialog\\)", -1).length - 1); - assertTrue("LightDialog must bind the window shell as well as the view tree", - light.contains("ThemeController.bindWindowBackground(background)")); - assertTrue("custom LightDialog shells must use the active semantic surface", - light.contains("ThemeController.current().colorSurfaceContainerHigh()")); - // dialogOpacity has exactly one owner: ThemeBinder.bindWindowBackground. Pre-multiplying - // it into the shape scaled the value twice (0.70 -> 0.49) and made the fill stop matching - // its baseline role, so the shell no longer followed a surfaceContainerHigh override. - assertFalse("the shell must ship opaque so dialogOpacity is applied only once", - light.contains("withAlpha(tokens.colorSurfaceContainerHigh(), tokens.dialogOpacity())")); - assertFalse("the dialog title must not be a hard-coded hex colour", - light.contains("Color.parseColor(\"#202124\")")); - assertTrue(light.contains("titleView.setTextColor(ThemeController.current().colorOnSurface())")); - - String base = read("src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java"); - assertTrue(base.contains("ThemeController.bindDialog(dialog)")); - assertFalse("MaterialAlertDialogBuilder owns the dialog OnShowListener", - base.contains("setOnShowListener")); - - // The channels the binder rewrites must really resolve through the - // semantic tokens, otherwise the exact-match guard could never fire. - for (String name : new String[]{"dialog_primary_button_bg", "dialog_primary_button_text", - "dialog_outlined_button_bg", "dialog_outlined_button_text", "dialog_outlined_button_stroke"}) { - String selector = read("src/main/res/color/" + name + ".xml"); - assertTrue(name + " must resolve through webhtv tokens", - selector.contains("@color/webhtv_color_")); - } - } - - @Test - public void seedDerivedTokensStayMappableFromTheFrozenBaseline() { - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0xFFB3261E, 0, null, null, false); - assertNotEquals("an explicit seed must really move the palette", - baseline.colorPrimary(), active.colorPrimary()); - // The seed derives focus from primary, so the shared-baseline ambiguity - // resolves and the view colour can be rewritten. - assertEquals(Integer.valueOf(active.colorPrimary()), - ThemeColorIndex.of(baseline).replacementFor(baseline.colorPrimary(), active)); - assertEquals(Integer.valueOf(active.colorSurface()), - ThemeColorIndex.of(baseline).replacementFor(baseline.colorSurface(), active)); - } - - @Test - public void aSeedDerivedBaselineWouldSilentlyDisableTheBinder() { - ThemeTokens staticPalette = ThemeTokens.light(); - ThemeTokens active = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0xFFB3261E, 0, null, null, false); - ThemeTokens seedBaseline = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0xFFB3261E, 0, null, null, false); - // Regression guard for the shipped defect: when the baseline was resolved - // through the legacy theme_color seed it matched no inflated view colour, - // so the binder bound nothing outside the legacy site dialog. - assertNull(ThemeColorIndex.of(seedBaseline) - .replacementFor(staticPalette.colorPrimary(), active)); - assertNull(ThemeColorIndex.of(seedBaseline) - .replacementFor(staticPalette.colorSurface(), active)); - } - - /** - * The TV flavour compiles the dark table into {@code values/} and ships no light - * table of its own, yet {@code frozenPalette()} used to derive the baseline from - * uiMode alone. On a light-mode device the baseline was therefore - * {@link ThemeTokens#light()} while inflation produced {@link ThemeTokens#dark()}; - * because every rewrite requires an exact baseline match, the whole TV theme - * channel bound nothing. Device evidence behind this guard: the same probe against - * the same build changed 0 pixels on TV while changing ~49k pixels on mobile, and - * switching the device to dark mode made TV respond (159174 px). - */ - @Test - public void tvFlavourBaselineMustDescribeTheCompiledResources() throws Exception { - ThemeTokens light = ThemeTokens.light(); - ThemeTokens compiled = ThemeTokens.dark(); - ThemeColorIndex fromLight = ThemeColorIndex.of(light); - ThemeColorIndex fromCompiled = ThemeColorIndex.of(compiled); - - // A light baseline cannot see a single colour the TV build actually inflated. - assertNull(fromLight.replacementFor(compiled.colorPrimary(), light)); - assertNull(fromLight.replacementFor(compiled.colorSurface(), light)); - // The compiled table does index them, so the corrected baseline can bind again. - assertFalse(fromCompiled.rolesFor(compiled.colorPrimary()).isEmpty()); - assertFalse(fromCompiled.rolesFor(compiled.colorSurface()).isEmpty()); - - // Pin the fix itself: the baseline must be read from the compiled resources - // instead of being inferred from uiMode, and it must be read through the context - // that inflates the views. Probing the application context instead re-broke the - // same invariant the other way round: on API < 31 a forced MODE_NIGHT_YES only - // reaches the activity's resources, so the application context still reported the - // light table while every inflated ?attr/color* was dark. - String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); - assertTrue(controller.contains("compiledDarkPalette(context)")); - assertTrue(controller.contains("R.color.webhtv_color_primary")); - assertTrue(controller.contains("R.color.webhtv_color_on_warning")); - assertTrue(controller.contains("resolvedDark(context)")); - assertTrue(controller.contains("baseline = frozenPalette(activity);")); - assertTrue(controller.contains("current = resolveWith(profile, activity);")); - assertFalse("the probe must not read the application context as its primary source", - controller.contains("Context context = App.get();")); - assertFalse("the frozen palette must not be derived from uiMode alone", - controller.contains("ThemeResolver.resolve(currentThemeMode(), ThemeSeed.NONE, 0, 0, null, null, systemDark)")); - } - - /** - * Pins the shared dark/light decision so the TV fix cannot silently re-break, and so - * the mobile answer is provably unchanged: - * - *

    - *
  • mobile light/dark: the compiled table equals the canonical table, so the - * result matches the pre-fix {@code mode}/{@code uiMode} answer; - *
  • TV: the compiled table is dark in every configuration, so TV stays dark even - * on a light-mode device or when the user explicitly picked light; - *
  • unidentifiable table: the historical rule is kept instead of guessing. - *
- */ - @Test - public void darkPaletteDecisionKeepsMobileStableAndForcesTvOntoItsCompiledTable() { - // Mobile: compiled table matches the canonical one, so both branches agree and - // the pre-fix behaviour is preserved exactly. - assertFalse(ThemeController.darkPaletteFor(false, ThemeMode.SYSTEM, false)); - assertTrue(ThemeController.darkPaletteFor(true, ThemeMode.SYSTEM, true)); - assertTrue(ThemeController.darkPaletteFor(true, ThemeMode.DARK, false)); - - // TV: the compiled table wins over a light system or an explicit light choice. - assertTrue(ThemeController.darkPaletteFor(true, ThemeMode.SYSTEM, false)); - assertTrue(ThemeController.darkPaletteFor(true, ThemeMode.LIGHT, false)); - assertTrue(ThemeController.darkPaletteFor(true, ThemeMode.LIGHT, true)); - - // Unidentifiable table: keep the historical rule rather than guessing. - assertFalse(ThemeController.darkPaletteFor(null, ThemeMode.SYSTEM, false)); - assertTrue(ThemeController.darkPaletteFor(null, ThemeMode.SYSTEM, true)); - assertFalse(ThemeController.darkPaletteFor(null, ThemeMode.LIGHT, true)); - assertTrue(ThemeController.darkPaletteFor(null, ThemeMode.DARK, false)); - - // The canonical tables must stay identifiable from the flavour resources; a - // drift here is what silently disabled every TV rewrite. - assertNotEquals(ThemeTokens.light().colorPrimary(), ThemeTokens.dark().colorPrimary()); - assertNotEquals(ThemeTokens.light().colorSurface(), ThemeTokens.dark().colorSurface()); - } - - @Test - public void bottomSheetsBindThroughTheSharedDialogChannel() throws Exception { - String sheet = read("src/main/java/com/fongmi/android/tv/ui/dialog/BaseBottomSheetDialog.java"); - assertTrue(sheet.contains("ThemeController.bindDialog(dialog)")); - assertTrue(sheet.contains("bindDialogTheme();")); - String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); - assertTrue(controller.contains("public static void bindTheme(View root)")); - assertTrue(controller.contains("public static void bindDialog(Dialog dialog)")); - assertTrue(controller.contains("public static boolean hasProfileOverrides()")); - assertTrue(controller.contains("ThemeProfileStore.load()")); - } - - /** - * scrimOpacity has to stay a strict no-op while unset. - * - *

The modal scrims in this app do not use the token colour: the episode-detail and - * TMDB-person dialogs paint a translucent white scrim in light mode and translucent - * black in dark. Replacing those with {@code colorScrim()} (translucent black in both) - * was measured to invert the light-mode scrim, so the wiring keeps each dialog's own - * colour and only replaces its alpha when the user explicitly set the slot. This pins - * the two halves of that contract: an unset slot must return the shipped literal - * untouched, and a set slot must change only the alpha. - */ - @Test - public void scrimOpacityKeepsTheDialogOwnColourAndOnlyMovesAlphaWhenSet() throws Exception { - String episode = read("src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java"); - String person = read("src/main/java/com/fongmi/android/tv/ui/dialog/TmdbPersonDialog.java"); - String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); - for (String source : new String[]{episode, person}) { - // The shipped literals must survive as the base colour... - assertTrue(source.contains("0x99F4F7FA")); - assertTrue(source.contains("applyScrimOpacity(")); - // ...the token must NOT be substituted for the dialog's own scrim colour... - assertFalse(source.contains("colorScrim()")); - // ...and the scrim maths must be delegated, not re-implemented. - assertTrue(source.contains( - "ThemeController.applyScrimOpacity(base, ThemeController.configuredScrimOpacity(light))")); - } - - // Behaviour of the shared maths, asserted directly rather than by string match. - int lightScrim = 0x99F4F7FA; - int darkScrim = 0xB3000000; - // Unset is a strict no-op: this is what keeps the shipped look byte-identical. - assertEquals(lightScrim, ThemeController.applyScrimOpacity(lightScrim, null)); - assertEquals(darkScrim, ThemeController.applyScrimOpacity(darkScrim, null)); - // Set replaces only the alpha, preserving hue. - assertEquals(0x00F4F7FA, ThemeController.applyScrimOpacity(lightScrim, 0f)); - // clamped low - assertEquals(0x00F4F7FA, ThemeController.applyScrimOpacity(lightScrim, -1f)); - assertEquals(0xFFF4F7FA, ThemeController.applyScrimOpacity(lightScrim, 1f)); - // clamped high - assertEquals(0xFFF4F7FA, ThemeController.applyScrimOpacity(lightScrim, 2f)); - assertEquals(0xD9F4F7FA, ThemeController.applyScrimOpacity(lightScrim, 0.85f)); - assertEquals(0xD9000000, ThemeController.applyScrimOpacity(darkScrim, 0.85f)); - // The accessor must report "unset" rather than the shipped default, otherwise the - // default rendering could change whenever the stored value equals the default. - assertTrue(controller.contains("public static Float configuredScrimOpacity(boolean light)")); - assertTrue(controller.contains("Float value = slots.scrimOpacity;")); - assertTrue(controller.contains("if (value == null || !Float.isFinite(value)) return null;")); - // The slot must be read from the same light/dark answer the dialog resolved. - assertTrue(controller.contains("ThemeProfile.SlotSet slots = light ? active.light : active.dark;")); - } - - /** - * The accessor must distinguish "user set the slot" from "the value happens to equal - * the shipped default", because the dialogs only move their own scrim alpha when the - * slot is genuinely set. Reads the private active profile via reflection rather than - * adding a test-only production setter. - */ - @Test - public void configuredScrimOpacityReportsUnsetSeparatelyFromAnyValue() throws Exception { - java.lang.reflect.Field field = ThemeController.class.getDeclaredField("profile"); - field.setAccessible(true); - Object previous = field.get(null); - try { - ThemeProfile profile = ThemeProfile.defaultProfile(); - - field.set(null, profile); - assertNull("an untouched profile must read as unset", ThemeController.configuredScrimOpacity(true)); - assertNull("an untouched profile must read as unset", ThemeController.configuredScrimOpacity(false)); - - profile.light.scrimOpacity = 0.85f; - assertEquals(Float.valueOf(0.85f), ThemeController.configuredScrimOpacity(true)); - assertNull("the dark half stays unset when only light was set", - ThemeController.configuredScrimOpacity(false)); - - // A value equal to the token baseline is still a user choice, not "unset". - profile.light.scrimOpacity = 0.322f; - assertEquals(Float.valueOf(0.322f), ThemeController.configuredScrimOpacity(true)); - - profile.dark.scrimOpacity = 0f; - assertEquals(Float.valueOf(0f), ThemeController.configuredScrimOpacity(false)); - } finally { - field.set(null, previous); - } - } - - /** - * overlayOpacity has no correct native landing site (its token is a divider colour and - * the only real image veil uses the frozen overlayDark), so it is documented in the - * editor as web-only instead of being wired to a wrong surface. - */ - @Test - public void overlayOpacityIsLabelledAsWebOnly() throws Exception { - assertTrue(read("src/main/res/values/strings.xml") - .contains("Overlay opacity (web theme only)")); - assertTrue(read("src/main/res/values-zh-rCN/strings.xml") - .contains("浮层透明度(仅 Web 主题生效)")); - assertTrue(read("src/main/res/values-zh-rTW/strings.xml") - .contains("浮層透明度(僅 Web 主題生效)")); - } - - private static String read(String path) throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeBinderFocusRingTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeBinderFocusRingTest.java deleted file mode 100644 index f100478e53..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeBinderFocusRingTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 电视焦点环「跟随主题 FOCUS 槽」的源码契约。 - * - *

用户报告:电视版选中颜色不管怎么调都是原始颜色。根因是渲染层从未参与主题覆写: - * 全部焦点环都写在 {@code } 的 {@code state_focused} 子项里,而 - * {@code ThemeBinder} 过去只改 {@code GradientDrawable} 的实心填充,既不进入 - * {@code StateListDrawable} 子项,也从不处理 {@code android:foreground}(卡片族的环正是 - * foreground)。因此 {@code ThemeEditor.Slot.FOCUS} → {@code ThemeTokens.colorFocus()} - * 在编辑器预览与 Web 快照里都对,真实界面却纹丝不动。 - * - *

本测试把修法固化成四条:走公开 API(禁反射)、覆盖 selector 子项、覆盖 foreground、 - * 以及**只改写显式白名单内的应用表面焦点环**。 - * - *

最后一条是本轮评审修正的结果:最初版本按「视图主题解析出的 {@code tvFocusRing} 是否等于 - * {@code tv_item_focus_ring}」判定,但 {@code webhtv_styles.xml} 把该属性在所有相关主题上都绑到 - * 同一个资源,判据于是恒真,会把刻意使用 {@code ?attr/tvPlayerRing} 的固定深色玻璃族 - * (弹幕/音轨/字幕/播控条)也改成调色板 FOCUS 深蓝——浅色表在深靛玻璃上只有 1.83:1, - * 等于把这些焦点环改到看不见;同时会把 3dp 未收敛的卡片族强制改成 1.5dp,重新露出卡片 - * 圆角外的月牙。因此改为按资源身份白名单收窄,白名单外一律不碰。 - */ -public class ThemeBinderFocusRingTest { - - private static final String BINDER = "app/src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java"; - - @Test - public void binderWalksSelectorItemsAndForegroundDrawables() throws Exception { - String source = values(read(BINDER)); - assertTrue("binder 必须进入 StateListDrawable 子项,否则所有 焦点环永远不会跟随主题", - source.contains("StateListDrawable")); - assertTrue("binder 必须提供 bindFocusRingStates 处理聚焦态子项", - source.contains("bindFocusRingStates")); - assertTrue("binder 必须改写 android:foreground,卡片族的焦点环画在 foreground 上", - source.contains("getForeground()") && source.contains("bindForeground")); - assertTrue("聚焦态判定必须只匹配 state_focused 子项", - source.contains("android.R.attr.state_focused")); - } - - @Test - public void binderUsesOnlyPublicDrawableApis() throws Exception { - String source = values(read(BINDER)); - assertFalse("不允许用反射读 GradientDrawable 的 stroke(仓库契约禁止反射/隐藏 API)", - source.contains("setAccessible")); - assertFalse("不允许用反射取私有字段", - source.contains("getDeclaredField")); - assertTrue("改写走公开的 setStroke 与 mutate", - source.contains("setStroke(") && source.contains("mutate()")); - } - - @Test - public void ringRewritingIsScopedToAnExplicitWhitelist() throws Exception { - String source = values(read(BINDER)); - assertTrue("焦点环改写必须有显式白名单,否则用户改主题时会误伤 tvPlayerRing 玻璃族与 3dp 未收敛族", - source.contains("APP_SURFACE_FOCUS_RINGS")); - assertTrue("白名单必须按资源名字解析(getIdentifier):这些 selector 只在 leanback 源集," - + "main 源集硬引用 R.drawable.* 会让 mobile 变体 javac 失败", - source.contains("getIdentifier(")); - assertTrue("环宽必须引用唯一宽度 token,不得写死 1.5dp/2dp/3dp", - source.contains("R.dimen.webhtv_focus_ring_width")); - assertTrue("显式 webhtv:focus tag 必须能无条件指定 FOCUS 角色", - source.contains("ThemeRole.FOCUS")); - assertFalse("不允许再按「视图主题解析出的 tvFocusRing」作判据:webhtv_styles.xml 把该属性绑到 " - + "tv_item_focus_ring 后该判据恒真,会把 tvPlayerRing 玻璃族一并改成 colorFocus" - + "(浅色表 #0B57D0 在深靛玻璃上 1.83:1,等于不可见)", - source.contains("focusRingFollowsTheme")); - assertTrue("容器 StateListDrawable 必须先 mutate 再改子项,避免共享 ConstantState 连带改写其它宿主", - source.contains("states.mutate()")); - } - - private static String values(String source) { - return source.replaceAll("(?s)", " ").replaceAll("\\s+", " "); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeContractTest.java deleted file mode 100644 index a82860b15e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeContractTest.java +++ /dev/null @@ -1,212 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.lang.reflect.RecordComponent; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -public class ThemeContractTest { - - private static final Pattern COLOR = Pattern.compile("#([0-9A-Fa-f]{6,8})"); - - @Test - public void resourceMappingMatchesTheImmutableRecordForEveryColor() throws Exception { - assertResourceMapping(ThemeTokens.light(), Path.of("src/main/res/values/webhtv_tokens.xml")); - assertResourceMapping(ThemeTokens.dark(), Path.of("src/main/res/values-night/webhtv_tokens.xml")); - } - - @Test - public void allContrastPairsAreFrozenAndPass() { - ThemeTokens.light().requireContrast(); - ThemeTokens.dark().requireContrast(); - assertEquals(6.386, ThemeContrast.ratio(ThemeTokens.light().colorOnPrimary(), ThemeTokens.light().colorPrimary()), 0.002); - assertEquals(4.284, ThemeContrast.ratio(ThemeTokens.light().colorOutline(), ThemeTokens.light().colorSurface()), 0.002); - assertEquals(14.724, ThemeContrast.ratio(ThemeTokens.dark().colorPlayerControlActive(), 0xFF000000), 0.002); - } - - @Test - public void playerControlsAreIsolatedFromGeneralFocusAndPrimary() { - for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.light(), ThemeTokens.dark()}) { - assertNotEquals(tokens.colorPrimary(), tokens.colorPlayerControlActive()); - assertNotEquals(tokens.colorFocus(), tokens.colorPlayerControlActive()); - } - } - - @Test - public void allSemanticAttrsAreDeclared() throws Exception { - String attrs = Files.readString(Path.of("src/main/res/values/webhtv_attrs.xml"), StandardCharsets.UTF_8); - for (RecordComponent component : ThemeTokens.class.getRecordComponents()) { - if (!component.getType().equals(int.class)) continue; - assertTrue(component.getName(), attrs.contains("name=\"" + attrName(component.getName()) + "\"")); - } - assertFalse(attrs.contains("format=\"string\"")); - } - - /** - * A semantic attr that is declared and referenced but never assigned is a crash, not a - * silent fallback. - * - *

{@code ?attr/webhtvColorOnSurface} and {@code ?attr/webhtvColorOnSurfaceVariant} were - * declared in {@code webhtv_attrs.xml} and referenced by every {@code TextAppearance.WebHTV.*} - * style, but no theme ever assigned them - {@code Theme.WebHTV} only maps the standard - * Material attrs. Reading such a text appearance makes {@code TextView.readTextAppearance} - * call {@code TypedArray.getColorStateList} on an unresolvable attribute, which throws - * {@code UnsupportedOperationException} inside the constructor, so inflation dies. That is - * exactly how "TMDB data configuration" crashed on open (and with it every other dialog - * using {@code Widget.WebHTV.Label}/{@code Helper}/{@code SliderLabel}). - * - *

Guard: every referenced {@code webhtv*} attr must also be assigned by a theme, so the - * attrs and their assignments can never drift apart again. - */ - @Test - public void everyReferencedSemanticAttrIsAssignedByATheme() throws Exception { - Pattern reference = Pattern.compile("\\?attr/(webhtv[A-Za-z0-9_]+)"); - java.util.Set referenced = new java.util.TreeSet<>(); - for (String sourceSet : new String[]{"main", "mobile", "leanback"}) { - Path base = Path.of("src/" + sourceSet + "/res"); - if (!Files.exists(base)) continue; - try (java.util.stream.Stream paths = Files.walk(base)) { - for (Path path : paths.filter(p -> p.toString().endsWith(".xml")).toList()) { - Matcher matcher = reference.matcher(Files.readString(path, StandardCharsets.UTF_8)); - while (matcher.find()) referenced.add(matcher.group(1)); - } - } - } - - StringBuilder assignedSources = new StringBuilder(); - Path values = Path.of("src/main/res/values"); - try (java.util.stream.Stream paths = Files.walk(values)) { - for (Path path : paths.filter(p -> p.toString().endsWith(".xml")).toList()) { - if (path.getFileName().toString().equals("webhtv_attrs.xml")) continue; - assignedSources.append(Files.readString(path, StandardCharsets.UTF_8)); - } - } - String themes = assignedSources.toString(); - java.util.List unassigned = new java.util.ArrayList<>(); - for (String attr : referenced) { - // An assignment is an entry; the bare declaration is not enough. - if (!themes.contains("name=\"" + attr + "\">")) unassigned.add(attr); - } - assertTrue("these semantic attrs are referenced but no theme assigns them, so any view " - + "reading them crashes during inflation: " + unassigned, unassigned.isEmpty()); - } - - /** - * 与 {@link #everyReferencedSemanticAttrIsAssignedByATheme} 同一类陷阱,但针对 {@code tv*} 焦点语义属性。 - * - *

用户报告(2026-10-10):「现在选中播放器看不出选中的效果,好像是黑色的边框」。 - * 根因:{@code selector_control_sheet_button} 的焦点环从写死半透明白改成了 - * {@code ?attr/tvPlayerRing},而 {@code Theme.WebHTV.Dialog}(父主题 - * {@code Theme.Material3.DayNight.Dialog.Alert})与 BottomSheet 用的 Material 默认 - * {@code bottomSheetDialogTheme} 都**不继承** {@code Theme.WebHTV}。 - * 属性解析不到时,{@code ?attr/xxx} 会静默退化为黑色/透明,而且**不会崩溃**, - * 所以这种回归只有人眼才能发现。 - * - *

本测试把「每个被引用的 {@code tv*} 属性必须被至少一个主题 style 赋值」钉死, - * 并额外要求基底主题 {@code Theme.WebHTV} 自带全部五项——因为对话框主题的父主题链 - * 绕开了 flavour 的 {@code Theme.Base},只有在基底上绑定才能覆盖所有入口。 - */ - @Test - public void everyReferencedTvFocusAttrIsAssignedByATheme() throws Exception { - Pattern reference = Pattern.compile("\\?attr/(tv[A-Za-z0-9_]+)"); - java.util.Set referenced = new java.util.TreeSet<>(); - for (String sourceSet : new String[]{"main", "mobile", "leanback"}) { - Path base = Path.of("src/" + sourceSet + "/res"); - if (!Files.exists(base)) continue; - try (java.util.stream.Stream paths = Files.walk(base)) { - for (Path path : paths.filter(p -> p.toString().endsWith(".xml")).toList()) { - Matcher matcher = reference.matcher(Files.readString(path, StandardCharsets.UTF_8)); - while (matcher.find()) referenced.add(matcher.group(1)); - } - } - } - assertFalse("必须真的扫到 tv* 属性引用,否则本测试是空断言", referenced.isEmpty()); - - // 赋值来源:全部 values 目录(含 values-night / 各 flavour 的 styles)。 - StringBuilder assigned = new StringBuilder(); - for (String sourceSet : new String[]{"main", "mobile", "leanback"}) { - Path values = Path.of("src/" + sourceSet + "/res/values"); - if (!Files.exists(values)) continue; - try (java.util.stream.Stream paths = Files.walk(values)) { - for (Path path : paths.filter(p -> p.toString().endsWith(".xml")).toList()) { - assigned.append(Files.readString(path, StandardCharsets.UTF_8)); - } - } - } - String themes = assigned.toString(); - java.util.List unassigned = new java.util.ArrayList<>(); - for (String attr : referenced) { - if (!themes.contains("name=\"" + attr + "\">")) unassigned.add(attr); - } - assertTrue("these tv* focus attrs are referenced but no theme assigns them, so ?attr/xxx " - + "silently degrades to black/transparent (measured defect): " + unassigned, - unassigned.isEmpty()); - - // 基底主题必须自带全部五项,覆盖那些父主题链绕开 Theme.Base 的对话框。 - String base = Files.readString(Path.of("src/main/res/values/webhtv_styles.xml"), StandardCharsets.UTF_8); - Matcher baseTheme = Pattern.compile( - "", Pattern.DOTALL).matcher(base); - assertTrue("必须能读到 Theme.WebHTV 定义", baseTheme.find()); - String baseBody = baseTheme.group(1); - for (String attr : new String[]{"tvFocusRing", "tvCurrentRing", "tvNormalStroke", - "tvPlayerRing", "tvPlayerCurrentRing"}) { - assertTrue("Theme.WebHTV 必须绑定 " + attr - + "(对话框/BottomSheet 的父主题链不经过 Theme.Base,未绑定会静默退化)", - baseBody.contains("name=\"" + attr + "\">")); - } - } - - private static void assertResourceMapping(ThemeTokens tokens, Path path) throws Exception { - Map colors = colors(path); - // 计数只覆盖 webhtv_color_* 这一族(见上面的 COLOR 模式),且刻意冻结:新增语义色 - // 必须在这里显式承认一次,避免"顺手加个颜色"绕过角色契约。 - // 51 → 54:新增 webhtv_color_badge_{progress,site,year}(卡片角标底色, - // 见 docs/TV-CARD-BADGE-CONTRAST-20261012.md)。它们不属于 ThemeTokens 的 49 项 - // 角色契约,只是同一命名族里的固定语义色,与 webhtv_on_wallpaper 同类。 - // 54 → 55:新增 webhtv_color_on_wallpaper_error(壁纸上的错误前景色, - // 见 docs/TV-ERROR-STATE-20261012.md),同理不属于角色契约。 - // 55 → 56:新增 webhtv_color_error_value_bg(错误取值文字的实心深色底, - // 让错误正文稳过 4.5:1,见 docs/TV-ERROR-STATE-CLOSEOUT-20261013.md)。 - // 56 → 58:新增 webhtv_color_item_overlay / webhtv_color_on_item_overlay(条目遮罩的 - // 底色与文字色,见 ThemeTokens.colorItemOverlay / colorOnItemOverlay)。它们**属于** - // 角色契约——默认值必须与 ThemeTokens 两张表逐值一致,否则默认外观就变了。 - assertEquals(58, colors.size()); - for (RecordComponent component : ThemeTokens.class.getRecordComponents()) { - if (!component.getType().equals(int.class)) continue; - String name = component.getName(); - String resource = "webhtv_color_" + snakeCase(name.substring("color".length())); - Integer actual = colors.get(resource); - assertTrue(path + " missing " + resource, actual != null); - assertEquals(resource, component.getAccessor().invoke(tokens), actual); - } - } - - private static Map colors(Path path) throws Exception { - Map colors = new LinkedHashMap<>(); - Matcher matcher = COLOR.matcher(Files.readString(path, StandardCharsets.UTF_8)); - while (matcher.find()) { - long value = Long.parseLong(matcher.group(2), 16); - colors.put(matcher.group(1), matcher.group(2).length() == 6 ? (int) (0xFF000000L | value) : (int) value); - } - return colors; - } - - private static String attrName(String getter) { - return "webhtv" + Character.toUpperCase(getter.charAt(0)) + getter.substring(1); - } - - private static String snakeCase(String value) { - return value.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(java.util.Locale.US); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeControllerContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeControllerContractTest.java deleted file mode 100644 index 9f1b8b07ee..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeControllerContractTest.java +++ /dev/null @@ -1,215 +0,0 @@ -package com.fongmi.android.tv.theme; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.util.regex.Matcher; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * The unified theme entry point must resolve the persisted WebHTV appearance - * preference into the shared semantic tokens so every module (following, lab, - * dialogs, web pages) reads one source of truth. - */ -public class ThemeControllerContractTest { - - @Test - public void controllerExposesPreferenceBackedResolution() throws Exception { - String source = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); - assertTrue(source.contains("public static ThemeTokens resolveFromPreferences()")); - assertTrue(source.contains("public static void applyFromPreferences(AppCompatActivity activity)")); - assertTrue(source.contains("Setting.getThemeColor()")); - assertTrue(source.contains("Setting.getWallColor()")); - assertTrue(source.contains("ThemeSeed.WALLPAPER")); - assertTrue(source.contains("ThemeSeed.EXPLICIT")); - } - - @Test - public void appearanceModeDrivesAppCompatNightMode() throws Exception { - String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); - assertTrue(controller.contains("public static void applyNightModeToApp()")); - assertTrue(controller.contains("AppCompatDelegate.MODE_NIGHT_NO")); - assertTrue(controller.contains("AppCompatDelegate.MODE_NIGHT_YES")); - assertTrue(controller.contains("AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM")); - assertTrue(controller.contains("ThemeMode themeMode = currentThemeMode();")); - assertTrue(read("src/main/java/com/fongmi/android/tv/App.java").contains("ThemeController.applyNightModeToApp();")); - for (String flavour : new String[]{"mobile", "leanback"}) { - String dialog = read("src/" + flavour + "/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java"); - assertTrue(dialog.contains("setting_theme_mode")); - assertTrue(dialog.contains("Setting.putThemeMode(mode);")); - assertTrue(dialog.contains("ThemeController.applyNightModeToApp();")); - } - assertTrue(read("src/main/java/com/fongmi/android/tv/setting/Setting.java").contains("public static int getThemeMode()")); - } - - @Test - public void everyActivityAppliesThePersistedThemeSnapshot() throws Exception { - String mobile = read("src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java"); - String leanback = read("src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java"); - assertTrue(mobile.contains("ThemeController.applyFromPreferences(this);")); - assertTrue(leanback.contains("ThemeController.applyFromPreferences(this);")); - } - - @Test - public void legacyPalettesDelegateToSemanticTokens() throws Exception { - String colors = read("src/main/res/values/colors.xml"); - assertTrue(colors.contains("@color/webhtv_color_surface")); - assertTrue(colors.contains("@color/webhtv_color_primary")); - assertTrue(colors.contains("@color/webhtv_color_error")); - assertTrue(colors.contains("@color/webhtv_color_health_good")); - assertTrue(colors.contains("@color/webhtv_color_health_bad")); - String lab = read("src/main/res/values/lab_colors.xml"); - assertTrue(lab.contains("@color/webhtv_color_surface_container_high")); - assertTrue(lab.contains("@color/webhtv_color_on_surface")); - } - - @Test - public void followingAndDetailSurfacesUseSemanticAttributes() throws Exception { - String following = read("src/main/res/layout/activity_following.xml"); - // 追更页改为壁纸背景:根布局必须透明,靠半透明面板承载文字。 - assertFalse(following.contains("android:background=\"?attr/colorSurface\"")); - assertTrue(following.contains("@drawable/shape_following_panel")); - assertTrue(following.contains("app:backgroundTint=\"?attr/colorPrimary\"")); - assertTrue(following.contains("android:textColor=\"?attr/colorOnSurfaceVariant\"")); - String card = read("src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java"); - assertTrue(card.contains("ThemeController.current()")); - assertTrue(card.contains("tokens.colorSurfaceContainerHigh()")); - // 焦点环已由前景 selector(?attr/tvFocusRing,取值 tv_item_focus_ring)统一绘制, - // presenter 只维护常态描边与卡面 token;不再自己画焦点描边以免双重描边。 - assertFalse(card.contains("tokens.colorFocus()")); - assertTrue(card.contains("STROKE_NORMAL")); - String video = read("src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbVideoPresenter.java"); - assertTrue(video.contains("selector_tmdb_media_focus")); - String dialog = read("src/mobile/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java"); - // 行的底色/描边与两个文字色必须来自同一个调色板:此前行底是固定浅色 - // selector_git_cloud_card,而文字已跟随 ThemeController.current(),在 TV 深色表上 - // 实测 1.16:1 / 1.36:1。现在两者都经由 AppearanceRowTheme 取当前 token。 - assertTrue(dialog.contains("AppearanceRowTheme.apply(row, title, summary, ThemeController.current())")); - String rowTheme = read("src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java"); - assertTrue(rowTheme.contains("safe.colorOnSurface()")); - assertTrue(rowTheme.contains("safe.colorOnSurfaceVariant()")); - } - - @Test - public void snapshotIsResolvedBeforeTheFirstContentView() throws Exception { - for (String flavour : new String[]{"mobile", "leanback"}) { - String source = read("src/" + flavour + "/java/com/fongmi/android/tv/ui/base/BaseActivity.java"); - int apply = source.indexOf("ThemeController.applyFromPreferences(this);"); - int content = source.indexOf("setContentView("); - assertTrue(flavour + " must apply the persisted snapshot", apply > 0); - assertTrue(flavour + " must set a content view", content > 0); - assertTrue(flavour + " must resolve the theme snapshot before its first content view", apply < content); - } - } - - @Test - public void controllerReadsTheV2ProfileAndKeepsABaselineSnapshot() throws Exception { - String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); - assertTrue(controller.contains("ThemeProfileStore.load()")); - assertTrue(controller.contains("private static ThemeTokens resolveWith(ThemeProfile profile, Context context)")); - assertTrue(controller.contains("baseline = frozenPalette(activity);")); - assertFalse("the binder baseline must never be seed-derived", - controller.contains("baseline = resolveWith(")); - assertTrue(controller.contains("private static ThemeTokens frozenPalette(Context context)")); - assertTrue(controller.contains("current = resolveWith(profile, activity);")); - assertTrue(controller.contains("ThemeBinder.bind(root, baseline, current);")); - assertTrue(controller.contains("public static ThemeTokens baseline()")); - } - - @Test - public void ordinaryUiNightDecisionComesFromTheThemeController() throws Exception { - String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); - assertTrue(controller.contains("public static boolean isNight(Context context)")); - String chrome = read("src/mobile/java/com/fongmi/android/tv/ui/activity/WebHomeChromeController.java"); - assertTrue(chrome.contains("ThemeController.isNight(activity)")); - assertFalse(chrome.contains("UI_MODE_NIGHT_MASK")); - } - - /** - * TV 产品默认深色(与上游一致:上游 TV 主题直接继承 {@code Theme.Material3.Dark})。 - * - *

理由:电视多在暗环境观看,大面积浅色亮底在夜间刺眼;而且 TV 常见的深色底上, - * 上游那套**白色焦点环**(对深色 surface 17–18:1)才能成立—— - * 白环对浅色表 surface 只有 1.05:1,等于看不见。 - * - *

“跟随系统”对 TV 不再等于“跟随一个通常恒为浅色的系统默认”,否则电视上会默认落到 - * 浅色表。用户仍可在外观设置里显式选浅色/深色;mobile 保持“跟随系统”不变。 - */ - @Test - public void tvDefaultsToDarkWhileMobileKeepsFollowingTheSystem() throws Exception { - String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); - assertTrue("TV 默认必须落到深色:follow-system 分支要按 flavour 分流", - controller.contains("Util.isLeanback()") - && controller.contains("AppCompatDelegate.MODE_NIGHT_YES") - && controller.contains("AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM")); - // 显式选择仍然优先(浅色/深色都能生效) - assertTrue("显式浅色/深色必须仍然优先", - controller.contains("case 0 -> AppCompatDelegate.MODE_NIGHT_NO;") - && controller.contains("case 1 -> AppCompatDelegate.MODE_NIGHT_YES;")); - } - - /** - * 深色表焦点环是「近白」(与上游的白色焦点环观感一致),但不能是纯白。 - * - *

本仓库有 {@link ThemeBinder} 运行时改写通道,它按「颜色 → 语义角色」精确匹配; - * 而 {@code webhtv_on_wallpaper} 两张表恒为 {@code #FFFFFF}。若焦点也取纯白, - * {@code ThemeColorIndex} 会把纯白映射到 FOCUS 角色,用户切换/自定义主题时 - * 壁纸上的白色文字会被改写成焦点色({@code ThemeBaseWiringTest} 钉住该不变量)。 - * 所以这里断言:深色焦点必须亮(对 surface 高对比)且**不等于**纯白。 - */ - @Test - public void darkFocusRingIsNearWhiteButNeverPureWhite() throws Exception { - String night = read("src/main/res/values-night/webhtv_tokens.xml"); - Matcher matcher = java.util.regex.Pattern - .compile("#([0-9A-Fa-f]{6})") - .matcher(night); - assertTrue("深色表必须声明 webhtv_color_focus", matcher.find()); - int focus = (int) Long.parseLong(matcher.group(1), 16); - - assertFalse("深色焦点环不得为纯白:会与 webhtv_on_wallpaper 碰撞,binder 会把壁纸白字改写成焦点色", - focus == 0xFFFFFF); - double ratio = ThemeContrast.ratio(0xFF000000 | focus, ThemeTokens.dark().colorSurface()); - assertTrue("深色焦点环必须对 surface 保持高对比(实测 " + String.format("%.2f", ratio) + ":1)", - ratio >= 10.0); - // 与 ThemeTokens.dark() 的字面量保持一致 - assertEquals("深色表资源必须与 ThemeTokens.dark().colorFocus() 同值", - 0xFF000000 | focus, ThemeTokens.dark().colorFocus()); - } - - /** - * 追更页按钮的「填充 ↔ 文本」必须始终配对。 - * - *

这两个按钮的 backgroundTint 在 focused/pressed 时会换成 - * {@code following_button_focus}(= {@code webhtv_color_focus})。此前文本写死 - * {@code @android:color/white},在浅色表下凑巧可读,但深色表下填充是浅色而文本仍为白 - * (1.00:1,完全不可读);TV 默认改深色后该缺陷会直接显形。 - */ - @Test - public void followingButtonTextPairsWithItsFocusFill() throws Exception { - for (String file : new String[]{"following_button_primary_text.xml", "following_button_secondary_text.xml"}) { - // 去注释后再断言:注释里会引用历史值作说明,不是实际取值。 - String body = read("src/main/res/color/" + file).replaceAll("(?s)", ""); - assertFalse(file + " 不得再用写死白色文本(深色表下与浅色填充 1.00:1 不可读)", - body.contains("@android:color/white")); - assertTrue(file + " 的焦点/按下文本必须取与填充配对的 on-色", - body.contains("@color/webhtv_color_on_primary")); - } - // 两张表的配对都必须过对比度门(文本 4.5:1) - for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.light(), ThemeTokens.dark()}) { - double ratio = ThemeContrast.ratio(tokens.colorOnPrimary(), tokens.colorFocus()); - assertTrue("焦点填充配 on-色文本必须 ≥4.5:1(实测 " + String.format("%.2f", ratio) + ":1)", - ratio >= 4.5); - } - } - - private static String read(String path) throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeEditorContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeEditorContractTest.java deleted file mode 100644 index bf3ae07168..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeEditorContractTest.java +++ /dev/null @@ -1,259 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -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; - -public class ThemeEditorContractTest { - - @Test - public void editorStartsFromThePersistedProfileAndIsNotDirty() { - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - assertFalse(editor.isDirty()); - assertNotNull(editor.draft()); - // The draft must be a copy: mutating it cannot touch the original. - ThemeProfile draft = editor.draft(); - draft.light.primary = "#123456"; - assertFalse(editor.isDirty()); - } - - @Test - public void everyColorSlotRoundTripsThroughTheDraft() { - for (ThemeEditor.Slot slot : ThemeEditor.Slot.values()) { - if (!slot.isColor()) continue; - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - ThemeEditor.Result result = editor.set(slot, false, "#12AB34", 0f); - assertTrue(slot.name(), result.success()); - assertEquals(slot.name(), "#12AB34", editor.valueOf(slot, false)); - } - } - - @Test - public void everyOpacitySlotEnforcesItsOwnRange() { - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - assertTrue(editor.set(ThemeEditor.Slot.SCRIM_OPACITY, false, null, 0.85f).success()); - assertTrue(editor.set(ThemeEditor.Slot.DIALOG_OPACITY, false, null, 0.70f).success()); - assertTrue(editor.set(ThemeEditor.Slot.OVERLAY_OPACITY, false, null, 0.05f).success()); - assertTrue(editor.set(ThemeEditor.Slot.PLAYER_SCRIM_OPACITY, false, null, 0.20f).success()); - assertTrue(editor.set(ThemeEditor.Slot.PLAYER_SCRIM_OPACITY, false, null, 1.00f).success()); - - assertFalse(editor.set(ThemeEditor.Slot.SCRIM_OPACITY, false, null, 0.86f).success()); - assertFalse(editor.set(ThemeEditor.Slot.DIALOG_OPACITY, false, null, 0.69f).success()); - assertFalse(editor.set(ThemeEditor.Slot.OVERLAY_OPACITY, false, null, 0.61f).success()); - assertFalse(editor.set(ThemeEditor.Slot.OVERLAY_OPACITY, false, null, Float.NaN).success()); - assertFalse(editor.set(ThemeEditor.Slot.PLAYER_SCRIM_OPACITY, false, null, 0.19f).success()); - assertFalse(editor.set(ThemeEditor.Slot.PLAYER_SCRIM_OPACITY, false, null, 1.01f).success()); - assertFalse(editor.set(ThemeEditor.Slot.PLAYER_SCRIM_OPACITY, false, null, Float.NaN).success()); - } - - /** - * 条目遮罩是"底色 + 遮罩上的文字色 + 强度"三者联动的同一个视觉机制。三者必须同时可编辑, - * 否则用户改了底色却改不动强度,编辑器就会给出一个只调了一半的面板。 - */ - @Test - public void itemOverlayGroupIsEditableThroughTheSameSlotApi() { - assertTrue(ThemeEditor.Slot.ITEM_OVERLAY.isColor()); - assertTrue(ThemeEditor.Slot.ON_ITEM_OVERLAY.isColor()); - assertFalse(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY.isColor()); - - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - assertTrue(editor.set(ThemeEditor.Slot.ITEM_OVERLAY, false, "#801122", 0f).success()); - assertTrue(editor.set(ThemeEditor.Slot.ON_ITEM_OVERLAY, false, "#FFEE11", 0f).success()); - assertEquals("#801122", editor.valueOf(ThemeEditor.Slot.ITEM_OVERLAY, false)); - assertEquals("#FFEE11", editor.valueOf(ThemeEditor.Slot.ON_ITEM_OVERLAY, false)); - assertTrue(editor.isDirty()); - - assertTrue(editor.clear(ThemeEditor.Slot.ITEM_OVERLAY, false).success()); - assertTrue(editor.clear(ThemeEditor.Slot.ON_ITEM_OVERLAY, false).success()); - assertNull(editor.valueOf(ThemeEditor.Slot.ITEM_OVERLAY, false)); - assertNull(editor.valueOf(ThemeEditor.Slot.ON_ITEM_OVERLAY, false)); - } - - @Test - public void itemOverlayOpacityIsClampedToItsOwnRange() { - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - assertTrue(editor.set(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY, false, null, - ThemeProfileValidator.MIN_ITEM_OVERLAY_OPACITY).success()); - assertTrue(editor.set(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY, false, null, - ThemeProfileValidator.MAX_ITEM_OVERLAY_OPACITY).success()); - // 条目遮罩强度与 overlayOpacity 的有效区间不同,越界必须被拒绝而不是静默夹取。 - assertFalse(editor.set(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY, false, null, - ThemeProfileValidator.MIN_ITEM_OVERLAY_OPACITY - 0.01f).success()); - assertFalse(editor.set(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY, false, null, - ThemeProfileValidator.MAX_ITEM_OVERLAY_OPACITY + 0.01f).success()); - assertFalse(editor.set(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY, false, null, Float.NaN).success()); - } - - /** 三个新槽位在编辑器里显示的是三语各自的本土文案,不能只剩英文兜底。 */ - @Test - public void itemOverlayGroupIsLabelledInEveryLocale() throws Exception { - String[] keys = { - "theme_editor_group_item_overlay", - "theme_editor_slot_item_overlay", - "theme_editor_slot_on_item_overlay", - "theme_editor_slot_item_overlay_opacity"}; - for (String locale : new String[]{"values", "values-zh-rCN", "values-zh-rTW"}) { - String xml = read("src/main/res/" + locale + "/strings.xml"); - for (String key : keys) { - assertTrue(locale + " must label " + key, xml.contains("name=\"" + key + "\"")); - } - } - } - - @Test - public void invalidColorsAreRejectedWithoutTouchingTheDraft() { - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - assertFalse(editor.set(ThemeEditor.Slot.PRIMARY, false, "#12", 0f).success()); - assertFalse(editor.set(ThemeEditor.Slot.PRIMARY, false, "#80112233", 0f).success()); - assertFalse(editor.set(ThemeEditor.Slot.PRIMARY, false, "red", 0f).success()); - assertNull(editor.valueOf(ThemeEditor.Slot.PRIMARY, false)); - } - - @Test - public void clearingASlotRestoresInheritance() { - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - assertTrue(editor.set(ThemeEditor.Slot.SURFACE, false, "#101820", 0f).success()); - assertTrue(editor.clear(ThemeEditor.Slot.SURFACE, false).success()); - assertNull(editor.valueOf(ThemeEditor.Slot.SURFACE, false)); - assertFalse(editor.isDirty()); - } - - @Test - public void lightAndDarkSlotsAreIndependent() { - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - editor.set(ThemeEditor.Slot.PRIMARY, false, "#111111", 0f); - editor.set(ThemeEditor.Slot.PRIMARY, true, "#222222", 0f); - assertEquals("#111111", editor.valueOf(ThemeEditor.Slot.PRIMARY, false)); - assertEquals("#222222", editor.valueOf(ThemeEditor.Slot.PRIMARY, true)); - } - - @Test - public void modeAndSeedMutationsAreNormalised() { - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - assertEquals(ThemeProfile.MODE_DARK, editor.setMode("dark").profile().mode); - assertEquals(ThemeProfile.MODE_SYSTEM, editor.setMode("nonsense").profile().mode); - - ThemeEditor.Result custom = editor.setSeed(ThemeProfile.SEED_CUSTOM, "#0B57D0"); - assertTrue(custom.success()); - assertEquals("#0B57D0", custom.profile().seedColor); - assertFalse(editor.setSeed(ThemeProfile.SEED_CUSTOM, "nope").success()); - - ThemeEditor.Result none = editor.setSeed(ThemeProfile.SEED_NONE, null); - assertTrue(none.success()); - assertNull(none.profile().seedColor); - } - - @Test - public void previewResolvesTheDraftWithoutPersistingIt() { - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - editor.set(ThemeEditor.Slot.PRIMARY, false, "#123456", 0f); - ThemeTokens tokens = editor.preview(ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, false); - assertEquals(0xFF123456, tokens.colorPrimary()); - // A draft-only edit must not reach persistence. - assertEquals(-1, ThemeProfileStore.legacyThemeColor(ThemeProfileStore.load())); - assertEquals(ThemeProfile.SCHEMA_VERSION, editor.draft().schemaVersion); - } - - @Test - public void editorRejectsUnknownSlotAndNullColorInsteadOfCrashing() { - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - assertEquals(false, editor.set(null, false, "#FFFFFF", 0f).success()); - assertEquals(false, editor.clear(null, false).success()); - assertFalse(editor.set(ThemeEditor.Slot.PRIMARY, false, null, 0f).success()); - assertFalse(editor.set(ThemeEditor.Slot.PRIMARY, true, " ", 0f).success()); - assertNull(editor.valueOf(null, false)); - } - - @Test - public void dialogsExposeTheSharedEditorWithoutLegacyColorOnlyPath() throws Exception { - for (String flavour : new String[]{"mobile", "leanback"}) { - String dialog = read("src/" + flavour + "/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java"); - assertTrue(flavour, dialog.contains("ThemeEditor.load()")); - assertTrue(flavour, dialog.contains("ThemePreviewView.createPanel")); - assertTrue(flavour, dialog.contains("ThemeColorPickerDialog.create")); - assertTrue(flavour, dialog.contains("editor.apply()")); - assertTrue(flavour, dialog.contains("editor.reset()")); - assertFalse("the old 14-swatch dialog must be gone", dialog.contains("private static final int[] COLORS")); - String appearance = read("src/" + flavour + "/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java"); - assertFalse(flavour + " must not write theme_color directly", appearance.contains("Setting.putThemeColor(")); - } - } - - @Test - public void previewAndPickerAvoidPlayerTokensAndReflection() throws Exception { - String preview = read("src/main/java/com/fongmi/android/tv/theme/ThemePreviewView.java"); - assertFalse(preview.contains("colorPlayerControl")); - assertFalse(preview.contains("colorHealth")); - String picker = read("src/main/java/com/fongmi/android/tv/theme/ThemeColorPickerDialog.java"); - assertTrue(picker.contains("ThemeProfileValidator.normalizeColor")); - assertFalse(picker.contains("setAccessible")); - assertFalse(picker.contains("getRecordComponents")); - } - - private static String read(String path) throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } - - /** - * The editor builds its panels from platform widgets ({@code new TextView(context)}, - * {@code new Button(...)}, {@code new EditText(...)}). Those carry no Material colour - * role, so they keep the framework default - measured on device as Material's - * {@code #49454F}, which stayed put no matter which surface the user picked and left - * the labels unreadable on a dark custom surface. Every programmatic text-bearing - * widget in the editor must therefore be given a token colour. - * - *

The check follows the declared variable, so colouring it anywhere in the class - * (for example the status line, which is recoloured on every update) satisfies it. - */ - @Test - public void programmaticEditorWidgetsAlwaysGetTokenColours() throws Exception { - java.util.regex.Pattern construction = java.util.regex.Pattern - .compile("(?:([A-Za-z_][\\w]*)\\s*=\\s*)?new\\s+(?:android\\.widget\\.)?(TextView|EditText|Button)\\("); - for (String path : new String[]{ - "src/main/java/com/fongmi/android/tv/theme/ThemeColorPickerDialog.java", - "src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java", - "src/leanback/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java"}) { - String source = read(path); - java.util.regex.Matcher m = construction.matcher(source); - while (m.find()) { - String variable = m.group(1); - assertNotNull(path + ": programmatic " + m.group(2) + " must be assigned to a named widget", - variable); - assertTrue(path + ": " + variable + " (" + m.group(2) - + ") must set an explicit token text colour", - source.contains(variable + ".setTextColor(")); - } - } - } - - @Test - public void theLiveListScrimSlotIsTheLastSlotAndUsesItsOwnRange() { - ThemeEditor.Slot[] slots = ThemeEditor.Slot.values(); - ThemeEditor.Slot last = slots[slots.length - 1]; - assertEquals("LIVE_LIST_SCRIM_OPACITY", last.name()); - // 位置仍是守卫的一部分:isColor() 现在显式列出不透明度槽,但末尾这个槽必须继续读作强度而不是颜色。 - assertFalse(last.isColor()); - - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - assertTrue(editor.set(ThemeEditor.Slot.LIVE_LIST_SCRIM_OPACITY, false, null, 0.20f).success()); - assertTrue(editor.set(ThemeEditor.Slot.LIVE_LIST_SCRIM_OPACITY, false, null, 1.00f).success()); - assertTrue(editor.set(ThemeEditor.Slot.LIVE_LIST_SCRIM_OPACITY, true, null, 0.20f).success()); - assertTrue(editor.set(ThemeEditor.Slot.LIVE_LIST_SCRIM_OPACITY, true, null, 1.00f).success()); - - assertFalse(editor.set(ThemeEditor.Slot.LIVE_LIST_SCRIM_OPACITY, false, null, 0.19f).success()); - assertFalse(editor.set(ThemeEditor.Slot.LIVE_LIST_SCRIM_OPACITY, false, null, 1.01f).success()); - assertFalse(editor.set(ThemeEditor.Slot.LIVE_LIST_SCRIM_OPACITY, false, null, Float.NaN).success()); - assertFalse(editor.set(ThemeEditor.Slot.LIVE_LIST_SCRIM_OPACITY, true, null, 0.19f).success()); - assertFalse(editor.set(ThemeEditor.Slot.LIVE_LIST_SCRIM_OPACITY, true, null, 1.01f).success()); - assertFalse(editor.set(ThemeEditor.Slot.LIVE_LIST_SCRIM_OPACITY, true, null, Float.NaN).success()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeInlinePreviewContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeInlinePreviewContractTest.java deleted file mode 100644 index 0aa36a23d2..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeInlinePreviewContractTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.*; - -/** Source-level boundaries complement the model tests and real view/device tests. */ -public class ThemeInlinePreviewContractTest { - - @Test - public void bothFlavoursOwnALocalSnapshotAndPutPresetsFirst() throws Exception { - String mobile = read("mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java"); - String tv = read("leanback/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java"); - assertNotEquals("TV mode persistence must remain an explicit TV-only contract", mobile, tv); - for (String source : new String[]{mobile, tv}) { - assertTrue(source.contains("editor.preview(dark, wallpaperColor)")); - assertTrue(source.contains("root.setTag(\"webhtv:ignore\")")); - assertTrue(source.indexOf("content.addView(buildPresetRow()") < source.indexOf("content.addView(buildPaletteRow()")); - assertFalse(source.contains("ThemeController.current()")); - assertFalse(source.contains("ThemeController.apply(")); - assertFalse(source.contains("removeAllViews()")); - assertTrue(source.contains("panel.render(editor, dark, tokens)")); - assertTrue(source.contains("preview_draft")); - assertTrue(source.contains("preview_dark")); - assertTrue(source.contains("preview_scroll")); - assertTrue(source.contains("previewTokens, hex ->")); - } - assertTrue(tv.contains("editor.setMode(dark ? ThemeProfile.MODE_DARK : ThemeProfile.MODE_LIGHT)")); - assertTrue(tv.contains("Setting.putThemeMode(dark ? 1 : 0)")); - assertTrue(tv.contains("ThemeController.applyNightModeToApp()")); - assertFalse(mobile.contains("editor.setMode(dark ? ThemeProfile.MODE_DARK : ThemeProfile.MODE_LIGHT)")); - } - - @Test - public void resetAndSelectionDoNotPublishAndSavingDoesNotDismissOnFailure() throws Exception { - String editor = read("main/java/com/fongmi/android/tv/theme/ThemeEditor.java"); - assertTrue(editor.contains("return replace(ThemeProfile.defaultProfile())")); - assertFalse(editor.contains("ThemeProfileStore.reset()")); - String dialog = read("mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java"); - assertTrue(dialog.contains("resetButton.setOnClickListener(view -> { editor.reset(); render(); })")); - assertFalse(dialog.contains(".setPositiveButton(")); - int apply = dialog.indexOf("private void applyDraft()"); - int failed = dialog.indexOf("if (!result.success())", apply); - int returnOnFailure = dialog.indexOf("return;", failed); - int dismiss = dialog.indexOf("dismissAllowingStateLoss();", apply); - assertTrue(apply >= 0 && failed > apply && returnOnFailure < dismiss); - assertTrue(dialog.contains("else RefreshEvent.theme();")); - } - - @Test - public void realColorRowsReplaceTheBottomSampleAndShowResolvedValues() throws Exception { - String source = read("main/java/com/fongmi/android/tv/theme/ThemePreviewView.java"); - assertFalse(source.contains("theme_editor_preview_title")); - assertFalse(source.contains("theme_editor_preview_filled")); - assertFalse(source.contains("theme_editor_preview_tonal")); - assertFalse(source.contains("theme_editor_inherit")); - assertTrue(source.contains("theme_editor_value_auto")); - assertTrue(source.contains("theme_editor_value_adjusted")); - assertFalse(source.contains("ThemeController.current()")); - assertFalse(source.contains("removeAllViews()")); - assertTrue(source.contains("if (!fromUser || binding) return;")); - assertTrue(source.contains("screenWidthDp >= 700")); - assertTrue(source.contains("tokens.colorSuccess()")); - assertTrue(source.contains("tokens.colorWarning()")); - } - - @Test - public void localInteractiveControlsKeepExplicitFocusAndReadableText() throws Exception { - String source = read("main/java/com/fongmi/android/tv/theme/ThemeEditorUi.java"); - assertTrue(source.contains("android.R.attr.state_focused")); - assertTrue(source.contains("ThemeContrast.ratio")); - assertTrue(source.contains("dp(context, 48)")); - assertFalse(source.contains("ThemeController")); - } - - private static String read(String path) throws Exception { - Path root = Files.isDirectory(Path.of("src")) ? Path.of("src") : Path.of("app/src"); - return Files.readString(root.resolve(path)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemePaletteStyleTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemePaletteStyleTest.java deleted file mode 100644 index 55c7cb1d32..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemePaletteStyleTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -public class ThemePaletteStyleTest { - - @Test - public void unknownOrMissingStylesKeepTheTonalSpotDefault() { - assertEquals(ThemePaletteStyle.TONAL_SPOT, ThemePaletteStyle.from(null)); - assertEquals(ThemePaletteStyle.TONAL_SPOT, ThemePaletteStyle.from("not-a-style")); - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.paletteStyle = "not-a-style"; - assertTrue(ThemeProfileValidator.validate(profile).valid()); - assertEquals(ThemePaletteStyle.TONAL_SPOT.id(), profile.paletteStyle); - } - - @Test - public void everyBuiltInStyleProducesAContrastingLightAndDarkScheme() { - for (ThemePaletteStyle style : ThemePaletteStyle.values()) { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.paletteStyle = style.id(); - ThemeTokens light = ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - ThemeTokens dark = ThemeResolver.resolve(ThemeMode.DARK, ThemeSeed.NONE, 0, 0, profile, null, true); - light.requireContrast(); - dark.requireContrast(); - if (style != ThemePaletteStyle.TONAL_SPOT) { - assertNotEquals(style.name() + " should affect the light palette", - ThemeTokens.light().colorPrimary(), light.colorPrimary()); - assertNotEquals(style.name() + " should affect the dark palette", - ThemeTokens.dark().colorPrimary(), dark.colorPrimary()); - } - } - } - - @Test - public void profileRoundTripKeepsPaletteStyle() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.paletteStyle = ThemePaletteStyle.EXPRESSIVE.id(); - ThemeProfile parsed = ThemeProfileCodec.parse(ThemeProfileCodec.encode(profile)); - assertEquals(ThemePaletteStyle.EXPRESSIVE.id(), parsed.paletteStyle); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemePresetsTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemePresetsTest.java deleted file mode 100644 index bb4e122b27..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemePresetsTest.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.util.HashSet; -import java.util.Set; - -import static org.junit.Assert.*; - -public class ThemePresetsTest { - - @Test - public void defaultIsFirstAndStaysByteIdenticalToTheFrozenPalette() { - assertEquals(ThemePresets.Preset.DEFAULT, ThemePresets.Preset.values()[0]); - ThemeProfile profile = ThemePresets.Preset.DEFAULT.profile(); - assertEquals(ThemeProfileCodec.encode(ThemeProfile.defaultProfile()), ThemeProfileCodec.encode(profile)); - ThemeEditor editor = new ThemeEditor(profile); - assertEquals(ThemeTokens.light(), editor.preview(false, 0)); - assertEquals(ThemeTokens.dark(), editor.preview(true, 0)); - } - - @Test - public void everyNamedPaletteDefinesAllThirteenColorsInBothModes() { - for (ThemePresets.Preset preset : ThemePresets.Preset.values()) { - if (!preset.isComplete()) continue; - ThemeProfile profile = preset.profile(); - ThemeEditor editor = new ThemeEditor(profile); - for (boolean dark : new boolean[]{false, true}) { - for (ThemeEditor.Slot slot : ThemeEditor.Slot.values()) { - if (slot.isColor()) assertNotNull(preset + "/" + dark + "/" + slot, editor.valueOf(slot, dark)); - } - ThemeTokens tokens = editor.preview(dark, 0); - assertFalse(preset + ": " + ThemeResolver.lastDiagnostic(), ThemeResolver.lastDiagnostic().startsWith("fallback")); - tokens.requireContrast(); - assertEquals(tokens.colorPrimary(), tokens.colorFocus()); - assertNotEquals(tokens.colorSurface(), tokens.colorSurfaceContainer()); - assertNotEquals(tokens.colorSurfaceContainer(), tokens.colorSurfaceContainerHigh()); - for (int background : new int[]{tokens.colorSurface(), tokens.colorSurfaceContainer(), tokens.colorSurfaceContainerHigh()}) { - assertTrue(preset + " secondary text contrast", ThemeContrast.ratio(tokens.colorOnSurfaceVariant(), background) >= 4.5); - } - } - String encoded = ThemeProfileCodec.encode(profile); - assertEquals(encoded, ThemeProfileCodec.encode(ThemeProfileCodec.parse(encoded))); - } - } - - @Test - public void palettesDifferInSurfacesSecondaryAndSemanticColorsNotOnlyPrimary() { - for (boolean dark : new boolean[]{false, true}) { - Set surfaces = new HashSet<>(); - Set secondary = new HashSet<>(); - Set success = new HashSet<>(); - Set warning = new HashSet<>(); - ThemeTokens baseline = dark ? ThemeTokens.dark() : ThemeTokens.light(); - for (ThemePresets.Preset preset : ThemePresets.Preset.values()) { - if (!preset.isComplete()) continue; - ThemeTokens tokens = new ThemeEditor(preset.profile()).preview(dark, 0); - surfaces.add(tokens.colorSurfaceContainerHigh()); - secondary.add(tokens.colorSecondaryContainer()); - success.add(tokens.colorSuccess()); - warning.add(tokens.colorWarning()); - assertNotEquals(preset + " success must be intentional", baseline.colorSuccess(), tokens.colorSuccess()); - assertNotEquals(preset + " warning must be intentional", baseline.colorWarning(), tokens.colorWarning()); - double greenHue = com.google.android.material.color.utilities.Hct.fromInt(tokens.colorSuccess()).getHue(); - double amberHue = com.google.android.material.color.utilities.Hct.fromInt(tokens.colorWarning()).getHue(); - assertTrue("success remains green: " + greenHue, greenHue >= 100 && greenHue <= 190); - assertTrue("warning remains amber: " + amberHue, amberHue >= 40 && amberHue <= 110); - } - assertEquals(7, surfaces.size()); - assertEquals(7, secondary.size()); - assertTrue(success.size() >= 6); - assertTrue(warning.size() >= 6); - } - } - - @Test - public void everyPaletteStyleIsSafeForEveryCompleteTemplate() { - for (ThemePresets.Preset preset : ThemePresets.Preset.values()) { - if (!preset.isComplete()) continue; - for (ThemePaletteStyle style : ThemePaletteStyle.values()) { - ThemeEditor editor = new ThemeEditor(preset.profile(style)); - for (boolean dark : new boolean[]{false, true}) { - ThemeTokens tokens = editor.preview(dark, 0); - assertFalse(preset + "/" + style + ": " + ThemeResolver.lastDiagnostic(), ThemeResolver.lastDiagnostic().startsWith("fallback")); - tokens.requireContrast(); - } - } - } - } - - @Test - public void changingStyleActuallyChangesTheFullTemplateAndKeepsManualOverrides() { - ThemeEditor editor = new ThemeEditor(ThemePresets.Preset.OCEAN.profile()); - editor.set(ThemeEditor.Slot.SUCCESS, false, "#126B42", 0); - editor.clear(ThemeEditor.Slot.OUTLINE, true); - editor.set(ThemeEditor.Slot.DIALOG_OPACITY, true, null, 0.85f); - ThemeProfile before = editor.draft(); - ThemeProfile after = ThemePresets.withPaletteStyle(before, ThemePaletteStyle.MONOCHROME); - assertNotEquals(before.light.primary, after.light.primary); - assertNotEquals(before.light.surfaceContainerHigh, after.light.surfaceContainerHigh); - assertEquals("#126B42", after.light.success); - assertNull(after.dark.outline); - assertEquals(Float.valueOf(0.85f), after.dark.dialogOpacity); - assertEquals(before.name, after.name); - assertEquals(before.mode, after.mode); - assertEquals("#126B42", before.light.success); - assertEquals(ThemePaletteStyle.VIBRANT.id(), before.paletteStyle); - } - - @Test - public void wallpaperRemainsDynamicAndUsesTheActualWallpaperColor() { - ThemeProfile profile = ThemePresets.Preset.WALLPAPER.profile(); - assertEquals(ThemeProfile.SEED_WALLPAPER, profile.seedSource); - assertNull(profile.light.surface); - ThemeEditor editor = new ThemeEditor(profile); - ThemeTokens blue = editor.preview(false, 0xFF1565C0); - ThemeTokens red = editor.preview(false, 0xFFAE315B); - assertNotEquals(blue.colorPrimary(), red.colorPrimary()); - assertFalse(ThemeResolver.lastDiagnostic().startsWith("fallback")); - } - - @Test - public void templatesAndDraftReplacementAreDeepCopiesAndKeepTheOriginalDirtyBaseline() { - ThemeProfile original = ThemePresets.Preset.OCEAN.profile(); - String snapshot = ThemeProfileCodec.encode(original); - ThemeEditor editor = new ThemeEditor(original); - assertFalse(editor.isDirty()); - ThemeProfile rose = ThemePresets.Preset.ROSE.profile(); - assertTrue(editor.replace(rose).success()); - assertTrue(editor.isDirty()); - rose.light.surface = "#000000"; - assertNotEquals(rose.light.surface, editor.draft().light.surface); - assertEquals(snapshot, ThemeProfileCodec.encode(original)); - assertTrue(editor.reset().success()); - assertTrue(editor.isDirty()); - assertEquals(ThemeTokens.light(), editor.preview(false, 0)); - editor.replace(original); - assertFalse(editor.isDirty()); - assertEquals(snapshot, ThemeProfileCodec.encode(ThemePresets.Preset.OCEAN.profile())); - } - - @Test - public void invalidReplacementCannotPoisonTheDraft() { - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - ThemeProfile invalid = ThemeProfile.defaultProfile(); - invalid.light.surface = "not-a-color"; - assertFalse(editor.replace(invalid).success()); - assertFalse(editor.replace(null).success()); - assertFalse(editor.isDirty()); - } - - @Test - public void readableTextUsesRealContrastNotWeightedRgbBrightness() { - for (int color : new int[]{0xFF6750A4, 0xFF007F7A, 0xFFFFCC55, 0xFFFCFAF8, 0xFF1F272D}) { - int on = ThemeEditorUi.readable(0xFF888888, color); - assertTrue(ThemeContrast.ratio(on, color) >= 4.5); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileCodecTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileCodecTest.java deleted file mode 100644 index eff883f254..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileCodecTest.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -public class ThemeProfileCodecTest { - - @Test - public void roundTripNormalizesShortAndLowercaseColors() { - String json = "{\"schemaVersion\":2,\"format\":\"webhtv-theme\",\"name\":\"Demo\"," - + "\"seedSource\":\"custom\",\"seedColor\":\"#abc\"," - + "\"light\":{\"primary\":\"#112233\",\"dialogOpacity\":0.8}}"; - ThemeProfile profile = ThemeProfileCodec.parse(json); - assertEquals("#AABBCC", profile.seedColor); - assertEquals("#112233", profile.light.primary); - assertEquals(0.8f, profile.light.dialogOpacity, 0.0001f); - assertEquals("#AABBCC", ThemeProfileCodec.parse(ThemeProfileCodec.encode(profile)).seedColor); - } - - @Test - public void missingFieldsFallBackToInheritedDefaults() { - ThemeProfile profile = ThemeProfileCodec.parse("{}"); - assertEquals(ThemeProfile.SCHEMA_VERSION, profile.schemaVersion); - assertEquals(ThemeProfile.FORMAT, profile.format); - assertEquals(ThemeProfile.MODE_SYSTEM, profile.mode); - assertEquals(ThemeProfile.SEED_NONE, profile.seedSource); - assertNull(profile.light.primary); - } - - @Test - public void unknownBenignFieldsAreIgnoredButDangerousFieldsAreRejected() { - ThemeProfile profile = ThemeProfileCodec.parse("{\"futureSlot\":\"#123456\"}"); - assertEquals(ThemeProfile.SEED_NONE, profile.seedSource); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"script\":\"alert(1)\"}")); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"light\":{\"onLoad\":\"x\"}}")); - } - - @Test - public void rejectsMalformedTransparentAndWrongTypedValues() { - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"seedColor\":\"#GGG\"}")); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"seedSource\":\"wallpaper\",\"light\":{\"primary\":\"#80112233\"}}")); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"light\":\"not-an-object\"}")); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse("[]")); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse("")); - } - - @Test - public void rejectsOpacityOutsideTheSafeRangeAndWrongSchemaVersion() { - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"light\":{\"dialogOpacity\":0.10}}")); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"light\":{\"scrimOpacity\":0.99}}")); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"light\":{\"overlayOpacity\":0.90}}")); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"schemaVersion\":1}")); - } - - @Test - public void rejectsOversizedAndDeeplyNestedPayloads() { - StringBuilder deep = new StringBuilder(); - for (int index = 0; index < 12; index++) deep.append('{'); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse(deep.toString())); - String huge = "{\"name\":\"" + "x".repeat(ThemeProfileValidator.MAX_JSON_BYTES) + "\"}"; - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse(huge)); - } - - @Test - public void playbackMenuScrimOpacityRoundTripsAndRejectsOutOfRange() { - ThemeProfile profile = ThemeProfileCodec.parse("{\"light\":{\"playerScrimOpacity\":0.65}}"); - assertEquals(0.65f, profile.light.playerScrimOpacity, 0.0001f); - assertEquals(0.65f, ThemeProfileCodec.parse(ThemeProfileCodec.encode(profile)).light.playerScrimOpacity, 0.0001f); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"light\":{\"playerScrimOpacity\":0.19}}")); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"light\":{\"playerScrimOpacity\":1.01}}")); - } - - @Test - public void encodeRejectsAnInvalidProfileInsteadOfWritingIt() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.primary = "#12"; - IllegalArgumentException error = assertThrows(IllegalArgumentException.class, - () -> ThemeProfileCodec.encode(profile)); - assertTrue(error.getMessage().contains("light.primary")); - } - - @Test - public void liveListScrimOpacityRoundTripsAndRejectsOutOfRange() { - ThemeProfile profile = ThemeProfileCodec.parse("{\"light\":{\"liveListScrimOpacity\":0.65}}"); - assertEquals(0.65f, profile.light.liveListScrimOpacity, 0.0001f); - assertEquals(0.65f, ThemeProfileCodec.parse(ThemeProfileCodec.encode(profile)).light.liveListScrimOpacity, 0.0001f); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"light\":{\"liveListScrimOpacity\":0.19}}")); - assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( - "{\"light\":{\"liveListScrimOpacity\":1.01}}")); - } - - @Test - public void anUnsetLiveListScrimSlotStaysUnsetInsteadOfDefaultingToANumber() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - assertNull(profile.light.liveListScrimOpacity); - assertNull(profile.dark.liveListScrimOpacity); - // A round trip must not bake a number into the slot: the shipped look comes from - // the DEFAULT_OPACITY fallback inside LiveListScrim, not from the profile. - assertNull(ThemeProfileCodec.parse(ThemeProfileCodec.encode(profile)).light.liveListScrimOpacity); - assertNull(ThemeProfileCodec.parse(ThemeProfileCodec.encode(profile)).dark.liveListScrimOpacity); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileMigrationTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileMigrationTest.java deleted file mode 100644 index 2bef99cd1b..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileMigrationTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -public class ThemeProfileMigrationTest { - - @Test - public void legacyOffMigratesToDisabledSeed() { - ThemeProfile profile = ThemeProfileStore.migrateLegacy(-1, null); - assertEquals(ThemeProfile.SEED_NONE, profile.seedSource); - assertNull(profile.seedColor); - assertEquals(-1, ThemeProfileStore.legacyThemeColor(profile)); - } - - @Test - public void legacyWallpaperKeepsWallpaperSeedMode() { - ThemeProfile profile = ThemeProfileStore.migrateLegacy(0, null); - assertEquals(ThemeProfile.SEED_WALLPAPER, profile.seedSource); - assertNull(profile.seedColor); - assertEquals(0, ThemeProfileStore.legacyThemeColor(profile)); - } - - @Test - public void legacyCustomSeedRoundTripsAsArgbInt() { - ThemeProfile profile = ThemeProfileStore.migrateLegacy(0xFF155DFC, null); - assertEquals(ThemeProfile.SEED_CUSTOM, profile.seedSource); - assertEquals("#155DFC", profile.seedColor); - assertEquals(0xFF155DFC, ThemeProfileStore.legacyThemeColor(profile)); - } - - @Test - public void legacyWallColorStaysOutOfTheProfileContract() { - // wall_color is not migrated into the profile: it remains the live wallpaper - // seed input owned by Setting.getWallColor(). - ThemeProfile profile = ThemeProfileStore.migrateLegacy(-1, null); - assertNull(profile.seedColor); - assertEquals(ThemeProfile.MODE_SYSTEM, profile.mode); - } - - @Test - public void legacyV1JsonContributesOnlyModeSeedAndFiveSafeSlots() { - String legacy = "{\"schemaVersion\":1,\"mode\":\"dark\",\"seedSource\":\"custom\",\"seedColor\":\"#155DFC\"," - + "\"colors\":{\"light\":{\"primary\":\"#112233\",\"surface\":\"#F8FAFD\",\"onSurface\":\"#1A1C1E\"," - + "\"outline\":\"#74777F\",\"error\":\"#B3261E\",\"onPrimary\":\"#FFFFFF\",\"script\":\"evil\"}}," - + "\"metadata\":{\"script\":\"evil\"}}"; - ThemeProfile profile = ThemeProfileStore.migrateLegacy(-1, legacy); - assertEquals(ThemeProfile.MODE_DARK, profile.mode); - assertEquals(ThemeProfile.SEED_CUSTOM, profile.seedSource); - assertEquals("#155DFC", profile.seedColor); - assertEquals("#112233", profile.slots(false).primary); - assertEquals("#F8FAFD", profile.slots(false).surface); - assertEquals("#1A1C1E", profile.slots(false).onSurface); - assertEquals("#74777F", profile.slots(false).outline); - assertEquals("#B3261E", profile.slots(false).error); - assertNull(profile.slots(false).primaryContainer); - } - - @Test - public void legacyMirrorWinsOverTheStaleV1Seed() { - String legacy = "{\"schemaVersion\":1,\"seedSource\":\"custom\",\"seedColor\":\"#111111\"}"; - ThemeProfile profile = ThemeProfileStore.migrateLegacy(0, legacy); - assertEquals(ThemeProfile.SEED_WALLPAPER, profile.seedSource); - assertNull(profile.seedColor); - - profile = ThemeProfileStore.migrateLegacy(0xFF0000FF, legacy); - assertEquals(ThemeProfile.SEED_CUSTOM, profile.seedSource); - assertEquals("#0000FF", profile.seedColor); - } - - @Test - public void malformedLegacyJsonFallsBackToTheDefaultProfile() { - ThemeProfile profile = ThemeProfileStore.migrateLegacy(-1, "{not json"); - assertTrue(profile != null); - assertEquals(ThemeProfile.SCHEMA_VERSION, profile.schemaVersion); - assertEquals(ThemeProfile.SEED_NONE, profile.seedSource); - } - - @Test - public void resetReturnsAnApplicableDefaultProfile() { - ThemeProfileValidator.Result result = ThemeProfileValidator.validate(ThemeProfile.defaultProfile()); - assertTrue(result.valid()); - assertEquals(-1, ThemeProfileStore.legacyThemeColor(result.profile())); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileValidatorTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileValidatorTest.java deleted file mode 100644 index 16404b3896..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileValidatorTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -public class ThemeProfileValidatorTest { - - @Test - public void acceptsTheEmptyDefaultProfile() { - ThemeProfileValidator.Result result = ThemeProfileValidator.validate(ThemeProfile.defaultProfile()); - assertTrue(result.message(), result.valid()); - assertEquals(ThemeProfile.SCHEMA_VERSION, result.profile().schemaVersion); - } - - @Test - public void rejectsNullAndWrongSchemaAndFormat() { - assertFalse(ThemeProfileValidator.validate(null).valid()); - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.schemaVersion = 1; - assertFalse(ThemeProfileValidator.validate(profile).valid()); - profile = ThemeProfile.defaultProfile(); - profile.format = "tweakcn"; - assertFalse(ThemeProfileValidator.validate(profile).valid()); - } - - @Test - public void normalizesModeAndSeedAndRequiresAColorForCustomSeed() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.mode = "DARK"; - profile.seedSource = "weird"; - assertTrue(ThemeProfileValidator.validate(profile).valid()); - assertEquals(ThemeProfile.MODE_SYSTEM, profile.mode); - assertEquals(ThemeProfile.SEED_NONE, profile.seedSource); - - profile.seedSource = ThemeProfile.SEED_CUSTOM; - ThemeProfileValidator.Result missingSeed = ThemeProfileValidator.validate(profile); - assertFalse(missingSeed.valid()); - assertTrue(missingSeed.message().contains("seedColor")); - } - - @Test - public void acceptsOnlyOpaqueRgbHexForEveryColorSlot() { - for (String value : new String[]{"#FFF", "#ABCDEF", "#abcdef"}) { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.primary = value; - ThemeProfileValidator.Result result = ThemeProfileValidator.validate(profile); - assertTrue(value, result.valid()); - assertTrue(value, result.profile().light.primary.startsWith("#")); - assertEquals(value, 7, result.profile().light.primary.length()); - } - for (String value : new String[]{"red", "#12", "#1234", "#80112233", "112233", " #123456 "}) { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.primary = value; - ThemeProfileValidator.Result result = ThemeProfileValidator.validate(profile); - if (value.startsWith(" ")) assertTrue(result.valid()); - else assertFalse(value, result.valid()); - } - } - - @Test - public void rejectsAlphaBearingColorsSoOpacityStaysInItsOwnSlots() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.surface = "#80112233"; - ThemeProfileValidator.Result result = ThemeProfileValidator.validate(profile); - assertFalse(result.valid()); - assertTrue(result.message().contains("light.surface")); - } - - @Test - public void enforcesOpacityRangesOnEveryModeIndependently() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.scrimOpacity = 0.85f; - profile.light.dialogOpacity = 0.70f; - profile.light.overlayOpacity = 0.05f; - assertTrue(ThemeProfileValidator.validate(profile).valid()); - - profile.dark.scrimOpacity = 0.86f; - assertFalse(ThemeProfileValidator.validate(profile).valid()); - profile.dark.scrimOpacity = 0f; - profile.dark.dialogOpacity = 0.69f; - assertFalse(ThemeProfileValidator.validate(profile).valid()); - profile.dark.dialogOpacity = 1.0f; - profile.dark.overlayOpacity = 0.61f; - assertFalse(ThemeProfileValidator.validate(profile).valid()); - ThemeProfile dark = ThemeProfile.defaultProfile(); - dark.dark.overlayOpacity = Float.NaN; - assertFalse(ThemeProfileValidator.validate(dark).valid()); - } - - @Test - public void normalizesNullSlotSetsAndTrimsText() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light = null; - profile.dark = null; - profile.id = " id "; - profile.name = " Name "; - ThemeProfileValidator.Result result = ThemeProfileValidator.validate(profile); - assertTrue(result.valid()); - assertEquals("id", result.profile().id); - assertEquals("Name", result.profile().name); - assertTrue(result.profile().light != null); - assertTrue(result.profile().dark != null); - } - - @Test - public void enforcesThePlaybackMenuScrimRangeOnEveryMode() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.playerScrimOpacity = ThemeProfileValidator.MIN_PLAYER_SCRIM_OPACITY; - profile.dark.playerScrimOpacity = ThemeProfileValidator.MAX_PLAYER_SCRIM_OPACITY; - assertTrue(ThemeProfileValidator.validate(profile).valid()); - - profile.light.playerScrimOpacity = 0.19f; - assertFalse(ThemeProfileValidator.validate(profile).valid()); - profile.light.playerScrimOpacity = 1.01f; - assertFalse(ThemeProfileValidator.validate(profile).valid()); - profile.light.playerScrimOpacity = Float.NaN; - assertFalse(ThemeProfileValidator.validate(profile).valid()); - } - - @Test - public void parsesAndFormatsOpaqueColorsForTheLegacyMirror() { - assertEquals(0xFF155DFC, ThemeProfileValidator.parseColor("#155DFC", -1)); - assertEquals(-1, ThemeProfileValidator.parseColor("#801155FC", -1)); - assertEquals("#155DFC", ThemeProfileValidator.formatColor(0xFF155DFC)); - assertNull(ThemeProfileValidator.normalizeColor("nope")); - } - - @Test - public void enforcesTheLiveListScrimRangeOnEveryMode() { - assertEquals(0.20f, ThemeProfileValidator.MIN_LIVE_LIST_SCRIM_OPACITY, 0.0001f); - assertEquals(1f, ThemeProfileValidator.MAX_LIVE_LIST_SCRIM_OPACITY, 0.0001f); - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.liveListScrimOpacity = ThemeProfileValidator.MIN_LIVE_LIST_SCRIM_OPACITY; - profile.dark.liveListScrimOpacity = ThemeProfileValidator.MAX_LIVE_LIST_SCRIM_OPACITY; - assertTrue(ThemeProfileValidator.validate(profile).valid()); - - profile.light.liveListScrimOpacity = 0.19f; - assertFalse(ThemeProfileValidator.validate(profile).valid()); - profile.light.liveListScrimOpacity = 1.01f; - assertFalse(ThemeProfileValidator.validate(profile).valid()); - profile.light.liveListScrimOpacity = Float.NaN; - assertFalse(ThemeProfileValidator.validate(profile).valid()); - - ThemeProfile dark = ThemeProfile.defaultProfile(); - dark.dark.liveListScrimOpacity = 0.19f; - assertFalse(ThemeProfileValidator.validate(dark).valid()); - dark = ThemeProfile.defaultProfile(); - dark.dark.liveListScrimOpacity = 1.01f; - assertFalse(ThemeProfileValidator.validate(dark).valid()); - dark = ThemeProfile.defaultProfile(); - dark.dark.liveListScrimOpacity = Float.NaN; - assertFalse(ThemeProfileValidator.validate(dark).valid()); - } - - @Test - public void anUnsetLiveListScrimSlotStaysNullThroughValidation() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - ThemeProfileValidator.Result result = ThemeProfileValidator.validate(profile); - assertTrue(result.message(), result.valid()); - assertNull(result.profile().light.liveListScrimOpacity); - assertNull(result.profile().dark.liveListScrimOpacity); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverOverrideTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverOverrideTest.java deleted file mode 100644 index e8f4aba210..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverOverrideTest.java +++ /dev/null @@ -1,414 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import com.google.android.material.color.utilities.Hct; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -public class ThemeResolverOverrideTest { - - private static final int BLACK = 0xFF000000; - private static final int WHITE = 0xFFFFFFFF; - - @Test - public void absentOrEmptyProfileKeepsTheFrozenPalette() { - assertEquals(ThemeTokens.light(), ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, null, null, false)); - assertEquals(ThemeTokens.dark(), ThemeResolver.resolve( - ThemeMode.DARK, ThemeSeed.NONE, 0, 0, ThemeProfile.defaultProfile(), null, true)); - assertTrue(ThemeResolver.lastDiagnostic().startsWith("default")); - } - - @Test - public void eachColorSlotOverridesOnlyItsOwnToken() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - ThemeProfile.SlotSet slots = profile.light; - slots.primary = "#000000"; - slots.primaryContainer = "#000000"; - slots.secondaryContainer = "#000000"; - slots.focus = "#000000"; - slots.surface = "#FFFFFF"; - slots.surfaceContainer = "#F7F7F7"; - slots.surfaceContainerHigh = "#EFEFEF"; - slots.onSurface = "#000000"; - slots.onSurfaceVariant = "#000000"; - slots.outline = "#000000"; - slots.error = "#000000"; - slots.success = "#000000"; - slots.warning = "#000000"; - - ThemeTokens base = ThemeTokens.light(); - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - tokens.requireContrast(); - - assertEquals(BLACK, tokens.colorPrimary()); - assertEquals(BLACK, tokens.colorPrimaryContainer()); - assertEquals(BLACK, tokens.colorSecondaryContainer()); - assertEquals(BLACK, tokens.colorFocus()); - assertEquals(WHITE, tokens.colorSurface()); - assertEquals(0xFFF7F7F7, tokens.colorSurfaceContainer()); - assertEquals(0xFFEFEFEF, tokens.colorSurfaceContainerHigh()); - assertEquals(BLACK, tokens.colorOnSurface()); - assertEquals(BLACK, tokens.colorOnSurfaceVariant()); - assertEquals(BLACK, tokens.colorOutline()); - assertEquals(BLACK, tokens.colorError()); - assertEquals(BLACK, tokens.colorSuccess()); - assertEquals(BLACK, tokens.colorWarning()); - - // Slots the user did not touch keep the frozen palette. - assertEquals(base.colorTertiary(), tokens.colorTertiary()); - assertEquals(base.colorSecondary(), tokens.colorSecondary()); - assertEquals(base.colorErrorContainer(), tokens.colorErrorContainer()); - assertEquals(base.colorOutlineVariant(), tokens.colorOutlineVariant()); - assertEquals(base.colorInverseSurface(), tokens.colorInverseSurface()); - } - - @Test - public void playerAndHealthTokensAreNeverReachableFromAProfile() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - ThemeProfile.SlotSet slots = profile.light; - slots.primary = "#FF00AA"; - slots.surface = "#FAFAFA"; - slots.error = "#AA0000"; - slots.dialogOpacity = 0.75f; - slots.scrimOpacity = 0.80f; - slots.overlayOpacity = 0.55f; - - ThemeTokens base = ThemeTokens.light(); - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - assertEquals(base.colorPlayerControl(), tokens.colorPlayerControl()); - assertEquals(base.colorPlayerControlMuted(), tokens.colorPlayerControlMuted()); - assertEquals(base.colorPlayerControlActive(), tokens.colorPlayerControlActive()); - assertEquals(base.colorPlayerScrim(), tokens.colorPlayerScrim()); - assertEquals(base.colorHealthGood(), tokens.colorHealthGood()); - assertEquals(base.colorHealthWarn(), tokens.colorHealthWarn()); - assertEquals(base.colorHealthBad(), tokens.colorHealthBad()); - assertEquals(base.colorOverlayDark(), tokens.colorOverlayDark()); - assertEquals(base.focusScale(), tokens.focusScale(), 0.0001f); - } - - @Test - public void derivedOnColorsFollowBlackOrWhiteReadability() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.primary = "#FFFFFF"; - ThemeTokens light = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - // Primary is also drawn as dialog action text, so a white primary on a near-white - // surface is repaired first. The on* pair must then stay readable against whatever - // primary the repair produced, and the accent contract must hold either way. - assertNotEquals("white primary on a near-white surface must be repaired", - 0xFFFFFFFF, light.colorPrimary()); - assertTrue(ThemeContrast.ratio(light.colorOnPrimary(), light.colorPrimary()) >= 4.5); - assertAccentLegible(light, "white primary request"); - - ThemeProfile darkProfile = ThemeProfile.defaultProfile(); - darkProfile.dark.primary = "#000000"; - ThemeTokens dark = ThemeResolver.resolve( - ThemeMode.DARK, ThemeSeed.NONE, 0, 0, darkProfile, null, true); - assertNotEquals("black primary on a near-black surface must be repaired", - 0xFF000000, dark.colorPrimary()); - assertTrue(ThemeContrast.ratio(dark.colorOnPrimary(), dark.colorPrimary()) >= 4.5); - assertAccentLegible(dark, "black primary request"); - } - - @Test - public void unreadableUserTextIsCorrectedInsteadOfRejected() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.surface = "#FFFFFF"; - profile.light.onSurface = "#FEFEFE"; - profile.light.onSurfaceVariant = "#FDFDFD"; - profile.light.outline = "#FBFBFB"; - - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - tokens.requireContrast(); - assertTrue(ThemeContrast.ratio(tokens.colorOnSurface(), tokens.colorSurface()) >= 4.5); - assertTrue(ThemeContrast.ratio(tokens.colorOnSurfaceVariant(), tokens.colorSurface()) >= 4.5); - assertTrue(ThemeContrast.ratio(tokens.colorOutline(), tokens.colorSurface()) >= 3.0); - assertTrue(ThemeResolver.lastDiagnostic().contains("profile")); - } - - @Test - public void identicalSurfaceLayersReceiveAMinimalDistinction() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.surface = "#F5F5F5"; - profile.light.surfaceContainer = "#F5F5F5"; - profile.light.surfaceContainerHigh = "#F5F5F5"; - - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - assertNotEquals(tokens.colorSurface(), tokens.colorSurfaceContainer()); - assertNotEquals(tokens.colorSurfaceContainer(), tokens.colorSurfaceContainerHigh()); - tokens.requireContrast(); - } - - @Test - public void lightAndDarkSlotsResolveIndependently() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - // #155DFC lands at 4.06:1 on surfaceContainerHighest and would be repaired, which - // would make this independence check assert repaired rather than chosen values. - profile.light.primary = "#1039B8"; - profile.dark.primary = "#FFCC00"; - - ThemeTokens light = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - ThemeTokens dark = ThemeResolver.resolve( - ThemeMode.DARK, ThemeSeed.NONE, 0, 0, profile, null, true); - assertEquals(0xFF1039B8, light.colorPrimary()); - assertEquals(0xFFFFCC00, dark.colorPrimary()); - assertNotEquals(light.colorPrimary(), dark.colorPrimary()); - } - - @Test - public void opacitySlotsAreClampedToTheValidatorRange() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.scrimOpacity = 0.85f; - profile.light.dialogOpacity = 0.80f; - profile.light.overlayOpacity = 0.60f; - - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - assertEquals(217, (tokens.colorScrim() >>> 24) & 0xFF); - assertEquals(0.80f, tokens.dialogOpacity(), 0.0001f); - assertEquals(153, (tokens.colorOverlayLight() >>> 24) & 0xFF); - } - - @Test - public void profileLayersOnTopOfTheSeedInsteadOfReplacingIt() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.focus = "#FF00AA"; - - ThemeTokens seeded = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0xFF0B57D0, 0, profile, null, false); - assertEquals(0xFFFF00AA, seeded.colorFocus()); - assertTrue(ThemeResolver.lastDiagnostic().contains("profile")); - // The seed still owns every slot the profile did not override. - ThemeTokens seedOnly = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0xFF0B57D0, 0, null, null, false); - assertEquals(seedOnly.colorPrimary(), seeded.colorPrimary()); - assertEquals(seedOnly.colorSurface(), seeded.colorSurface()); - } - - @Test - public void invalidProfileFallsBackToLastGoodThenToTheFrozenDefault() { - ThemeProfile broken = ThemeProfile.defaultProfile(); - broken.light.dialogOpacity = 0.10f; - - ThemeProfile lastGood = ThemeProfile.defaultProfile(); - lastGood.light.primary = "#123456"; - - ThemeTokens recovered = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, broken, lastGood, false); - assertEquals(0xFF123456, recovered.colorPrimary()); - assertTrue(ThemeResolver.lastDiagnostic().startsWith("last-good")); - - ThemeTokens fallback = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, broken, null, false); - assertEquals(ThemeTokens.light(), fallback); - assertTrue(ThemeResolver.lastDiagnostic().startsWith("fallback")); - } - - @Test - public void invalidSeedStillShortCircuitsToTheDefaultPalette() { - assertEquals(ThemeTokens.light(), ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0x00123456, 0, null, null, false)); - assertTrue(ThemeResolver.lastDiagnostic().startsWith("fallback:invalid-seed")); - } - - /** - * The B-safe contract promises a profile can never create an unreadable pair. - * {@code colorPrimary} doubles as the AlertDialog action-button text colour, so a - * user-chosen dark surface with the default primary rendered those actions at - * 1.28:1 - measured on device. The resolver must restore Material's own guarantee - * (its baseline scheme measures 4.97-10.91:1 on the surface roles). - */ - @Test - public void customSurfaceKeepsPrimaryLegibleOnEverySurfaceRole() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.surface = "#7B1FA2"; - profile.light.surfaceContainer = "#7B1FA2"; - profile.light.surfaceContainerHigh = "#7B1FA2"; - - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - - assertAccentLegible(tokens, "purple surface"); - // Hue is preserved rather than snapped to black/white. - double hueBefore = Hct.fromInt(ThemeTokens.light().colorPrimary()).getHue(); - double hueAfter = Hct.fromInt(tokens.colorPrimary()).getHue(); - assertEquals("primary must keep its hue", hueBefore, hueAfter, 12.0); - assertNotEquals("primary must actually move", ThemeTokens.light().colorPrimary(), tokens.colorPrimary()); - } - - @Test - public void lightSurfaceAlsoRepairsADarkCustomPrimary() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.surface = "#FFFFFF"; - profile.light.primary = "#F2F2F2"; - - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - assertAccentLegible(tokens, "white surface with near-white primary"); - } - - /** - * 用户报告的实况:TV 主题编辑器「焦点色」输入 {@code #8C0000}、显示却是 {@code #FFFFFF}。 - * FOCUS 槽当时走的是把前景色直接夹到纯黑/纯白的 ensureContrast,深红在深色 surface - * {@code #101418} 上只有 1.86:1,于是被整条改成白色。焦点色必须和 primary 一样只走明度。 - */ - @Test - public void darkSurfaceFocusKeepsTheUsersHueInsteadOfSnappingToWhite() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.dark.focus = "#8C0000"; - - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.DARK, ThemeSeed.NONE, 0, 0, profile, null, true); - - assertNotEquals("深红焦点色不得被夹成纯白", WHITE, tokens.colorFocus()); - double ratio = ThemeContrast.ratio(tokens.colorFocus(), tokens.colorSurface()); - assertTrue("focus/surface = " + ratio + " must be >= 3.0", ratio + 0.0001 >= 3.0); - assertEquals("焦点色必须保留用户选定的色相", - Hct.fromInt(0xFF8C0000).getHue(), Hct.fromInt(tokens.colorFocus()).getHue(), 12.0); - } - - @Test - public void repairedPrimaryKeepsItsForegroundReadable() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.surface = "#7B1FA2"; - profile.light.surfaceContainerHigh = "#7B1FA2"; - - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - assertTrue("onPrimary must stay readable on the repaired primary", - ThemeContrast.ratio(tokens.colorOnPrimary(), tokens.colorPrimary()) >= 4.5); - } - - /** The frozen palettes already satisfy the contract, so no repair may fire there. */ - @Test - public void shippedPalettesAlreadyClearTheAccentContract() { - assertAccentLegible(ThemeTokens.light(), "frozen light"); - assertAccentLegible(ThemeTokens.dark(), "frozen dark"); - } - - /** An empty profile must stay byte-identical: the repair may not perturb it. */ - @Test - public void emptyProfileStillResolvesToTheFrozenPalette() { - assertEquals(ThemeTokens.light(), ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, ThemeProfile.defaultProfile(), null, false)); - assertEquals(ThemeTokens.dark(), ThemeResolver.resolve( - ThemeMode.DARK, ThemeSeed.NONE, 0, 0, ThemeProfile.defaultProfile(), null, false)); - } - - /** Seed-derived palettes come from one tonal scheme, so no repair may fire. */ - @Test - public void seedDerivedPalettesAreNotRepaired() { - for (int seed : new int[]{0xFF0B57D0, 0xFF00897B, 0xFFFB8C00, 0xFF8E24AA}) { - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.EXPLICIT, seed, 0, null, null, false); - assertAccentLegible(tokens, String.format("seed %08X", seed)); - } - } - - /** - * 空 profile 必须解析出编译期取值:遮罩 {@code #66000000}({@code @color/black_40}), - * 遮罩上的文字沿用该表的 colorOnPrimary。这条断言就是「默认字节不变」的机器化表述。 - */ - @Test - public void itemOverlaySlotsDefaultToTheCompiledAppearance() { - for (boolean dark : new boolean[]{false, true}) { - ThemeTokens base = dark ? ThemeTokens.dark() : ThemeTokens.light(); - ThemeTokens resolved = ThemeResolver.resolve(dark ? ThemeMode.DARK : ThemeMode.LIGHT, - ThemeSeed.NONE, 0, 0, ThemeProfile.defaultProfile(), null, dark); - assertEquals("空 profile 的遮罩必须仍是 @color/black_40", 0x66000000, resolved.colorItemOverlay()); - assertEquals(base.colorItemOverlay(), resolved.colorItemOverlay()); - assertEquals("遮罩文字默认沿用 onPrimary", base.colorOnPrimary(), resolved.colorOnItemOverlay()); - assertEquals("遮罩上的文字色必须与编译期 colorOnPrimary 完全一致", - base.colorOnPrimary(), base.colorOnItemOverlay()); - assertEquals("深色表同样的落点:black_40 + 该表 onPrimary", - dark ? 0xFF062E6F : 0xFFFFFFFF, base.colorOnItemOverlay()); - assertEquals("整张 token 表不得被新槽扰动", base, resolved); - assertEquals("空 profile 的遮罩文字同样不得漂移", base.colorOnItemOverlay(), resolved.colorOnItemOverlay()); - } - } - - /** - * 用户改过遮罩或文字色之后,文字与该遮罩复合在 surface 上仍须 ≥4.5:1(WCAG 正文门槛); - * 夹取只走明度、保住色相,与 FOCUS 槽同一口径。遮罩是半透明的,宿主必须按复合后的实色算。 - */ - @Test - public void userSelectedItemTextStaysReadableOnTheCompositedOverlay() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.itemOverlay = "#FFFFFF"; - profile.light.onItemOverlay = "#FFD54F"; - - ThemeTokens tokens = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - - assertEquals("遮罩底色必须照用户选择写入", 0xFFFFFFFF, tokens.colorItemOverlay()); - int backdrop = ThemeContrast.composite(tokens.colorItemOverlay(), tokens.colorSurface()); - double ratio = ThemeContrast.ratio(tokens.colorOnItemOverlay(), backdrop); - assertTrue("onItemOverlay/遮罩复合底色 = " + ratio + " 必须 >= 4.5", ratio + 0.0001 >= 4.5); - assertNotEquals("不可读的文字色必须被修正,而不是原样写入", 0xFFFFD54F, tokens.colorOnItemOverlay()); - assertEquals("夹取只走明度,色相必须保住", - Hct.fromInt(0xFFFFD54F).getHue(), Hct.fromInt(tokens.colorOnItemOverlay()).getHue(), 12.0); - - // 深色表 + 只改强度:alpha 被强度槽缩放,文字仍在复合底色上达标。 - ThemeProfile darkProfile = ThemeProfile.defaultProfile(); - darkProfile.dark.itemOverlay = "#000000"; - darkProfile.dark.itemOverlayOpacity = 0.85f; - darkProfile.dark.onItemOverlay = "#062E6F"; - ThemeTokens darkTokens = ThemeResolver.resolve( - ThemeMode.DARK, ThemeSeed.NONE, 0, 0, darkProfile, null, true); - assertEquals("强度槽只缩放 alpha", 217, (darkTokens.colorItemOverlay() >>> 24) & 0xFF); - int darkBackdrop = ThemeContrast.composite(darkTokens.colorItemOverlay(), darkTokens.colorSurface()); - double darkRatio = ThemeContrast.ratio(darkTokens.colorOnItemOverlay(), darkBackdrop); - assertTrue("深色表 onItemOverlay/复合底色 = " + darkRatio + " 必须 >= 4.5", darkRatio + 0.0001 >= 4.5); - } - - /** 条目遮罩强度只有 0.05–1.0 这一个合法区间,越界与 NaN 都必须被拒,且只缩放 alpha。 */ - @Test - public void itemOverlayOpacityIsRangeCheckedAndOnlyScalesAlpha() { - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - assertTrue(editor.set(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY, false, null, - ThemeProfileValidator.MIN_ITEM_OVERLAY_OPACITY).success()); - assertTrue(editor.set(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY, false, null, - ThemeProfileValidator.MAX_ITEM_OVERLAY_OPACITY).success()); - assertFalse("越上界必须被拒绝", editor.set(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY, false, null, - ThemeProfileValidator.MAX_ITEM_OVERLAY_OPACITY + 0.01f).success()); - assertFalse("越下界必须被拒绝", editor.set(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY, false, null, - ThemeProfileValidator.MIN_ITEM_OVERLAY_OPACITY - 0.01f).success()); - assertFalse("NaN 必须被拒绝", editor.set(ThemeEditor.Slot.ITEM_OVERLAY_OPACITY, false, null, - Float.NaN).success()); - - // 导入的 JSON 走同一个校验入口,直接塞进 profile 的越界值同样不行。 - ThemeProfile outOfRange = ThemeProfile.defaultProfile(); - outOfRange.light.itemOverlayOpacity = 1.01f; - assertFalse(ThemeProfileValidator.validate(outOfRange).valid()); - assertTrue(ThemeProfileValidator.validate(outOfRange).message().contains("itemOverlayOpacity")); - - // 强度只缩放遮罩色的 alpha:色相(用户选的颜色)与不透明度互不干扰。 - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.itemOverlay = "#123456"; - profile.light.itemOverlayOpacity = 0.2f; - ThemeTokens tokens = ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - assertEquals(51, (tokens.colorItemOverlay() >>> 24) & 0xFF); - assertEquals(0x123456, tokens.colorItemOverlay() & 0x00FFFFFF); - } - - private static void assertAccentLegible(ThemeTokens tokens, String label) { - String[] roles = {"surface", "surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest"}; - int[] backgrounds = {tokens.colorSurface(), tokens.colorSurfaceContainer(), - tokens.colorSurfaceContainerHigh(), tokens.colorSurfaceContainerHighest()}; - for (int index = 0; index < backgrounds.length; index++) { - double ratio = ThemeContrast.ratio(tokens.colorPrimary(), backgrounds[index]); - assertTrue(label + ": primary/" + roles[index] + " = " + ratio + " must be >= 4.5", - ratio + 0.0001 >= 4.5); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverTest.java deleted file mode 100644 index c83d47736b..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.lang.reflect.Constructor; -import java.lang.reflect.RecordComponent; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -public class ThemeResolverTest { - - @Test - public void noSeedUsesTheFrozenPalettes() { - assertEquals(ThemeTokens.light(), ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, false)); - assertEquals(ThemeTokens.dark(), ThemeResolver.resolve(ThemeMode.DARK, ThemeSeed.NONE, 0, 0, false)); - } - - @Test - public void explicitSeedGeneratesAContrastingPaletteInsteadOfReturningTheSeed() { - int seed = 0xFF0B57D0; - ThemeTokens tokens = ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.EXPLICIT, seed, 0, false); - assertNotEquals(seed, tokens.colorPrimary()); - tokens.requireContrast(); - assertEquals("seed:explicit", ThemeResolver.lastDiagnostic()); - } - - @Test - public void wallpaperSeedRemainsADarkSurfaceAndNeverBecomesText() { - int wallpaper = 0xFFB7F7D8; - ThemeTokens tokens = ThemeResolver.resolve(ThemeMode.DARK, ThemeSeed.WALLPAPER, 0, wallpaper, false); - assertNotEquals(wallpaper, tokens.colorSurface()); - assertNotEquals(wallpaper, tokens.colorOnSurface()); - assertTrue(ThemeContrast.ratio(tokens.colorSurface(), 0xFF000000) < 2.0); - tokens.requireContrast(); - } - - @Test - public void androidNineFallbackUsesTheDefaultPalette() { - assertEquals(ThemeTokens.dark(), ThemeResolver.resolve(ThemeMode.SYSTEM, ThemeSeed.NONE, 0, 0, true)); - } - - @Test - public void invalidSeedFallsBackWithoutThrowing() { - assertEquals(ThemeTokens.light(), ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0x00123456, 0, false)); - assertTrue(ThemeResolver.lastDiagnostic().startsWith("fallback:invalid-seed")); - } - - @Test - public void lowContrastCandidateFallsBack() throws Exception { - ThemeTokens invalid = replacePrimary(ThemeTokens.light(), ThemeTokens.light().colorPrimary()); - assertEquals(invalid, ThemeResolver.requireOrFallback(invalid, ThemeTokens.light())); - - ThemeTokens bad = replacePrimary(ThemeTokens.light(), 0xFFFFFFFF); - assertEquals(ThemeTokens.light(), ThemeResolver.requireOrFallback(bad, ThemeTokens.light())); - assertTrue(ThemeResolver.lastDiagnostic().contains("seed-contrast")); - } - - @Test - public void webBridgeSnapshotIsBoundedAndReadOnly() { - String json = ThemeWebBridge.snapshotJson(ThemeTokens.dark()); - assertTrue(json.contains("\"primary\":")); - assertTrue(json.contains("\"focusScale\":1.1")); - assertTrue(!json.contains("seed") && !json.contains("wallpaper")); - } - - private static ThemeTokens replacePrimary(ThemeTokens source, int primary) throws Exception { - RecordComponent[] components = ThemeTokens.class.getRecordComponents(); - Class[] types = new Class[components.length]; - Object[] values = new Object[components.length]; - for (int i = 0; i < components.length; i++) { - types[i] = components[i].getType(); - values[i] = components[i].getAccessor().invoke(source); - } - values[0] = primary; - Constructor constructor = ThemeTokens.class.getDeclaredConstructor(types); - return constructor.newInstance(values); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapterTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapterTest.java deleted file mode 100644 index 524b139b0c..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapterTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.io.ByteArrayInputStream; -import java.nio.charset.StandardCharsets; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.assertThrows; - -public class ThemeTweakCnAdapterTest { - - @Test - public void importsTweakCnCssVarsIntoTheCurrentV2Profile() { - String json = "{\"name\":\"Nord-ish\",\"cssVars\":{\"light\":{" - + "\"--primary\":\"#5E81AC\",\"--background\":\"#ECEFF4\"," - + "\"--foreground\":\"#2E3440\",\"--ring\":\"oklch(0.55 0.12 245)\"}," - + "\"dark\":{\"--primary\":\"rgb(136,192,208)\",\"--background\":\"#2E3440\"}}}"; - ThemeTweakCnAdapter.Result result = ThemeTweakCnAdapter.parse(json); - assertEquals("Nord-ish", result.profile().name); - assertEquals("#5E81AC", result.profile().light.primary); - assertEquals("#88C0D0", result.profile().dark.primary); - assertEquals(ThemeProfile.SEED_CUSTOM, result.profile().seedSource); - assertTrue(result.warnings().isEmpty()); - ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, result.profile(), null, false).requireContrast(); - } - - @Test - public void ignoresUnsupportedColorTokensButRejectsExecutableKeysAndOversizedInput() { - ThemeTweakCnAdapter.Result result = ThemeTweakCnAdapter.parse( - "{\"--primary\":\"#123456\",\"--radius\":\"0.5rem\",\"--font\":\"Inter\"}"); - assertEquals("#123456", result.profile().light.primary); - assertFalse(result.warnings().isEmpty()); - assertThrows(IllegalArgumentException.class, () -> ThemeTweakCnAdapter.parse( - "{\"script\":\"alert(1)\",\"--primary\":\"#123456\"}")); - assertThrows(IllegalArgumentException.class, () -> ThemeTweakCnAdapter.parse( - "{\"--primary\":\"" + "x".repeat(ThemeProfileValidator.MAX_JSON_BYTES) + "\"}")); - } - - @Test - public void transportAcceptsOnlyPublicHttpsAndBoundsLocalReads() throws Exception { - assertTrue(ThemeTransfer.isHttps("https://example.com/theme.json")); - assertFalse(ThemeTransfer.isHttps("http://example.com/theme.json")); - assertFalse(ThemeTransfer.isHttps("https://127.0.0.1/theme.json")); - assertFalse(ThemeTransfer.isHttps("https://localhost/theme.json")); - assertEquals("{\"--primary\":\"#123456\"}", ThemeTransfer.read( - new ByteArrayInputStream("{\"--primary\":\"#123456\"}".getBytes(StandardCharsets.UTF_8)))); - byte[] oversized = new byte[ThemeTransfer.MAX_BYTES + 1]; - assertThrows(java.io.IOException.class, () -> ThemeTransfer.read(new ByteArrayInputStream(oversized))); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeWebBridgeTokenTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeWebBridgeTokenTest.java deleted file mode 100644 index 9c9b52f033..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/ThemeWebBridgeTokenTest.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class ThemeWebBridgeTokenTest { - - private static final String[] ORIGINAL_13 = { - "primary", "onPrimary", "surface", "surfaceContainer", "surfaceContainerHigh", - "onSurface", "onSurfaceVariant", "outline", "outlineVariant", "error", - "success", "warning", "focus", - }; - - @Test - public void originalFieldsRemainPresentAndUnchangedInShape() { - String json = ThemeWebBridge.snapshotJson(ThemeTokens.light()); - for (String key : ORIGINAL_13) { - assertTrue(key + " must stay in the snapshot", json.contains("\"" + key + "\":")); - } - // 焦点缩放已统一为与明暗模式无关的 1.10(见 ThemeTokens 与 TvFocusScale): - // 它是动效属性而不是配色属性,此前浅色表为 1.00 只是历史取值。 - assertTrue(json.contains("\"focusScale\":1.10")); - assertTrue(json.startsWith("{") && json.endsWith("}")); - } - - @Test - public void opacityFieldsAreAppendedWithStableFormatting() { - String light = ThemeWebBridge.snapshotJson(ThemeTokens.light()); - String dark = ThemeWebBridge.snapshotJson(ThemeTokens.dark()); - for (String key : new String[]{"scrimOpacity", "dialogOpacity", "overlayOpacity"}) { - assertTrue(key, light.contains("\"" + key + "\":")); - assertTrue(key, dark.contains("\"" + key + "\":")); - } - // Two decimals, no locale-dependent separators, no trailing zeros dropped. - assertTrue(light.matches(".*\"scrimOpacity\":\\d+\\.\\d{2}.*")); - assertTrue(light.matches(".*\"dialogOpacity\":\\d+\\.\\d{2}.*")); - assertTrue(light.matches(".*\"overlayOpacity\":\\d+\\.\\d{2}.*")); - } - - @Test - public void opacityValuesMatchTheResolvedTokens() { - ThemeTokens tokens = ThemeTokens.light(); - String json = ThemeWebBridge.snapshotJson(tokens); - // The snapshot contract is a stable 2-decimal fraction, so compare at that precision. - assertEquals(round2(alpha(tokens.colorScrim())), value(json, "scrimOpacity"), 0.001); - assertEquals(round2(tokens.dialogOpacity()), value(json, "dialogOpacity"), 0.001); - assertEquals(round2(alpha(tokens.colorOverlayLight())), value(json, "overlayOpacity"), 0.001); - } - - @Test - public void itemOverlayFieldsMirrorTheResolvedTokens() { - for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.light(), ThemeTokens.dark()}) { - String json = ThemeWebBridge.snapshotJson(tokens); - assertTrue(json.contains("\"itemOverlay\":\"" + hex(tokens.colorItemOverlay()) + "\"")); - assertTrue(json.contains("\"onItemOverlay\":\"" + hex(tokens.colorOnItemOverlay()) + "\"")); - assertTrue(json.matches(".*\"itemOverlayOpacity\":\\d+\\.\\d{2}.*")); - assertEquals(round2(alpha(tokens.colorItemOverlay())), value(json, "itemOverlayOpacity"), 0.001); - } - } - - @Test - public void itemOverlayFieldsFollowTheProfileSlots() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.itemOverlay = "#203040"; - profile.light.itemOverlayOpacity = 0.50f; - ThemeTokens active = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - String json = ThemeWebBridge.snapshotJson(active); - // 50% of #203040 is the alpha the resolver applies, so the page sees the strength the list uses. - assertTrue(json.contains("\"itemOverlay\":\"#80203040\"")); - assertEquals(0.50f, value(json, "itemOverlayOpacity"), 0.001); - assertTrue(json.contains("\"onItemOverlay\":\"" + hex(active.colorOnItemOverlay()) + "\"")); - } - - @Test - public void snapshotNeverLeaksProfileOrSeedInternals() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.primary = "#123456"; - ThemeTokens active = ThemeResolver.resolve( - ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - String json = ThemeWebBridge.snapshotJson(active); - assertFalse(json.contains("seed")); - assertFalse(json.contains("wallpaper")); - assertFalse(json.contains("profile")); - assertFalse(json.contains("webhtv-theme")); - assertTrue(json.contains("#" + String.format("%08X", active.colorPrimary()))); - } - - @Test - public void nullTokensFallBackToTheFrozenLightSnapshot() { - assertEquals(ThemeWebBridge.snapshotJson(ThemeTokens.light()), ThemeWebBridge.snapshotJson(null)); - } - - private static float round2(float value) { - return Math.round(value * 100f) / 100f; - } - - private static float alpha(int color) { - return ((color >>> 24) & 0xFF) / 255f; - } - - private static String hex(int color) { - return String.format(java.util.Locale.US, "#%08X", color); - } - - private static float value(String json, String key) { - String marker = "\"" + key + "\":"; - int start = json.indexOf(marker) + marker.length(); - int end = start; - while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '.')) end++; - return Float.parseFloat(json.substring(start, end)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/TvFixedDarkSurfaceContrastTest.java b/app/src/test/java/com/fongmi/android/tv/theme/TvFixedDarkSurfaceContrastTest.java deleted file mode 100644 index 4010bd71ae..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/TvFixedDarkSurfaceContrastTest.java +++ /dev/null @@ -1,400 +0,0 @@ -package com.fongmi.android.tv.theme; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Deque; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * The TV mirror of {@link LeanbackForegroundContrastTest}'s "constant light foreground" guard. - * - *

{@code LeanbackForegroundContrastTest} catches a palette-independent light - * foreground on a light day-mode surface. This class catches the opposite, equally - * unreadable pair: a palette-following dark foreground on a surface that is - * palette-independent dark. - * - *

That combination cannot be seen while a flavour pins its token table, which is - * exactly why it went unnoticed: TV used to compile a permanent dark - * {@code leanback/res/values/webhtv_tokens.xml}, so {@code ?attr/colorOnSurface} was - * {@code #E2E2E9} in every configuration and painting it on a fixed dark panel was - * correct. Commit {@code 35a97894a2} ("真正打通 TV 浅色模式") deleted that table, so TV now - * resolves the same day/night tables as mobile and the same markup becomes near-black - * text on a dark panel. Measured on {@code 192.168.50.3:5559} (1920x1080, day mode) the - * exit-confirm primary button went from 4.95:1 to 2.18:1; {@code dialog_display} - * 15.53:1 to 1.16:1, {@code dialog_disc_menu} 12.81:1 to 1.04:1, - * {@code dialog_audio_comment} 13.48:1 to 1.02:1. - * - *

The check is structural rather than a file list: for every leanback-reachable layout - * it walks the tag tree, and for each palette-following foreground it resolves the nearest - * ancestor (or self) that paints a background. When that background has no - * {@code ?attr/} fill at all - i.e. it is the same colour in both tables - and its fills - * are dark, the foreground must still clear the body-text floor. - */ -public class TvFixedDarkSurfaceContrastTest { - - /** Foregrounds that follow the palette, with the value the day table resolves them to. */ - private static final Map PALETTE_FOREGROUNDS = new LinkedHashMap<>(); - - static { - PALETTE_FOREGROUNDS.put("?attr/colorOnSurface", "#1A1C1E"); - PALETTE_FOREGROUNDS.put("?attr/colorOnSurfaceVariant", "#44474F"); - } - - /** Minimum contrast for the body text these layouts carry. */ - private static final double MIN_CONTRAST = 4.5; - - /** - * Fills above this luminance can no longer be called "designed for light text". - * - *

The panels below composite to {@code #181A21} / {@code #1B1F27} / {@code #090A0D}; - * the threshold only needs to separate those from a day-mode surface role. - */ - private static final double DARK_SURFACE_LUMINANCE = 0.35; - - /** - * Layouts owned by the Lab module, which pins {@code colorOnSurface} to - * {@code #FFFFFFFF} in its own {@code Theme.App.Lab} / {@code Theme.App.Lab.Dialog} - * styles. Its {@code ?attr/colorOnSurface} therefore does not follow the - * app palette, and the module is already hard-coded dark by design - * ({@code android:colorBackground = #111318}); routing it through the app tokens is - * a separate concern from this defect class. - */ - private static final String[] LAB_LAYOUTS = { - "adapter_lab_command_compact.xml", "adapter_lab_package.xml", - "activity_lab_output.xml", "dialog_lab_command_sheet.xml", - }; - - private static final Pattern TAG = Pattern.compile("<(/?)([A-Za-z0-9_.]+)((?:\"[^\"]*\"|[^>\"])*?)(/?)>", Pattern.DOTALL); - private static final Pattern BACKGROUND = Pattern.compile("(?:android:background|app:cardBackgroundColor|android:cardBackgroundColor)=\"([^\"]+)\""); - private static final Pattern TEXT_COLOR = Pattern.compile("android:textColor=\"([^\"]+)\""); - private static final Pattern SOLID = Pattern.compile("([^<]+)"); - private static final Pattern SELECTOR_COLOR = Pattern.compile("android:color=\"([^\"]+)\""); - private static final Pattern COMMENT = Pattern.compile("", Pattern.DOTALL); - private static final Pattern STROKE = Pattern.compile("]*/>|", Pattern.DOTALL); - private static final Pattern PADDING = Pattern.compile("]*/>|", Pattern.DOTALL); - private static final Pattern MASK_ITEM = Pattern.compile("]*@android:id/mask[^>]*>.*?", Pattern.DOTALL); - private static final Pattern RIPPLE_COLOR = Pattern.compile("]*android:color=\"[^\"]*\"[^>]*>"); - - private static final Map LITERALS = new HashMap<>(); - - // ------------------------------------------------------------------ palette loading - - private static Path root() { - return Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - } - - private static String readIfExists(String relative) { - try { - Path path = root().resolve(relative); - return Files.exists(path) ? Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n") : null; - } catch (Exception error) { - return null; - } - } - - private static void loadLiterals() { - if (!LITERALS.isEmpty()) return; - for (String file : new String[]{ - "src/main/res/values/webhtv_tokens.xml", - "src/main/res/values/colors.xml", - "src/leanback/res/values/colors.xml"}) { - String source = readIfExists(file); - if (source == null) continue; - Matcher matcher = COLOR_ENTRY.matcher(withoutComments(source)); - while (matcher.find()) LITERALS.put(matcher.group(1), matcher.group(2).trim()); - } - } - - private static String withoutComments(String source) { - return COMMENT.matcher(source).replaceAll(""); - } - - /** Literal {@code #RRGGBB}/{@code #AARRGGBB} a {@code @color/} resolves to, or null when it follows the palette. */ - private static String literalOf(String name, Map seen) { - if (seen.containsKey(name)) return null; - seen.put(name, name); - String value = LITERALS.get(name); - if (value != null) { - if (value.startsWith("#")) return value; - if (value.startsWith("@color/")) return literalOf(value.substring("@color/".length()), seen); - } - for (String directory : new String[]{"src/main/res/color", "src/leanback/res/color"}) { - String source = readIfExists(directory + "/" + name + ".xml"); - if (source == null) continue; - if (withoutComments(source).contains("?attr/")) return null; - Matcher matcher = SELECTOR_COLOR.matcher(withoutComments(source)); - while (matcher.find()) { - String entry = matcher.group(1); - if (entry.startsWith("#")) return entry; - if (entry.startsWith("@color/")) { - String resolved = literalOf(entry.substring("@color/".length()), seen); - if (resolved != null) return resolved; - } - } - } - return null; - } - - private static double luminance(String hex) { - String digits = hex.startsWith("#") ? hex.substring(1) : hex; - if (digits.length() == 8) digits = digits.substring(2); - double[] channels = new double[3]; - for (int i = 0; i < 3; i++) { - double value = Integer.parseInt(digits.substring(i * 2, i * 2 + 2), 16) / 255.0; - channels[i] = value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); - } - return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; - } - - private static int alpha(String hex) { - String digits = hex.startsWith("#") ? hex.substring(1) : hex; - return digits.length() == 8 ? Integer.parseInt(digits.substring(0, 2), 16) : 0xFF; - } - - private static double contrast(String first, String second) { - double a = luminance(first); - double b = luminance(second); - return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); - } - - /** - * Composites a translucent fill over the app's dark content backdrop. - * - *

The panels under test are translucent by design (the "glass sheet" look), so their - * effective colour is darker than the raw value; the darkest shipped surface - * ({@code #101418}) is used as the backdrop, which is the least favourable case for a - * light foreground and the most favourable for the dark one. - */ - private static String compositeOver(String fill, String backdrop) { - double a = alpha(fill) / 255.0; - String top = fill.startsWith("#") ? fill.substring(1) : fill; - if (top.length() == 8) top = top.substring(2); - String bottom = backdrop.startsWith("#") ? backdrop.substring(1) : backdrop; - StringBuilder out = new StringBuilder("#"); - for (int i = 0; i < 3; i++) { - int front = Integer.parseInt(top.substring(i * 2, i * 2 + 2), 16); - int back = Integer.parseInt(bottom.substring(i * 2, i * 2 + 2), 16); - out.append(String.format(Locale.ROOT, "%02X", Math.round(front * a + back * (1 - a)))); - } - return out.toString(); - } - - /** - * The day-mode fills of a background, or {@code null} when it follows the palette. - * - *

A {@code ?attr/} fill, an unresolvable {@code @color/} or a non-colour value makes - * the whole drawable palette-following, which is the case this guard does not cover. - */ - private static List fixedFills(String name) { - List fills = new ArrayList<>(); - boolean palette = false; - for (String directory : new String[]{"src/main/res/drawable", "src/leanback/res/drawable"}) { - String source = readIfExists(directory + "/" + name + ".xml"); - if (source == null) continue; - String body = RIPPLE_COLOR.matcher(MASK_ITEM.matcher(PADDING.matcher(STROKE.matcher(withoutComments(source)) - .replaceAll("")).replaceAll("")).replaceAll("")).replaceAll(""); - if (body.contains("?attr/")) palette = true; - Matcher solid = SOLID.matcher(body); - while (solid.find()) { - String value = solid.group(1); - if (value.startsWith("#")) fills.add(value); - else if (value.startsWith("@color/")) { - String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); - if (literal == null) palette = true; - else fills.add(literal); - } else if (!value.contains("transparent")) palette = true; - } - Matcher gradient = GRADIENT.matcher(body); - while (gradient.find()) { - String value = gradient.group(1); - if (value.startsWith("#")) fills.add(value); - else if (value.startsWith("@color/")) { - String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); - if (literal == null) palette = true; - else fills.add(literal); - } else palette = true; - } - } - if (palette) return null; - List visible = fills.stream().filter(fill -> alpha(fill) >= 0x66).toList(); - if (visible.isEmpty()) return null; - return visible.stream().map(fill -> compositeOver(fill, "#101418")).toList(); - } - - // ------------------------------------------------------------------ the guard - - /** - * A palette-following foreground must not sit on a palette-independent dark surface. - * - *

Reported per file so a failure names the layout to fix, together with the resolved - * surface and the measured contrast. - */ - @Test - public void paletteForegroundsStayReadableOnFixedDarkSurfaces() throws Exception { - loadLiterals(); - List layouts = new ArrayList<>(); - for (String directory : new String[]{"src/leanback/res/layout", "src/main/res/layout"}) { - Path base = root().resolve(directory); - if (!Files.isDirectory(base)) continue; - try (var paths = Files.walk(base)) { - paths.filter(Files::isRegularFile) - .filter(path -> path.getFileName().toString().endsWith(".xml")) - .sorted() - .forEach(layouts::add); - } - } - assertFalse("no layouts were found to inspect", layouts.isEmpty()); - - List failures = new ArrayList<>(); - for (Path layout : layouts) { - String name = layout.getFileName().toString(); - if (isLabLayout(name)) continue; - String source = Files.readString(layout, StandardCharsets.UTF_8).replace("\r\n", "\n"); - for (String[] hit : inspect(source)) { - String foreground = hit[0]; - String surface = hit[1]; - double worst = Double.parseDouble(hit[2]); - if (worst < MIN_CONTRAST) { - failures.add(name + ": " + foreground + " sits on the fixed dark surface " + surface - + " with " + String.format(Locale.ROOT, "%.2f:1", worst) + " in day mode"); - } - } - } - if (!failures.isEmpty()) { - fail("TV paints a palette-following foreground on a palette-independent dark surface," - + " which is near-black on dark in day mode:\n " + String.join("\n ", failures)); - } - } - - /** - * Proves the guard above is not vacuous. - * - *

Every real instance of this defect class has been fixed, so the tree walk now - * legitimately matches nothing. The probe feeds {@link #inspect} the exact markup that - * was measured on device before the fix and requires it to be flagged with a - * sub-threshold ratio, so a future regression cannot hide behind an empty match set. - */ - @Test - public void theGuardDetectsTheDefectItWasWrittenFor() { - loadLiterals(); - String defective = "" - + ""; - List hits = inspect(defective); - assertFalse("the guard must flag the measured pre-fix markup", hits.isEmpty()); - assertEquals("@drawable/shape_audio_playlist_panel", hits.get(0)[1]); - assertTrue("the probe must reproduce the unreadable ratio", Double.parseDouble(hits.get(0)[2]) < MIN_CONTRAST); - - // The mirror of the same markup: a palette-independent light foreground must be ignored here. - String alreadyCorrect = "" - + ""; - assertTrue("a palette-independent foreground is not this guard's concern", - inspect(alreadyCorrect).isEmpty()); - } - - private static boolean isLabLayout(String name) { - for (String lab : LAB_LAYOUTS) if (lab.equals(name)) return true; - return false; - } - - /** {@code {foreground, surface, worstDayContrast}} for every matched element. */ - private static List inspect(String source) { - List hits = new ArrayList<>(); - Deque backgrounds = new ArrayDeque<>(); - Matcher matcher = TAG.matcher(source); - while (matcher.find()) { - boolean closing = !matcher.group(1).isEmpty(); - String attributes = matcher.group(3); - boolean selfClosing = !matcher.group(4).isEmpty(); - if (closing) { - if (!backgrounds.isEmpty()) backgrounds.pop(); - continue; - } - Matcher backgroundMatcher = BACKGROUND.matcher(attributes); - String own = backgroundMatcher.find() ? backgroundMatcher.group(1) : null; - if (!selfClosing) backgrounds.push(own == null ? "" : own); - - Matcher colorMatcher = TEXT_COLOR.matcher(attributes); - if (!colorMatcher.find()) continue; - String foreground = PALETTE_FOREGROUNDS.get(colorMatcher.group(1)); - if (foreground == null) continue; - - String nearest = own != null && !own.isEmpty() ? own : null; - if (nearest == null) { - for (String ancestor : backgrounds) { - if (ancestor != null && !ancestor.isEmpty()) { - nearest = ancestor; - break; - } - } - } - if (nearest == null || !nearest.startsWith("@drawable/")) continue; - List fills = fixedFills(nearest.substring("@drawable/".length())); - if (fills == null) continue; - boolean dark = fills.stream().allMatch(fill -> luminance(fill) <= DARK_SURFACE_LUMINANCE); - if (!dark) continue; - double worst = fills.stream().mapToDouble(fill -> contrast(foreground, fill)).min().orElse(Double.MAX_VALUE); - hits.add(new String[]{colorMatcher.group(1), nearest, String.valueOf(worst)}); - } - return hits; - } - - /** - * Pins the four surfaces this defect was fixed on, so a later "migrate the TV - * foregrounds" sweep cannot put a palette-following role back on them. - */ - @Test - public void fixedDarkPanelsKeepPaletteIndependentForegrounds() throws Exception { - String[][] expectations = { - {"src/main/res/layout/dialog_display.xml", "shape_display_dialog_panel"}, - {"src/main/res/layout/dialog_disc_menu.xml", "shape_disc_menu_panel"}, - {"src/main/res/layout/dialog_audio_comment.xml", "shape_audio_playlist_panel"}, - {"src/leanback/res/layout/dialog_exit_confirm.xml", "selector_exit_confirm_primary"}, - }; - for (String[] expectation : expectations) { - String source = readIfExists(expectation[0]); - assertTrue(expectation[0] + " is missing", source != null); - assertTrue(expectation[0] + " must still paint its panel with " + expectation[1], - source.contains(expectation[1])); - for (String[] hit : inspect(source)) { - if (!expectation[1].equals(hit[1].substring("@drawable/".length()))) continue; - fail(expectation[0] + " must not paint " + hit[0] + " on " + hit[1] - + " (measured " + String.format(Locale.ROOT, "%.2f:1", Double.parseDouble(hit[2])) + ")"); - } - } - } - - /** - * The four panels must really be palette-independent and dark, otherwise the fix above - * would be protecting the wrong thing. - */ - @Test - public void theFixedPanelsAreStillPaletteIndependentAndDark() throws Exception { - loadLiterals(); - for (String drawable : new String[]{ - "shape_display_dialog_panel", "shape_disc_menu_panel", "shape_audio_playlist_panel", - "selector_exit_confirm_primary"}) { - List fills = fixedFills(drawable); - assertTrue(drawable + " must have no palette-following fill, or the guard is moot", fills != null); - assertTrue(drawable + " must be dark in both tables", - fills.stream().allMatch(fill -> luminance(fill) <= DARK_SURFACE_LUMINANCE)); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/TvFixedLightSurfaceContrastTest.java b/app/src/test/java/com/fongmi/android/tv/theme/TvFixedLightSurfaceContrastTest.java deleted file mode 100644 index bc184a4d00..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/TvFixedLightSurfaceContrastTest.java +++ /dev/null @@ -1,588 +0,0 @@ -package com.fongmi.android.tv.theme; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Deque; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * The mirror of {@link TvFixedDarkSurfaceContrastTest}: a palette-following - * foreground on a surface whose fill is palette-independent light. - * - *

That pair breaks in the opposite direction. A drawable with a literal light fill keeps - * that fill in every table, so the only thing that moves is the foreground role: readable - * {@code #44474F} in the day table, but {@code #C4C6D0} in the night table - 1.63:1 on - * {@code #F8FAFD}. Measured on the TV exit-confirm dialog on 2026-10-24 the secondary - * ("取消") button was a near-white pill with an almost invisible label, because - * {@code dialog_exit_confirm.xml} painted it with {@code ?attr/colorOnSurfaceVariant} while - * {@code selector_exit_confirm_secondary} kept its literal light fill. - * - *

Neither existing guard covers this: {@link LeanbackForegroundContrastTest} only tracks - * constant light foregrounds, and {@link TvFixedDarkSurfaceContrastTest} only tracks - * fixed dark surfaces (and only in day mode). This class therefore resolves each - * palette role in both tables and requires the worst of the two to clear the - * body-text floor, so a role that only becomes unreadable after dark cannot hide. - */ -public class TvFixedLightSurfaceContrastTest { - - /** Minimum contrast for the button and label text these layouts carry. */ - private static final double MIN_CONTRAST = 4.5; - - /** - * Fills below this luminance can no longer be called "designed for dark text". - * - *

The surfaces below are {@code #F8FAFD} / {@code #DCEAFF} / {@code #FAFBFC}; the - * threshold only needs to separate those from a dark panel role. - */ - private static final double LIGHT_SURFACE_LUMINANCE = 0.6; - - /** - * Layouts exempt from this guard, one reason per entry, no empty slots. - * - *

It was non-empty for exactly one day: the first full run of this guard on 2026-10-24 - * found the same defect on ten more screens (ad-block stats, the MPV profile card, the AI - * recommendation chips, the CSP step buttons, the MPV config close buttons and the GitHub - * proxy row). Those were then fixed in the same change as the exit-confirm dialog, so the - * list is empty again and every layout in all three source sets is covered by - * {@link #paletteForegroundsStayReadableOnFixedLightSurfaces()}. Add an entry only with a - * reason that still holds. - */ - private static final String[] EXEMPT_LAYOUTS = {}; - - /** - * Layouts owned by the Lab module, which pins {@code colorOnSurface} to - * {@code #FFFFFFFF} in its own {@code Theme.App.Lab} styles. Its roles do not follow the - * app tables, and the module is hard-coded dark by design, so nothing here applies. - */ - private static final String[] LAB_LAYOUTS = { - "adapter_lab_command_compact.xml", "adapter_lab_package.xml", - "activity_lab_output.xml", "dialog_lab_command_sheet.xml", - }; - - private static final Pattern TAG = Pattern.compile("<(/?)([A-Za-z0-9_.]+)((?:\"[^\"]*\"|[^>\"])*?)(/?)>", Pattern.DOTALL); - private static final Pattern BACKGROUND = Pattern.compile("(?:android:background|app:cardBackgroundColor|android:cardBackgroundColor)=\"([^\"]+)\""); - /** - * Every attribute that paints a foreground glyph on the element's own background. - * - *

{@code android:tint} matters for the icon-only buttons ({@code ic_close}) whose colour - * comes from the drawable tint rather than from a text colour, and the two tab attributes - * matter because the ad-stats {@code TabLayout} carries its own fixed light fill. - */ - private static final Pattern FOREGROUND = Pattern.compile( - "(?:android:textColor|android:textColorHint|android:tint|app:tint" - + "|app:tabTextColor|app:tabSelectedTextColor)=\"([^\"]+)\""); - private static final Pattern SOLID = Pattern.compile("([^<]+)"); - private static final Pattern SELECTOR_COLOR = Pattern.compile("android:color=\"([^\"]+)\""); - private static final Pattern STYLE_COLOR_ENTRY = Pattern.compile("(@color/[A-Za-z0-9_]+)"); - private static final Pattern COMMENT = Pattern.compile("", Pattern.DOTALL); - private static final Pattern STROKE = Pattern.compile("]*/>|", Pattern.DOTALL); - private static final Pattern PADDING = Pattern.compile("]*/>|", Pattern.DOTALL); - private static final Pattern MASK_ITEM = Pattern.compile("]*@android:id/mask[^>]*>.*?", Pattern.DOTALL); - private static final Pattern RIPPLE_COLOR = Pattern.compile("]*android:color=\"[^\"]*\"[^>]*>"); - - /** Day-table colour literals, then the night table layered on top by qualifier order. */ - private static final Map DAY = new HashMap<>(); - private static final Map NIGHT = new HashMap<>(); - - /** {@code ?attr/} name to the {@code @color/} resource its theme style points at. */ - private static final Map STYLE_COLORS = new LinkedHashMap<>(); - - private static boolean loaded; - - // ------------------------------------------------------------------ palette loading - - private static Path root() { - return Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - } - - private static String readIfExists(String relative) { - try { - Path path = root().resolve(relative); - return Files.exists(path) ? Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n") : null; - } catch (Exception error) { - return null; - } - } - - private static void loadTables() { - if (loaded) return; - loaded = true; - loadLiterals("src/main/res/values/webhtv_tokens.xml", DAY); - loadLiterals("src/main/res/values/colors.xml", DAY); - loadLiterals("src/leanback/res/values/colors.xml", DAY); - NIGHT.putAll(DAY); - loadLiterals("src/main/res/values-night/webhtv_tokens.xml", NIGHT); - loadLiterals("src/main/res/values-night/colors.xml", NIGHT); - loadLiterals("src/leanback/res/values-night/colors.xml", NIGHT); - String styles = readIfExists("src/main/res/values/webhtv_styles.xml"); - if (styles == null) return; - Matcher matcher = STYLE_COLOR_ENTRY.matcher(withoutComments(styles)); - while (matcher.find()) STYLE_COLORS.putIfAbsent(matcher.group(1), matcher.group(2)); - } - - private static void loadLiterals(String file, Map target) { - String source = readIfExists(file); - if (source == null) return; - Matcher matcher = COLOR_ENTRY.matcher(withoutComments(source)); - while (matcher.find()) target.put(matcher.group(1), matcher.group(2).trim()); - } - - private static String withoutComments(String source) { - return COMMENT.matcher(source).replaceAll(""); - } - - /** Literal a {@code @color/} resource resolves to in one table, or null when it follows the palette. */ - private static String literalOf(String name, Map table, boolean night) { - return literalOf(name, table, night, new HashMap<>()); - } - - private static String literalOf(String name, Map table, boolean night, Map seen) { - if (seen.put(name, name) != null) return null; - String value = table.get(name); - if (value != null) { - if (value.startsWith("#")) return value; - if (value.startsWith("@color/")) return literalOf(value.substring("@color/".length()), table, night, seen); - return null; - } - for (String directory : new String[]{"src/main/res/color", "src/leanback/res/color"}) { - String source = night ? readIfExists(directory + "-night/" + name + ".xml") : null; - if (source == null) source = readIfExists(directory + "/" + name + ".xml"); - if (source == null) continue; - if (withoutComments(source).contains("?attr/")) return null; - Matcher matcher = SELECTOR_COLOR.matcher(withoutComments(source)); - while (matcher.find()) { - String entry = matcher.group(1); - if (entry.startsWith("#")) return entry; - if (entry.startsWith("@color/")) { - String resolved = literalOf(entry.substring("@color/".length()), table, night, seen); - if (resolved != null) return resolved; - } - } - } - return null; - } - - /** - * The colours a foreground paints in each table, or {@code null} when the guard cannot say. - * - *

Both kinds of palette-following foreground matter: a bare {@code ?attr/} role, and an - * {@code @color/} selector that is state-aware and whose states point at such a role (the - * ad-block labels used {@code @color/dialog_outlined_button_text}, whose every state resolves - * to {@code ?attr/colorPrimary}). Resolution therefore descends through selectors and theme - * attributes instead of stopping at the outer resource name. - * - *

A foreground that resolves to the same literal in both tables is deliberately - * reported as {@code null}: a palette-independent foreground on a fixed light surface is - * exactly the fix this guard asks for, not a defect. - */ - @SuppressWarnings("unchecked") - private static List[] foregroundColors(String value) { - List day = new ArrayList<>(); - List night = new ArrayList<>(); - resolve(value, DAY, false, day, new HashMap<>()); - resolve(value, NIGHT, true, night, new HashMap<>()); - if (day.isEmpty() || night.isEmpty()) return null; - // Identical literals in both tables: palette-independent, i.e. exactly the desired fix. - if (new java.util.TreeSet<>(day).equals(new java.util.TreeSet<>(night))) return null; - return new List[]{day, night}; - } - - private static void resolve(String value, Map table, boolean night, List out, Map seen) { - if (value == null || seen.put(value, value) != null) return; - if (value.startsWith("#")) { - out.add(value); - return; - } - if (value.startsWith("?attr/")) { - String color = STYLE_COLORS.get(value.substring("?attr/".length())); - if (color != null) resolve(color, table, night, out, seen); - return; - } - if (value.startsWith("@android:color/")) return; - if (!value.startsWith("@color/")) return; - String name = value.substring("@color/".length()); - String literal = table.get(name); - if (literal != null) { - resolve(literal, table, night, out, seen); - return; - } - for (String directory : new String[]{"src/main/res/color", "src/leanback/res/color", "src/mobile/res/color"}) { - String source = night ? readIfExists(directory + "-night/" + name + ".xml") : null; - if (source == null) source = readIfExists(directory + "/" + name + ".xml"); - if (source == null) continue; - Matcher matcher = SELECTOR_COLOR.matcher(withoutComments(source)); - while (matcher.find()) resolve(matcher.group(1), table, night, out, seen); - return; - } - } - - private static double luminance(String hex) { - String digits = hex.startsWith("#") ? hex.substring(1) : hex; - if (digits.length() == 8) digits = digits.substring(2); - double[] channels = new double[3]; - for (int i = 0; i < 3; i++) { - double value = Integer.parseInt(digits.substring(i * 2, i * 2 + 2), 16) / 255.0; - channels[i] = value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); - } - return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; - } - - private static int alpha(String hex) { - String digits = hex.startsWith("#") ? hex.substring(1) : hex; - return digits.length() == 8 ? Integer.parseInt(digits.substring(0, 2), 16) : 0xFF; - } - - private static double contrast(String first, String second) { - double a = luminance(first); - double b = luminance(second); - return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); - } - - /** - * Composites a translucent fill over the app's dark content backdrop. - * - *

A light panel that is translucent becomes darker, which can only move a fill - * away from the light threshold. The result is therefore conservative: such a - * panel drops out of this guard rather than producing a false failure. - */ - private static String compositeOver(String fill, String backdrop) { - double a = alpha(fill) / 255.0; - String top = fill.startsWith("#") ? fill.substring(1) : fill; - if (top.length() == 8) top = top.substring(2); - String bottom = backdrop.startsWith("#") ? backdrop.substring(1) : backdrop; - StringBuilder out = new StringBuilder("#"); - for (int i = 0; i < 3; i++) { - int front = Integer.parseInt(top.substring(i * 2, i * 2 + 2), 16); - int back = Integer.parseInt(bottom.substring(i * 2, i * 2 + 2), 16); - out.append(String.format(Locale.ROOT, "%02X", Math.round(front * a + back * (1 - a)))); - } - return out.toString(); - } - - /** - * The fills of one background value: a {@code @drawable/} shape or selector, or a - * {@code @color/} literal painted with {@code android:background}. - * - *

The colour form matters: the ad-stats log page lays its column headers and filter - * buttons on a solid {@code @color/} band rather than on a drawable. That band used to be - * {@code @color/webhtv_color_scrim} (translucent black over the fixed light panel), which - * no drawable walk can see, so the headers were measured against the panel underneath and - * looked fine while the band on screen was mid-grey. It is now a fixed light literal, and - * this branch is what keeps it in the guard. - */ - private static List surfaceFills(String background) { - if (background == null) return null; - if (background.startsWith("@color/")) { - String literal = fixedLiteral(background.substring("@color/".length())); - if (literal == null || alpha(literal) < 0x66) return null; - return List.of(compositeOver(literal, "#101418")); - } - if (!background.startsWith("@drawable/")) return null; - return fixedFills(background.substring("@drawable/".length())); - } - - /** - * The fills of a background in both tables, or {@code null} when it follows the palette. - * - *

A {@code ?attr/} fill, an unresolvable {@code @color/} or a non-colour value makes - * the whole drawable palette-following, which is the case this guard does not cover. - */ - private static List fixedFills(String name) { - List fills = new ArrayList<>(); - boolean palette = false; - for (String directory : new String[]{"src/main/res/drawable", "src/leanback/res/drawable"}) { - String source = readIfExists(directory + "/" + name + ".xml"); - if (source == null) continue; - String body = RIPPLE_COLOR.matcher(MASK_ITEM.matcher(PADDING.matcher(STROKE.matcher(withoutComments(source)) - .replaceAll("")).replaceAll("")).replaceAll("")).replaceAll(""); - if (body.contains("?attr/")) palette = true; - Matcher solid = SOLID.matcher(body); - while (solid.find()) { - String value = solid.group(1); - if (value.startsWith("#")) fills.add(value); - else if (value.startsWith("@color/")) { - String literal = fixedLiteral(value.substring("@color/".length())); - if (literal == null) palette = true; - else fills.add(literal); - } else if (!value.contains("transparent")) palette = true; - } - Matcher gradient = GRADIENT.matcher(body); - while (gradient.find()) { - String value = gradient.group(1); - if (value.startsWith("#")) fills.add(value); - else if (value.startsWith("@color/")) { - String literal = fixedLiteral(value.substring("@color/".length())); - if (literal == null) palette = true; - else fills.add(literal); - } else palette = true; - } - } - if (palette) return null; - List visible = fills.stream().filter(fill -> alpha(fill) >= 0x66).toList(); - if (visible.isEmpty()) return null; - return visible.stream().map(fill -> compositeOver(fill, "#101418")).toList(); - } - - /** - * The one literal an {@code @color/} resource paints in both tables. - * - *

A token whose day and night values differ follows the palette - it is simply not - * the "fixed fill" this guard is about - so it returns null and drops the whole drawable - * from the guard, exactly like a {@code ?attr/} fill would. - */ - private static String fixedLiteral(String name) { - String day = literalOf(name, DAY, false); - String night = literalOf(name, NIGHT, true); - if (day == null || night == null) return null; - return day.equalsIgnoreCase(night) ? day : null; - } - - // ------------------------------------------------------------------ the guard - - /** - * A palette-following foreground must not sit on a palette-independent light surface. - * - *

Reported per file so a failure names the layout to fix, together with the resolved - * surface and the worst ratio across the day and night tables. - */ - @Test - public void paletteForegroundsStayReadableOnFixedLightSurfaces() throws Exception { - loadTables(); - List layouts = new ArrayList<>(); - for (String directory : new String[]{"src/leanback/res/layout", "src/main/res/layout", "src/mobile/res/layout"}) { - Path base = root().resolve(directory); - if (!Files.isDirectory(base)) continue; - try (var paths = Files.walk(base)) { - paths.filter(Files::isRegularFile) - .filter(path -> path.getFileName().toString().endsWith(".xml")) - .sorted() - .forEach(layouts::add); - } - } - assertFalse("no layouts were found to inspect", layouts.isEmpty()); - - List failures = new ArrayList<>(); - for (Path layout : layouts) { - String name = layout.getFileName().toString(); - if (isExcluded(name)) continue; - String source = Files.readString(layout, StandardCharsets.UTF_8).replace("\r\n", "\n"); - for (String[] hit : inspect(source)) { - double worst = Double.parseDouble(hit[2]); - if (worst < MIN_CONTRAST) { - failures.add(name + ": " + hit[0] + " sits on the fixed light surface " + hit[1] - + " with " + ratio(hit[2]) + " at worst (day " + ratio(hit[3]) + ", night " + ratio(hit[4]) + ")"); - } - } - } - if (!failures.isEmpty()) { - fail("TV paints a palette-following foreground on a palette-independent light surface," - + " which becomes near-white on near-white in the night table:\n " + String.join("\n ", failures)); - } - } - - /** - * Proves the guard above is not vacuous. - * - *

The probe feeds {@link #inspect} the exact markup the exit-confirm dialog carried - * before the fix and requires it to be flagged with a sub-threshold worst-case ratio, so a - * future regression cannot hide behind an empty match set. - */ - @Test - public void theGuardDetectsTheDefectItWasWrittenFor() { - loadTables(); - String defective = "" - + ""; - List hits = inspect(defective); - assertFalse("the guard must flag the measured pre-fix markup", hits.isEmpty()); - assertEquals("@drawable/selector_exit_confirm_secondary", hits.get(0)[1]); - assertTrue("the probe must reproduce the unreadable ratio", Double.parseDouble(hits.get(0)[2]) < MIN_CONTRAST); - - // The ad-block stats found on the second sweep: a state-aware @color/ selector whose states are - // palette roles, plus a drawable tint instead of a text colour. Both shapes must be caught. - String selectorDriven = "" - + ""; - List selectorHits = inspect(selectorDriven); - assertFalse("a palette-following @color/ selector must be caught too", selectorHits.isEmpty()); - assertTrue("the selector probe must reproduce the unreadable ratio", - Double.parseDouble(selectorHits.get(0)[2]) < MIN_CONTRAST); - - // The mobile ad-stats log page: a palette role sitting on the fixed light band rather than - // on a drawable. Without this probe the colour branch of surfaceFills could regress to - // ignoring @color/ fills and the mobile flavour would silently drift back to 2.37:1. - String bandDriven = "" - + ""; - List bandHits = inspect(bandDriven); - assertFalse("a palette-following foreground on a fixed @color/ band must be caught", bandHits.isEmpty()); - assertTrue("the band probe must reproduce the unreadable ratio", - Double.parseDouble(bandHits.get(0)[2]) < MIN_CONTRAST); - - String tinted = "" - + ""; - assertFalse("a palette-following drawable tint must be caught", inspect(tinted).isEmpty()); - - // The mirror of the same markup: a palette-independent dark foreground must be ignored here. - String alreadyCorrect = "" - + ""; - assertTrue("a palette-independent foreground is not this guard's concern", - inspect(alreadyCorrect).isEmpty()); - } - - /** - * Pins the surface this defect was fixed on, so a later "migrate the TV foregrounds" - * sweep cannot put a palette-following role back on it. - */ - @Test - public void fixedLightPanelsKeepPaletteIndependentForegrounds() throws Exception { - loadTables(); - String[][] expectations = { - {"src/leanback/res/layout/dialog_exit_confirm.xml", "@drawable/selector_exit_confirm_secondary", - "@color/exit_confirm_secondary_text"}, - {"src/leanback/res/layout/adapter_ad_block_log.xml", "@drawable/selector_ad_stats_item", "@color/fixed_light_focus_text"}, - {"src/leanback/res/layout/adapter_ad_stats_item.xml", "@drawable/selector_ad_stats_item", "@color/fixed_light_focus_text"}, - {"src/leanback/res/layout/dialog_ad_block_stats.xml", "@drawable/shape_ad_stats_content", "@color/fixed_light_focus_text"}, - // The same dialog's log page: a solid @color/ band, not a drawable, so it needs the - // colour branch of surfaceFills - and its headers plus its eight filter buttons. - {"src/leanback/res/layout/dialog_ad_block_stats.xml", "@color/fixed_light_band", "@color/fixed_light_focus_text"}, - - // The mobile flavour of the same dialog kept the scrim band for one more cycle: its - // twelve column headers and eight filter buttons still drew palette roles on a band - // that composites to mid-grey, so the night table painted #A8C7FA on #9D9DA2 (2.37:1). - {"src/mobile/res/layout/dialog_ad_block_stats.xml", "@color/fixed_light_band", "@color/fixed_light_focus_text"}, - {"src/main/res/layout/adapter_mpv_config_profile.xml", "@drawable/selector_mpv_profile_card", "@color/fixed_light_focus_text"}, - {"src/main/res/layout/dialog_ai_recommendation_info.xml", "@drawable/shape_recommendation_source_chip", "@color/fixed_light_accent"}, - {"src/main/res/layout/dialog_custom_csp.xml", "@drawable/selector_dialog_step_button", "@color/fixed_light_focus_text"}, - {"src/main/res/layout/dialog_mpv_config_create.xml", "@drawable/selector_mpv_icon_button", "@color/fixed_light_focus_icon"}, - {"src/main/res/layout/dialog_mpv_config_manager.xml", "@drawable/selector_mpv_icon_button", "@color/fixed_light_focus_icon"}, - {"src/main/res/layout/dialog_mpv_config_rename.xml", "@drawable/selector_mpv_icon_button", "@color/fixed_light_focus_icon"}, - {"src/mobile/res/layout/adapter_github_proxy.xml", "@drawable/selector_custom_csp_item", "@color/fixed_light_focus_text"}, - {"src/leanback/res/layout/adapter_ad_rule.xml", "@drawable/selector_dialog_switch", "@color/fixed_light_focus_icon"}, - {"src/main/res/layout/adapter_custom_csp.xml", "@drawable/selector_dialog_switch", "@color/fixed_light_focus_icon"}, - {"src/main/res/layout/dialog_update_settings.xml", "@drawable/selector_dialog_step_button", "@color/fixed_light_focus_icon"}, - {"src/leanback/res/layout/dialog_update.xml", "@drawable/selector_dialog_step_button", "@color/fixed_light_focus_icon"}, - {"src/mobile/res/layout/adapter_github_proxy.xml", "@drawable/selector_dialog_switch", "@color/fixed_light_error"}, - }; - for (String[] expectation : expectations) { - String source = readIfExists(expectation[0]); - assertTrue(expectation[0] + " is missing", source != null); - assertTrue(expectation[0] + " must still paint its surface with " + expectation[1], - source.contains(expectation[1])); - assertTrue(expectation[0] + " must paint that surface with the fixed " + expectation[2] - + " foreground, not a palette role", - source.contains(expectation[2])); - for (String[] hit : inspect(source)) { - if (!expectation[1].equals(hit[1])) continue; - fail(expectation[0] + " must not paint " + hit[0] + " on " + hit[1] - + " (worst " + ratio(hit[2]) + ", day " + ratio(hit[3]) + ", night " + ratio(hit[4]) + ")"); - } - } - } - - /** - * The pinned surface must really be palette-independent and light, otherwise the fix - * above would be protecting the wrong thing. - */ - @Test - public void theFixedLightSurfacesAreStillPaletteIndependentAndLight() throws Exception { - loadTables(); - for (String drawable : new String[]{ - "selector_exit_confirm_secondary", "selector_ad_stats_item", "shape_ad_stats_content", - "selector_mpv_profile_card", "shape_mpv_active_badge", "shape_recommendation_source_chip", - "shape_douban_rating_chip_light", "selector_dialog_step_button", "selector_mpv_icon_button", - "selector_custom_csp_item", "selector_dialog_switch"}) { - List fills = fixedFills(drawable); - assertTrue(drawable + " must have no palette-following fill, or the guard is moot", fills != null); - assertTrue(drawable + " must be light in both tables", - fills.stream().allMatch(fill -> luminance(fill) >= LIGHT_SURFACE_LUMINANCE)); - } - } - - private static String ratio(String value) { - return String.format(Locale.ROOT, "%.2f:1", Double.parseDouble(value)); - } - - private static boolean isExcluded(String name) { - for (String exempt : EXEMPT_LAYOUTS) if (exempt.equals(name)) return true; - for (String lab : LAB_LAYOUTS) if (lab.equals(name)) return true; - return false; - } - - /** - * {@code {foreground, surface, worstRatio, dayRatio, nightRatio}} for every matched element. - */ - private static List inspect(String source) { - List hits = new ArrayList<>(); - Deque backgrounds = new ArrayDeque<>(); - Matcher matcher = TAG.matcher(source); - while (matcher.find()) { - boolean closing = !matcher.group(1).isEmpty(); - String attributes = matcher.group(3); - boolean selfClosing = !matcher.group(4).isEmpty(); - if (closing) { - if (!backgrounds.isEmpty()) backgrounds.pop(); - continue; - } - Matcher backgroundMatcher = BACKGROUND.matcher(attributes); - String own = backgroundMatcher.find() ? backgroundMatcher.group(1) : null; - if (!selfClosing) backgrounds.push(own == null ? "" : own); - - Matcher colorMatcher = FOREGROUND.matcher(attributes); - List found = new ArrayList<>(); - List labels = new ArrayList<>(); - while (colorMatcher.find()) { - List[] palette = foregroundColors(colorMatcher.group(1)); - if (palette == null) continue; - found.add(new String[]{palette[0].get(0), palette[1].get(0)}); - labels.add(colorMatcher.group(1)); - found.set(found.size() - 1, new String[]{ - String.join(",", palette[0]), String.join(",", palette[1])}); - } - if (found.isEmpty()) continue; - - String nearest = own != null && !own.isEmpty() ? own : null; - if (nearest == null) { - for (String ancestor : backgrounds) { - if (ancestor != null && !ancestor.isEmpty()) { - nearest = ancestor; - break; - } - } - } - List fills = surfaceFills(nearest); - if (fills == null) continue; - boolean light = fills.stream().allMatch(fill -> luminance(fill) >= LIGHT_SURFACE_LUMINANCE); - if (!light) continue; - for (int index = 0; index < found.size(); index++) { - double day = Double.MAX_VALUE; - for (String color : found.get(index)[0].split(",")) { - day = Math.min(day, fills.stream().mapToDouble(fill -> contrast(color, fill)).min().orElse(Double.MAX_VALUE)); - } - double night = Double.MAX_VALUE; - for (String color : found.get(index)[1].split(",")) { - night = Math.min(night, fills.stream().mapToDouble(fill -> contrast(color, fill)).min().orElse(Double.MAX_VALUE)); - } - double worst = Math.min(day, night); - hits.add(new String[]{labels.get(index), nearest, - String.valueOf(worst), String.valueOf(day), String.valueOf(night)}); - } - } - return hits; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/UiTokenGatePortabilityContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/UiTokenGatePortabilityContractTest.java deleted file mode 100644 index 05cf8b9d6d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/UiTokenGatePortabilityContractTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.fongmi.android.tv.theme; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * UI token 静态门槛的可执行性契约。 - * - *

设计文档 §13.1 把 {@code scripts/check_ui_tokens.sh} 列为**每阶段必做**的静态验证。 - * 但该脚本把全部检查体放在内嵌的 {@code python3} heredoc 里,真正依赖 bash 的只有参数解析与 - * {@code git rev-parse}——在没有可用 bash 的主机上(Windows + WSL 服务不可用)它**根本无法 - * 运行**,于是这个门槛在相当长一段 UI 工作里**静默地没有被执行过**,期间有固定色被引入而 - * 无人拦下(审计实测:{@code violations=1}、{@code hex_layouts=1})。 - * - *

本契约钉住三件事: - *

    - *
  1. 存在一个**不依赖 bash** 的等价入口;
  2. - *
  3. 两个入口的 CLI 契约一致(选项名、strict 推导规则、退出码语义);
  4. - *
  5. 门槛的五项检查一项都没少(防止有人把移植版简化成"只看对比度")。
  6. - *
- * - *

注意:这里断言的是**脚本源码的内容契约**,不是运行结果——运行结果由 CI/人工执行 - * {@code python scripts/check_ui_tokens.py --strict} 得到。之所以用源码断言,是因为 JVM - * 单测里无法可靠地 spawn 一个 python 进程(且不该依赖宿主装了 python)。 - */ -public class UiTokenGatePortabilityContractTest { - - private static final String SH = "scripts/check_ui_tokens.sh"; - private static final String PY = "scripts/check_ui_tokens.py"; - - /** 必须存在一个不依赖 bash 的入口。 */ - @Test - public void aShellIndependentEntryPointExists() throws Exception { - Path py = resolve(PY); - assertTrue("必须存在 " + PY + "(无 bash 主机上的等价入口)", Files.isRegularFile(py)); - String source = Files.readString(py, StandardCharsets.UTF_8); - assertFalse("移植版不得再调用 bash/sh", source.contains("subprocess.run([\"bash\"")); - assertFalse("移植版不得依赖内嵌 heredoc", source.contains("<<'PY'")); - assertTrue("必须声明它是 .sh 的移植(否则下一个人会以为是第二套实现)", - source.contains("Python port of scripts/check_ui_tokens.sh")); - } - - /** - * CLI 契约必须一致:选项名、strict 推导规则、退出码。 - */ - @Test - public void bothEntryPointsShareTheSameCliContract() throws Exception { - String sh = read(SH); - String py = read(PY); - for (String option : new String[]{"--baseline", "--strict", "--stage", "--tokens-file", "--allowlist"}) { - assertTrue(".sh 必须接受 " + option, sh.contains(option)); - assertTrue(".py 必须接受 " + option, py.contains(option)); - } - // 退出码语义:PASS=0 / FAIL=1 / 用法错误=2 - assertTrue(".sh 必须用 1 表示失败", sh.contains("raise SystemExit(1 if failed else 0)")); - assertTrue(".py 必须用 1 表示失败", py.contains("return 1 if failed else 0")); - assertTrue(".py 必须用 2 表示用法错误", py.contains("return 2")); - // strict 推导:--baseline 关;stage 非 A 开 - for (String source : new String[]{sh, py}) { - assertTrue("必须保留「--baseline 关 strict」的规则", source.contains("baseline")); - assertTrue("必须保留「stage 非 A 开 strict」的规则", source.contains("\"A\"")); - } - } - - /** - * 五项检查一项都不能少。逐项按输出行断言——那些行是脚本对外的可观测契约。 - */ - @Test - public void allFiveChecksSurviveThePort() throws Exception { - String py = read(PY); - // 1) token 完整性 2) style 完整性 3) 对比度 4) 固定色扫描 5) 遗留主题名 - assertTrue("缺 token 完整性检查", py.contains("UI_TOKEN_STATUS FAIL missing tokens=")); - assertTrue("缺 style 完整性检查", py.contains("UI_TOKEN_STATUS FAIL missing styles=")); - assertTrue("缺对比度检查", py.contains("UI_TOKEN_CONTRAST_FAILURE")); - assertTrue("缺固定色扫描", py.contains("UI_TOKEN_VIOLATION")); - assertTrue("缺遗留主题名检查", py.contains("UI_TOKEN_LEGACY")); - // 基线行与状态行是既有记录引用的可观测输出,必须保持。 - // 注意按**前缀**断言:这些行是 f-string 拼接的(例如 f"UI_TOKEN_SCOPE {scope} ..."), - // 所以 "UI_TOKEN_SCOPE stage=" 这种含具体值的字面量在源码里并不存在。 - assertTrue("缺 UI_TOKEN_BASELINE 行", py.contains("UI_TOKEN_BASELINE layouts=")); - assertTrue("缺 UI_TOKEN_SCOPE 行", py.contains("UI_TOKEN_SCOPE ")); - assertTrue("缺 UI_TOKEN_CONTRAST 行", py.contains("UI_TOKEN_CONTRAST pairs=")); - assertTrue("缺 UI_TOKEN_STATUS 行", py.contains("UI_TOKEN_STATUS ")); - // 扫描范围必须与 .sh 一致(少了任何一类都会让门槛变松) - for (String root : new String[]{ - "app/src/main/res/layout", "app/src/mobile/res/layout", "app/src/leanback/res/layout", - "app/src/main/res/drawable", "app/src/mobile/res/drawable", "app/src/leanback/res/drawable", - "app/src/main/res/color", "app/src/main/assets"}) { - assertTrue("扫描范围缺 " + root, py.contains(root)); - } - } - - /** - * 规范必须指向新入口,否则下一个人仍然只会去跑 .sh 然后卡住。 - */ - @Test - public void theSpecPointsAtTheRunnableEntryPoint() throws Exception { - String spec = read("docs/webhtv-unified-visual-design-system-20260920.md"); - assertTrue("§13.1 必须给出无 bash 主机上的替代入口", - spec.contains("python scripts/check_ui_tokens.py")); - assertTrue("必须说明两者是同一门槛的两个入口", spec.contains("同一个门槛的两个入口")); - } - - // ------------------------------------------------------------------ helpers - - private static Path resolve(String path) { - Path direct = Path.of(path); - return Files.exists(direct) ? direct : Path.of("..").resolve(path); - } - - private static String read(String path) throws Exception { - return Files.readString(resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/UpdateVersionRowThemeTest.java b/app/src/test/java/com/fongmi/android/tv/theme/UpdateVersionRowThemeTest.java deleted file mode 100644 index e29a9d0ceb..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/theme/UpdateVersionRowThemeTest.java +++ /dev/null @@ -1,240 +0,0 @@ -package com.fongmi.android.tv.theme; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * The version cards in the “版本更新” dialog must be readable on every shipped palette - * and under a user theme profile. - * - *

Regression under test (user screenshot, 1080x2340, dark wallpaper, “主题色彩 = - * 壁纸取色”): the beta card rendered as a solid {@code #0F1D26} block carrying - * {@code #49454F} text - 1.5:1 - while the stable card beside it stayed legible. - * The two cards took their fill from the static - * {@code selector_update_version_item} drawable and their text from three separate - * {@code ColorStateList}s. {@link ThemeBinder} rewrites those text lists but leaves the - * card's {@code StateListDrawable} untouched, so on a derived palette the text and the - * fill drift apart. Most users never see it because the default profile resolves - * {@code ThemeSeed.NONE} and {@code ThemeBinder} is then a no-op. - * - *

These assertions are pure arithmetic on the shipped token tables plus source - * contracts, so they fail on the old wiring and pass on the fix without a device. - */ -public class UpdateVersionRowThemeTest { - - @Test - public void everyCardTextClearsWcagAaOnBothCardFillsForEveryShippedPalette() { - for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { - for (boolean expanded : new boolean[]{false, true}) { - int fill = UpdateVersionRowTheme.fill(tokens, expanded); - String where = (tokens == ThemeTokens.dark() ? "dark" : "light") + (expanded ? "/expanded" : "/resting"); - assertTrue(where + " name", ThemeContrast.ratio(UpdateVersionRowTheme.nameColor(tokens, expanded), fill) >= 4.5); - assertTrue(where + " version", ThemeContrast.ratio(UpdateVersionRowTheme.versionColor(tokens, expanded), fill) >= 4.5); - assertTrue(where + " description", ThemeContrast.ratio(UpdateVersionRowTheme.descColor(tokens, expanded), fill) >= 4.5); - assertTrue(where + " expand affordance", ThemeContrast.ratio(UpdateVersionRowTheme.expandColor(tokens, expanded), fill) >= 4.5); - } - } - } - - /** - * The card keeps its text colours while the user toggles it open, so a colour that - * only clears AA on one of the two fills would go unreadable on the other. Pin that - * directly instead of trusting the two per-state assertions above. - */ - @Test - public void cardTextSurvivesTheRestingToExpandedFillChange() { - for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { - int resting = UpdateVersionRowTheme.fill(tokens, false); - int expanded = UpdateVersionRowTheme.fill(tokens, true); - assertNotEquals("the two states must be distinguishable", resting, expanded); - for (int color : new int[]{ - UpdateVersionRowTheme.nameColor(tokens, false), - UpdateVersionRowTheme.versionColor(tokens, false), - UpdateVersionRowTheme.descColor(tokens, false), - UpdateVersionRowTheme.expandColor(tokens, false)}) { - assertTrue(ThemeContrast.ratio(color, resting) >= 4.5); - assertTrue(ThemeContrast.ratio(color, expanded) >= 4.5); - } - } - } - - @Test - public void thePillAndItsLabelStayReadable() { - for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { - int fill = UpdateVersionRowTheme.pillFill(tokens); - assertTrue(ThemeContrast.ratio(UpdateVersionRowTheme.pillTextColor(tokens), fill) >= 4.5); - // The pill has to be visible on both card fills, otherwise it reads as part of - // the card instead of as a badge. - assertTrue(ThemeContrast.ratio(fill, UpdateVersionRowTheme.fill(tokens, false)) >= 1.2); - } - } - - @Test - public void selectedAndFocusedRingsStayVisibleAgainstTheirOwnFill() { - for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { - assertTrue("resting ring", UpdateVersionRowTheme.ringIsVisible(tokens, false)); - assertTrue("expanded ring", UpdateVersionRowTheme.ringIsVisible(tokens, true)); - } - } - - /** - * The static selector separated "focused" from "selected" by swapping the fill and ring - * roles ({@code colorPrimary} vs {@code colorPrimaryContainer}). The runtime renderer has - * to keep that: the TV dialog always opens with {@code stableItem.requestFocus()}, so a - * focus fill of {@code colorPrimaryContainer} would make the focused card look like the - * expanded one and collapse the two clues into one. - */ - @Test - public void theFocusedCardStaysDistinguishableFromTheSelectedCard() { - for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { - String where = tokens == ThemeTokens.dark() ? "dark" : "light"; - int focused = UpdateVersionRowTheme.focusFill(tokens); - int selected = UpdateVersionRowTheme.fill(tokens, true); - int resting = UpdateVersionRowTheme.fill(tokens, false); - assertNotEquals(where + ": focus must not paint the selected fill", focused, selected); - assertTrue(where + ": the focus fill has to clear the non-text floor against the selected fill", - ThemeContrast.ratio(focused, selected) >= 3.0); - assertTrue(where + ": the focus fill has to differ from the resting fill", ThemeContrast.ratio(focused, resting) >= 3.0); - assertTrue(where + ": focus ink on the focus fill", - ThemeContrast.ratio(UpdateVersionRowTheme.focusInk(tokens), focused) >= 4.5); - // The selected card keeps the resting ink while it is not focused, so the - // selected fill still has to carry it. - assertTrue(where + ": resting ink on the selected fill", - ThemeContrast.ratio(UpdateVersionRowTheme.nameColor(tokens, true), selected) >= 4.5); - } - } - - /** - * The contract test in the leanback source set reads - * {@code selector_update_version_item.xml}, which this dialog no longer renders: the - * helper overrides {@code row.setBackground(...)}. Pin the runtime side here so focus and - * selected cannot silently collapse again while that test stays green. - */ - @Test - public void theCardFaceKeepsItsStatesAndTheSharedRingWidth() throws Exception { - String source = codeOnly(read("src/main/java/com/fongmi/android/tv/theme/UpdateVersionRowTheme.java")); - for (String state : new String[]{"android.R.attr.state_focused", "android.R.attr.state_pressed", "android.R.attr.state_selected"}) { - assertTrue("the card face must declare " + state, source.contains(state)); - } - assertTrue("the focused face must paint the primary fill", source.contains("faced(context, safe.colorPrimary(),")); - assertTrue("the selected face must paint the primary container fill", source.contains("faced(context, safe.colorPrimaryContainer(),")); - assertTrue("the ring width must come from the shared dimens token", source.contains("R.dimen.webhtv_focus_ring_width")); - assertTrue("the card text must flip on focus", source.contains("setTextColor(textColors(")); - assertTrue("focused and pressed must be matched before the default entry", - source.contains("new int[][]{new int[]{android.R.attr.state_focused}, new int[]{android.R.attr.state_pressed}, new int[]{}}")); - // The state list only reaches the text because the card nodes propagate the row - // state; without these declarations the focused branch would never be selected. - for (String flavour : new String[]{"mobile", "leanback"}) { - String layout = read("src/" + flavour + "/res/layout/dialog_update.xml"); - int declarations = layout.split("duplicateParentState", -1).length - 1; - assertTrue(flavour + ": every card text node must propagate the row focus state", declarations >= 8); - } - assertFalse("the ring width must not be a per-widget literal", source.contains(", 2, CORNER_DP)")); - } - - /** - * Pins the reported defect numerically. The user screenshot measured a - * {@code #0F1D26} card carrying {@code #49454F} text; a fix that still allowed that - * pair would pass every other assertion here. - */ - @Test - public void theReportedScreenshotPairIsExactlyTheUnreadableOne() { - int reportedFill = 0xFF0F1D26; - int reportedText = 0xFF49454F; - assertTrue("the reported pair must be the unreadable one", - ThemeContrast.ratio(reportedText, reportedFill) < 2.0); - assertTrue("the fix must not reproduce it", - ThemeContrast.ratio(UpdateVersionRowTheme.versionColor(ThemeTokens.dark(), false), - UpdateVersionRowTheme.fill(ThemeTokens.dark(), false)) >= 4.5); - } - - /** - * An arbitrary user palette can make {@code onSurfaceVariant} collide with the card - * fill - {@link ThemeContrast#require(ThemeTokens)} never checks that pair. The card - * has to fall back to a colour that does clear AA instead of painting an unreadable - * one. - */ - @Test - public void aCollidingProfileFallsBackToAReadableForeground() { - int fill = 0xFF0F1D26; - int colliding = 0xFF11202A; - assertEquals("white is the only fallback that clears AA on a dark fill", 0xFFFFFFFF, UpdateVersionRowTheme.readableOn(colliding, fill)); - assertTrue(ThemeContrast.ratio(UpdateVersionRowTheme.readableOn(colliding, fill), fill) >= 4.5); - int paleFill = 0xFFE1E2E9; - assertEquals("black is the only fallback that clears AA on a pale fill", 0xFF000000, UpdateVersionRowTheme.readableOn(0xFFDDDEE6, paleFill)); - // A colour that already clears AA on every fill must be kept, not replaced. - assertEquals(0xFF000000, UpdateVersionRowTheme.readableOn(0xFF000000, paleFill)); - assertEquals(0xFFFFFFFF, UpdateVersionRowTheme.readableOn(0xFFFFFFFF, fill)); - } - - @Test - public void bothFlavoursRouteTheCardsThroughTheSharedPalette() throws Exception { - for (String flavour : new String[]{"mobile", "leanback"}) { - String dialog = codeOnly(read("src/" + flavour + "/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java")); - assertTrue(flavour + " must paint the card from the active palette", - dialog.contains("UpdateVersionRowTheme.apply(binding.stableItem, binding.stableName, binding.stableVersion, binding.stableStatus, binding.stableExpand, binding.stableDesc, expanded, tokens);")); - assertTrue(flavour + " must paint the beta card too", - dialog.contains("UpdateVersionRowTheme.apply(binding.betaItem, binding.betaName, binding.betaVersion, binding.betaStatus, binding.betaExpand, binding.betaDesc, expanded, tokens);")); - assertTrue(flavour + " must read the active palette", dialog.contains("ThemeTokens tokens = ThemeController.current();")); - assertTrue(flavour + " must apply the theme while rendering", dialog.contains("applyCardTheme(channel, expanded);")); - // The old wiring let the card fill and the card text come from different graphs. - assertFalse(flavour + " must not colour card text inline from a fixed resource", - dialog.contains("setTextColor(R.color.update_version_item")); - } - } - - /** - * The helper must own the whole card, including the {@code webhtv:ignore} tag: the - * dialog still runs {@link ThemeController#bindDialog}, and without the tag the binder - * would re-colour the text from a different role mapping and undo the guarantee above. - */ - @Test - public void theHelperOwnsTheCardSubtreeAndUsesOnlySemanticRoles() throws Exception { - String source = codeOnly(read("src/main/java/com/fongmi/android/tv/theme/UpdateVersionRowTheme.java")); - assertTrue(source.contains("row.setTag(IGNORE_TAG)")); - assertTrue(source.contains("safe.colorSurfaceContainerHighest()")); - assertTrue(source.contains("safe.colorPrimaryContainer()")); - assertTrue(source.contains("safe.colorOnSurface()")); - assertTrue(source.contains("safe.colorOnSurfaceVariant()")); - assertTrue(source.contains("safe.colorPrimary()")); - assertTrue(source.contains("safe.colorOutlineVariant()")); - assertTrue(source.contains("colorSurface()")); - assertFalse("the card renderer must not hard-code a fill", source.contains("#0F1D26")); - assertFalse(source.contains("#E1E2E9")); - assertFalse(source.contains("#34393E")); - } - - /** Strips comments so a source contract matches code, not the prose naming the defect. */ - private static String codeOnly(String source) { - StringBuilder out = new StringBuilder(source.length()); - int index = 0; - while (index < source.length()) { - char current = source.charAt(index); - char next = index + 1 < source.length() ? source.charAt(index + 1) : '\0'; - if (current == '/' && next == '/') { - while (index < source.length() && source.charAt(index) != '\n') index++; - } else if (current == '/' && next == '*') { - index += 2; - while (index + 1 < source.length() && !(source.charAt(index) == '*' && source.charAt(index + 1) == '/')) index++; - index = Math.min(source.length(), index + 2); - } else { - out.append(current); - index++; - } - } - return out.toString(); - } - - private static String read(String path) throws Exception { - Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/title/MediaTitleCacheTest.java b/app/src/test/java/com/fongmi/android/tv/title/MediaTitleCacheTest.java index 4d6e445324..650acd7d85 100644 --- a/app/src/test/java/com/fongmi/android/tv/title/MediaTitleCacheTest.java +++ b/app/src/test/java/com/fongmi/android/tv/title/MediaTitleCacheTest.java @@ -4,10 +4,7 @@ import com.fongmi.android.tv.bean.AiConfig; import org.junit.Test; -import java.util.List; - import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertEquals; public class MediaTitleCacheTest { @@ -47,74 +44,4 @@ public class MediaTitleCacheTest { assertNotEquals(cache.key(firstRequest, config), cache.key(secondRequest, config)); } - - @Test - public void key_changesWhenRecognitionContextChanges() { - MediaTitleRequest firstRequest = MediaTitleRequest.builder() - .rawTitle("qyn 第二季") - .folderName("/media/庆余年") - .contextTitles(java.util.List.of("庆余年 第01集")) - .build(); - MediaTitleRequest secondRequest = MediaTitleRequest.builder() - .rawTitle("qyn 第二季") - .folderName("/media/另一部剧") - .contextTitles(java.util.List.of("另一部剧 第01集")) - .build(); - - MediaTitleCache cache = new MediaTitleCache(); - AiConfig config = AiConfig.objectFrom("{}"); - - assertNotEquals(cache.key(firstRequest, config), cache.key(secondRequest, config)); - } - - @Test - public void request_contextIsBoundedAndFolderIsReducedToBasename() { - MediaTitleRequest request = MediaTitleRequest.builder() - .folderName("/private/path/庆余年/") - .contextTitles(List.of("relative/path/title-0.mkv", "/private/path/title-1.mkv")) - .build(); - - assertEquals("庆余年", request.getFolderName()); - assertEquals(2, request.getContextTitles().size()); - assertEquals("title-0.mkv", request.getContextTitles().get(0)); - } - - @Test - public void request_contextTitlesAreLimitedToSixteenEntries() { - java.util.List values = new java.util.ArrayList<>(); - for (int i = 0; i < 30; i++) values.add("/private/path/title-" + i + ".mkv"); - - MediaTitleRequest request = MediaTitleRequest.builder().contextTitles(values).build(); - - assertEquals(16, request.getContextTitles().size()); - assertEquals("title-0.mkv", request.getContextTitles().get(0)); - } - - @Test - public void key_changesWhenPromptRecognitionInputsChange() { - MediaTitleRequest base = MediaTitleRequest.builder() - .rawTitle("庆余年 S02E05") - .build(); - MediaTitleRequest remarks = MediaTitleRequest.builder() - .rawTitle("庆余年 S02E05") - .rawRemarks("更新至05集") - .build(); - MediaTitleRequest year = MediaTitleRequest.builder() - .rawTitle("庆余年 S02E05") - .vodYear("2024") - .build(); - MediaTitleRequest learning = MediaTitleRequest.builder() - .rawTitle("庆余年 S02E05") - .learningExamples(List.of(MediaTitleLearningExample.manual( - "qyn", "qyn", "庆余年", "tv", 0, 2, - MediaTitleLearningExample.SOURCE_TMDB_MANUAL))) - .build(); - - MediaTitleCache cache = new MediaTitleCache(); - AiConfig config = AiConfig.objectFrom("{}"); - - assertNotEquals(cache.key(base, config), cache.key(remarks, config)); - assertNotEquals(cache.key(base, config), cache.key(year, config)); - assertNotEquals(cache.key(base, config), cache.key(learning, config)); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/title/MediaTitleParserHantTest.java b/app/src/test/java/com/fongmi/android/tv/title/MediaTitleParserHantTest.java deleted file mode 100644 index d99cfebd0c..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/title/MediaTitleParserHantTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.fongmi.android.tv.title; - -import com.github.catvod.utils.Trans; - -import org.junit.After; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -/** - * 繁体界面下站源标题已经被 {@code Vod.trans()} 转成繁体,标题清洗词表必须同时覆盖繁体写法。 - * - *

场景来源:用户反馈「繁体语言的场景下会导致 TMDB 匹配失败」。繁体态下「慶餘年(高碼)」 - * 这类标题的尾部标记若清洗不掉,会连标记一起当作搜索词发给 TMDB,直接搜出 0 条结果。 - */ -public class MediaTitleParserHantTest { - - @After - public void tearDown() { - Trans.setTraditional(null); - } - - @Test - public void cleanTitle_removesTraditionalQualityTag() { - assertEquals("慶餘年", new MediaTitleParser().cleanTitle("慶餘年(高碼)")); - } - - @Test - public void cleanTitle_removesTraditionalLanguageTag() { - assertEquals("與鳳行", new MediaTitleParser().cleanTitle("與鳳行 國語中字")); - } - - @Test - public void cleanTitle_removesTraditionalSerialTag() { - assertEquals("贅婿", new MediaTitleParser().cleanTitle("贅婿 連載至第30集")); - } - - @Test - public void cleanTitle_removesTraditionalRegionTag() { - assertEquals("甄嬛傳", new MediaTitleParser().cleanTitle("甄嬛傳 大陸版")); - } - - @Test - public void cleanTitle_removesTraditionalBilingualTag() { - assertEquals("長相思", new MediaTitleParser().cleanTitle("長相思 雙語字幕")); - } - - @Test - public void cleanTitle_keepsTraditionalTitleIntact() { - assertEquals("慶餘年", new MediaTitleParser().cleanTitle("慶餘年")); - } -} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/title/MediaTitleParserTest.java b/app/src/test/java/com/fongmi/android/tv/title/MediaTitleParserTest.java index 1fbcf51969..0d06e0d4e2 100644 --- a/app/src/test/java/com/fongmi/android/tv/title/MediaTitleParserTest.java +++ b/app/src/test/java/com/fongmi/android/tv/title/MediaTitleParserTest.java @@ -124,46 +124,6 @@ public class MediaTitleParserTest { assertEquals(List.of("云秀行"), titles); } - @Test - public void cleanSearchTitles_stripsGenreAndRegionGroupLabels() { - List titles = new MediaTitleParser().cleanSearchTitles( - MediaTitleRequest.builder() - .rawTitle("[电影] 小丑回魂:德里故事 (2026) [小丑回魂:欢迎来到德里镇 万圣节特别集][美国 恐怖 奇幻]") - .build()); - - assertEquals("小丑回魂:德里故事", titles.get(0)); - } - - @Test - public void cleanSearchTitles_keepsTitleBracketOnlyCategoryLabelsPresent() { - List titles = new MediaTitleParser().cleanSearchTitles( - MediaTitleRequest.builder() - .rawTitle("[美国][恐怖][我的电影].mkv") - .build()); - - assertEquals("我的电影", titles.get(0)); - } - - @Test - public void cleanSearchTitles_dropsResidualNonCategoryBracketNoise() { - List titles = new MediaTitleParser().cleanSearchTitles( - MediaTitleRequest.builder() - .rawTitle("[北美票房][美国][阿凡达].mkv") - .build()); - - assertEquals("阿凡达", titles.get(0)); - } - - @Test - public void cleanSearchTitles_ignoresTrailingReleaseNoiseBracket() { - List titles = new MediaTitleParser().cleanSearchTitles( - MediaTitleRequest.builder() - .rawTitle("[美国][阿凡达][完整版].mkv") - .build()); - - assertEquals("阿凡达", titles.get(0)); - } - @Test public void candidates_doNotReuseLearningOnlyBecauseBothTitlesHaveTrueColorTag() { MediaTitleLearningExample unrelated = MediaTitleLearningExample.manual( @@ -183,67 +143,4 @@ public class MediaTitleParserTest { assertEquals("云秀行", resolution.getCanonicalTitle()); } - - @Test - public void parse_usesStrongContextConsensusForLowConfidenceObfuscatedTitle() { - MediaTitleResolution resolution = new MediaTitleParser().parse( - MediaTitleRequest.builder() - .rawTitle("qyn 第2季 防和谐版") - .contextTitles(List.of( - "庆余年 第二季 第01集 4K", - "庆余年 第二季 第02集 1080P", - "庆余年 第二季 第03集 WEB-DL")) - .build()); - - assertEquals("庆余年", resolution.getCanonicalTitle()); - assertTrue(resolution.getContextConfidence() >= 0.85f); - assertEquals(4, resolution.getContextGroupSize()); - assertTrue(resolution.getCandidates().stream().anyMatch(candidate -> - MediaTitleCandidate.SOURCE_CONTEXT.equals(candidate.getSource()))); - assertTrue(resolution.getConfidenceBreakdown().contains("context=")); - } - - @Test - public void parse_doesNotLetOneConflictingContextReplaceUsefulRuleTitle() { - MediaTitleResolution resolution = new MediaTitleParser().parse( - MediaTitleRequest.builder() - .rawTitle("庆余年 第05集 4K") - .contextTitles(List.of("另一部剧 第01集")) - .build()); - - assertEquals("庆余年", resolution.getCanonicalTitle()); - assertTrue(resolution.getContextConfidence() < 0.85f); - } - - @Test - public void parse_doesNotPromoteRemarksOrEpisodeNameToContextVotes() { - MediaTitleResolution resolution = new MediaTitleParser().parse( - MediaTitleRequest.builder() - .rawTitle("qyn 防和谐版") - .rawRemarks("更新至18集") - .episodeName("第18集") - .build()); - - assertEquals(0, resolution.getContextGroupSize()); - assertEquals(0f, resolution.getContextConfidence(), 0.001f); - } - - @Test - public void parse_countsEachContextInputOnceWhenOneInputHasSeveralSearchCandidates() { - MediaTitleResolution resolution = new MediaTitleParser().parse( - MediaTitleRequest.builder() - .rawTitle("qyn 第2季") - .contextTitles(List.of("组 | 庆余年 | 01")) - .build()); - - assertEquals(2, resolution.getContextGroupSize()); - } - - @Test - public void cleanSearchTitles_rejectsPureUpdateRemark() { - List titles = new MediaTitleParser().cleanSearchTitles( - MediaTitleRequest.builder().rawTitle("更新至18集").build()); - - assertTrue(titles.isEmpty()); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/tts/TtsHttpRuleTest.java b/app/src/test/java/com/fongmi/android/tv/tts/TtsHttpRuleTest.java deleted file mode 100644 index 192ca56afb..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/tts/TtsHttpRuleTest.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.fongmi.android.tv.tts; - -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; - -public class TtsHttpRuleTest { - - @Test - public void encodesSpeakTextWithEncodeUriSemantics() { - String rendered = TtsHttpRule.render("https://a/b?text={{java.encodeURI(speakText)}}", "你好 世界", 5); - assertEquals("https://a/b?text=%E4%BD%A0%E5%A5%BD%20%E4%B8%96%E7%95%8C", rendered); - } - - @Test - public void encodeUriKeepsReservedCharacters() { - // JS encodeURI 不转义这些保留字符;换成 URLEncoder 语义会让部分接口取不到参数 - assertEquals("a-b_c.d!e~f'g(h)i/j:k?l,m;n", TtsHttpRule.encodeUri("a-b_c.d!e~f'g(h)i/j:k?l,m;n")); - } - - @Test - public void arithmeticSpeakSpeedIsEvaluated() { - // legado 百度规则:spd = (speakSpeed + 5) / 10 + 4(JS 语义,浮点除法) - assertEquals("5", TtsHttpRule.render("{{(speakSpeed + 5) / 10 + 4}}", "x", 5)); - assertEquals("4.6", TtsHttpRule.render("{{(speakSpeed + 5) / 10 + 4}}", "x", 1)); - assertEquals("6", TtsHttpRule.render("{{String((speakSpeed + 5) / 10 + 4)}}", "x", 15)); - assertEquals("500", TtsHttpRule.render("{{String((speakSpeed) * 20 - 400)}}", "x", 45)); - } - - @Test - public void nestedEncodeUriAndExpression() { - String rendered = TtsHttpRule.render( - "tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{speakSpeed}}", "中 文", 4); - // 双重编码后空格变成 %2520 - assertTrue(rendered, rendered.startsWith("tex=%25E4%25B8%25AD%2520%25E6%2596%2587&spd=4")); - } - - @Test - public void plainUrlWithoutOptionsParsesAsGet() { - TtsHttpRule rule = TtsHttpRule.parse( - "https://fanyi.baidu.com/gettts?lan=zh&text={{java.encodeURI(speakText)}}&spd={{speakSpeed}}&source=web", - "audio/mpeg"); - assertTrue(rule.isUsable()); - assertEquals("GET", rule.method()); - assertNull(rule.body("你好", 5)); - assertTrue(rule.url("你好", 5).contains("spd=5")); - } - - @Test - public void baiduStylePostRuleParsesBodyAndMethod() { - String raw = "http://tts.baidu.com/text2audio,{" - + "\"method\": \"POST\"," - + "\"body\": \"tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{(speakSpeed + 5) / 10 + 4}}&per=3\"" - + "}"; - TtsHttpRule rule = TtsHttpRule.parse(raw, "audio/wav"); - assertTrue(rule.unsupportedReason(), rule.isUsable()); - assertEquals("POST", rule.method()); - assertEquals("http://tts.baidu.com/text2audio", rule.url("文本", 5)); - String body = rule.body("文本", 5); - assertNotNull(body); - assertTrue(body, body.endsWith("&spd=5&per=3")); - } - - @Test - public void optionsWithHeadersAreParsed() { - String raw = "https://host/tts,{" - + "\"method\":\"POST\",\"body\":\"t={{speakText}}\"," - + "\"headers\":{\"User-Agent\":\"webhtv\",\"X-A\":\"1\"}}"; - TtsHttpRule rule = TtsHttpRule.parse(raw, "audio/mpeg"); - assertTrue(rule.isUsable()); - assertEquals("webhtv", rule.headers().get("User-Agent")); - assertEquals("1", rule.headers().get("X-A")); - } - - @Test - public void jsRuleIsRejectedWithReason() { - TtsHttpRule rule = TtsHttpRule.parse("@js:apiurl(speakText,speakSpeed);", "audio/mpeg"); - assertFalse(rule.isUsable()); - assertTrue(rule.unsupportedReason().contains("@js")); - } - - @Test - public void postWithoutBodyIsRejected() { - TtsHttpRule rule = TtsHttpRule.parse("https://host/tts,{\"method\":\"POST\"}", "audio/mpeg"); - assertFalse(rule.isUsable()); - } - - @Test - public void emptyRuleIsRejected() { - assertFalse(TtsHttpRule.parse("", "audio/mpeg").isUsable()); - assertFalse(TtsHttpRule.parse(null, "audio/mpeg").isUsable()); - } - - @Test - public void unknownExpressionIsLeftVerbatimSoItIsVisibleInLogs() { - assertEquals("{{foo}}", TtsHttpRule.render("{{foo}}", "x", 5)); - } - - @Test - public void engineConfigRoundTripsThroughJson() { - TtsEngineConfig config = new TtsEngineConfig(); - config.name = "我的引擎"; - config.url = "https://host/tts?t={{java.encodeURI(speakText)}}"; - config.contentType = "audio/mpeg"; - config.header = "{\"User-Agent\":\"webhtv\"}"; - TtsEngineConfig parsed = TtsEngineConfig.fromJson(config.toJson()); - assertNotNull(parsed); - assertEquals(config.name, parsed.name); - assertEquals(config.url, parsed.url); - assertEquals(config.contentType, parsed.contentType); - assertEquals("我的引擎", parsed.displayName()); - } - - @Test - public void engineConfigWithoutUrlIsRejected() { - assertNull(TtsEngineConfig.fromJson("{\"name\":\"x\"}")); - assertNull(TtsEngineConfig.fromJson("not json")); - } - - @Test - public void builtinBaiduRuleIsUsableAndMapsRateIntoEngineRange() { - // 内置百度音源规则必须可用,且语速映射落在接口有效区间 1..7 - TtsHttpRule rule = TtsHttpRule.parse( - "https://fanyi.baidu.com/gettts?lan=zh&text={{java.encodeURI(speakText)}}&spd={{speakSpeed}}&source=web", - "audio/mpeg"); - assertTrue(rule.isUsable()); - for (float rate : new float[]{0.5f, 1.0f, 1.5f, 2.0f}) { - int speed = Math.max(1, Math.min(7, Math.round(rate * 5f))); - assertTrue("rate=" + rate + " speed=" + speed, speed >= 1 && speed <= 7); - String url = rule.url("测试", speed); - assertTrue(url, url.contains("spd=" + speed)); - } - } - - @Test - public void optionsAreOrderedDescendingWhenMultipleCommasExist() { - // 真实场景:URL 模板里带逗号,选项在最后 - String raw = "https://host/tts?t={{java.encodeURI(speakText)}},{\"method\":\"GET\"}"; - TtsHttpRule rule = TtsHttpRule.parse(raw, "audio/mpeg"); - assertTrue(rule.unsupportedReason(), rule.isUsable()); - assertEquals("https://host/tts?t=a", rule.url("a", 5)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/tts/TtsTextSplitterTest.java b/app/src/test/java/com/fongmi/android/tv/tts/TtsTextSplitterTest.java deleted file mode 100644 index cc7d60da1e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/tts/TtsTextSplitterTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.fongmi.android.tv.tts; - -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.Arrays; -import java.util.List; - -public class TtsTextSplitterTest { - - @Test - public void systemModeKeepsWholeParagraph() { - List chunks = TtsTextSplitter.split( - Arrays.asList("第一段。第二句!", "第二段很长很长很长。"), 0); - assertEquals(2, chunks.size()); - assertEquals("第一段。第二句!", chunks.get(0).text); - assertEquals(0, chunks.get(0).paragraph); - assertEquals("第二段很长很长很长。", chunks.get(1).text); - assertEquals(1, chunks.get(1).paragraph); - } - - @Test - public void onlineModeSplitsSentencesAndTracksParagraphIndex() { - List chunks = TtsTextSplitter.split( - Arrays.asList("甲。乙!丙?", "丁。"), TtsTextSplitter.MAX_CHUNK); - assertEquals(4, chunks.size()); - assertEquals("甲。", chunks.get(0).text); - assertEquals("乙!", chunks.get(1).text); - assertEquals("丙?", chunks.get(2).text); - assertEquals("丁。", chunks.get(3).text); - // 段落归属必须正确,否则高亮会跳到别的段落 - assertEquals(0, chunks.get(0).paragraph); - assertEquals(0, chunks.get(2).paragraph); - assertEquals(1, chunks.get(3).paragraph); - } - - @Test - public void punctuationOnlyParagraphsAreDropped() { - List chunks = TtsTextSplitter.split( - Arrays.asList("……", "——", " ", "正文。"), TtsTextSplitter.MAX_CHUNK); - assertEquals(1, chunks.size()); - assertEquals("正文。", chunks.get(0).text); - assertEquals(3, chunks.get(0).paragraph); - } - - @Test - public void longSentenceIsHardSplitUnderLimit() { - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < 40; i++) sb.append("汉字测试,"); - sb.append("。"); - List chunks = TtsTextSplitter.split( - java.util.Collections.singletonList(sb.toString()), 20); - assertTrue("长句必须被切开", chunks.size() > 1); - for (TtsTextSplitter.Chunk chunk : chunks) { - assertTrue("单个片段不得超过上限: " + chunk.text.length(), chunk.text.length() <= 20); - } - } - - @Test - public void controlCharactersAreStripped() { - List chunks = TtsTextSplitter.split( - Arrays.asList("正文\u0000\u0007内容。"), 0); - assertEquals(1, chunks.size()); - assertFalse(chunks.get(0).text.contains("\u0000")); - assertFalse(chunks.get(0).text.contains("\u0007")); - assertEquals("正文内容。", chunks.get(0).text); - } - - @Test - public void silentDetectionMatchesSpeechEngineExpectation() { - assertTrue(TtsTextSplitter.isSilent("……")); - assertTrue(TtsTextSplitter.isSilent(" ")); - assertTrue(TtsTextSplitter.isSilent(null)); - assertFalse(TtsTextSplitter.isSilent("a")); - assertFalse(TtsTextSplitter.isSilent("字")); - assertFalse(TtsTextSplitter.isSilent(",。字。")); - } - - @Test - public void pitchMapsToSsmlOffset() { - assertEquals("+0Hz", TtsOptions.pitchOffsetHz(1.0f)); - assertEquals("+50Hz", TtsOptions.pitchOffsetHz(1.5f)); - assertEquals("-50Hz", TtsOptions.pitchOffsetHz(0.5f)); - // 越界值必须按面板范围夹取,避免把非法 pitch 丢给微软接口 - assertEquals("-50Hz", TtsOptions.pitchOffsetHz(0.1f)); - assertEquals("+50Hz", TtsOptions.pitchOffsetHz(9f)); - } - - @Test - public void emptyInputProducesNoChunks() { - assertTrue(TtsTextSplitter.split(null, 20).isEmpty()); - assertTrue(TtsTextSplitter.split(java.util.Collections.emptyList(), 20).isEmpty()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/C47SeekLoadingProgressSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/C47SeekLoadingProgressSourceTest.java deleted file mode 100644 index 54b80ee477..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/C47SeekLoadingProgressSourceTest.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * 拖拽进度时「大概率看不到加载中/网速」的回归契约。 - * - *

现象:拖动进度条后,加载圈(转圈 + 网速)基本不出现,或者一闪就没。 - * - *

根因:{@code onSeekStarted()} 会在 seek 命令发出前先亮圈,随后 {@code showProgress()} - * 立刻投递每秒一跳的网速 ticker({@code App.post(mR2/mR3, 0)})。那个 ticker 的兜底收口 - * {@code hidePlaybackProgressIfStale()} 读到引擎尚未开始 seek 的旧 READY,就在同一帧把圈 - * 收掉——圈因此连最小可见时长都活不到,第二跳网速采样(需要 1000ms 间隔)更没机会发生。 - * - *

修法:seek 期间打开一个「最小可见 + 必须确实可收」的窗口。窗口未关时任何收圈路径 - * (网速 ticker 兜底、READY 回调、控制器晚绑定补发)都不得收圈;窗口由显式收圈关闭。 - */ -public class C47SeekLoadingProgressSourceTest { - - private static final String MOBILE = - "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"; - private static final String LEANBACK = - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"; - private static final String[] SOURCES = {MOBILE, LEANBACK}; - - @Test - public void seekOpensTheMinimumVisibleWindowBeforeShowingProgress() throws Exception { - for (String path : SOURCES) { - String body = bodyToBrace(read(path), "protected void onSeekStarted()"); - - int pending = body.indexOf("mSeekProgressPending = true;"); - int startedAt = body.indexOf("mSeekProgressStartedAtMs = SystemClock.elapsedRealtime();"); - int show = body.indexOf("showProgress();"); - int arm = body.indexOf("App.post(mSeekProgressFallback, SEEK_PROGRESS_MIN_VISIBLE_MS);"); - - assertTrue(path + " must open the seek window", pending >= 0); - assertTrue(path + " must stamp the window start", startedAt > pending); - // showProgress() 会立刻投递网速 ticker,窗口必须先于它打开,否则第一跳就把圈收掉。 - assertTrue(path + " must open the window before showProgress() posts the traffic ticker", - show > startedAt); - assertTrue(path + " must arm the minimum-visible timer after showing progress", arm > show); - } - } - - @Test - public void theMinimumVisibleGateIsTheOnlyWayToCloseTheSeekWindow() throws Exception { - for (String path : SOURCES) { - String source = read(path); - String gate = bodyToBrace(source, "private boolean canHideSeekProgress()"); - String fallback = bodyToBrace(source, "private void hideSeekProgressIfReady()"); - String stale = bodyToBrace(source, "private void hidePlaybackProgressIfStale()"); - - // 最小可见时长:第二跳网速采样需要 1000ms 间隔,窗口不能被瞬间收掉。 - assertTrue(path + " must enforce a minimum visible duration", - gate.contains("SystemClock.elapsedRealtime() - mSeekProgressStartedAtMs < SEEK_PROGRESS_MIN_VISIBLE_MS")); - // 网速读数由相邻两次采样的字节差算出,而采样每秒一跳:窗口必须盖过一跳, - // 否则加载圈虽然出现了,用户仍然看不到网速(本次诉求的另一半)。 - assertTrue(path + " must keep the overlay visible long enough to render one speed sample", - seekMinVisibleMs(source) >= 1000L); - assertTrue(path + " must keep the readout ticker period at one second", - bodyToBrace(source, "private void setTraffic()").contains("App.post(mR2, 1000);") - || bodyToBrace(source, "private void setTraffic()").contains("App.post(mR3, 1000);")); - assertTrue(path + " must not treat an unopened window as blocking", - gate.contains("if (!mSeekProgressPending) return true;")); - // 已 READY 但仍在真实加载(且没在播)时不能收圈,那正是「卡画面」的那一帧。 - assertTrue(path + " must keep the spinner while the player is still loading", - gate.contains("player().getPlaybackState() == Player.STATE_READY") - && gate.contains("!player().isLoading() || player().isPlaying()")); - - assertTrue(path + " must close the window when the gate opens", - fallback.contains("if (!canHideSeekProgress()) return;") - && fallback.contains("mSeekProgressPending = false;")); - - // 网速 ticker 的兜底收口是本次症状的直接执行者,必须受同一道闸门约束。 - assertTrue(path + " must not let the traffic ticker hide a pending seek", - stale.contains("if (!canHideSeekProgress()) return;")); - assertTrue(path + " must keep the stale-spinner fallback owner-scoped", - stale.contains("if (!isOwner()) return;")); - assertTrue(path + " must only act while the spinner is up", - stale.contains("getVisibility() != View.VISIBLE")); - - // 控制器晚绑定补发收口同样不能越过闸门。 - String reconcile = bodyToBrace(source, "protected void onControllerReadyReconciled()"); - assertTrue(path + " must gate the late-controller reconciliation", - reconcile.contains("if (canHideSeekProgress()) showPlaybackContent();")); - } - } - - @Test - public void aStaleReadyReadingCannotCloseTheSeekWindow() throws Exception { - for (String path : SOURCES) { - String source = read(path); - int state = source.indexOf("protected void onStateChanged(int state)"); - int ready = source.indexOf("case Player.STATE_READY:", state); - int ended = source.indexOf("case Player.STATE_ENDED:", ready); - assertTrue(path + " must find the READY branch", state >= 0 && ready > state && ended > ready); - - String readyBody = source.substring(ready, ended); - assertTrue(path + " must route the READY reveal through the seek gate", - readyBody.contains("if (canHideSeekProgress()) showPlaybackContent();")); - // 未通过闸门时绝不能无条件收圈:那是把圈收在冻结画面上的原始缺陷。 - assertFalse(path + " must not reveal playback content unconditionally on READY", - readyBody.contains("\n showPlaybackContent();")); - } - } - - @Test - public void anyExplicitHideAlsoClosesTheWindow() throws Exception { - for (String path : SOURCES) { - String hide = bodyToBrace(read(path), "private void hideProgress()"); - assertTrue(path + " must clear the pending seek window whenever progress is hidden", - hide.contains("mSeekProgressPending = false;") - && hide.contains("mSeekProgressStartedAtMs = 0;")); - // 顺序:先关窗口再摘回调,避免关窗后仍有缓存计时器把状态又推回去。 - assertTrue(path + " must close the window before dropping its callback", - hide.indexOf("mSeekProgressPending = false;") < hide.indexOf("App.removeCallbacks(mSeekProgressFallback);")); - } - } - - @Test - public void showingProgressKeepsAnOpenSeekWindowArmed() throws Exception { - for (String path : SOURCES) { - String show = bodyToBrace(read(path), "private void showProgress()"); - // seek 窗口开着时那次 showProgress 属于 seek 自身(BUFFERING 分支), - // 不能顺手把窗口唯一的最小可见计时器摘掉。 - assertTrue(path + " must not cancel an open seek window's timer while showing progress", - show.contains("if (!mSeekProgressPending && mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback);")); - } - } - - /** 取源文件里 seek 加载圈最小可见时长常量的数值。 */ - private static long seekMinVisibleMs(String source) { - String marker = "private static final long SEEK_PROGRESS_MIN_VISIBLE_MS = "; - int start = source.indexOf(marker); - assertTrue("cannot locate SEEK_PROGRESS_MIN_VISIBLE_MS", start >= 0); - int end = source.indexOf(';', start + marker.length()); - assertTrue("cannot locate the end of SEEK_PROGRESS_MIN_VISIBLE_MS", end > start); - return Long.parseLong(source.substring(start + marker.length(), end).trim().replace("L", "").replace("l", "")); - } - - /** 取一个方法体:从签名到紧随其后的第一个方法级闭合花括号。 */ - private static String bodyToBrace(String source, String marker) { - int start = source.indexOf(marker); - assertTrue("cannot locate " + marker, start >= 0); - int end = source.indexOf("\n }", start + marker.length()); - assertTrue("cannot locate the end of " + marker, end > start); - return source.substring(start, end); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of(path.substring("app/".length())), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/DetailPlaybackHandoffSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/DetailPlaybackHandoffSourceTest.java deleted file mode 100644 index deed4ce2cf..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/DetailPlaybackHandoffSourceTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 详情页出播必须把"用户选中的季-集-版本"作为单一身份传给播放页: - * 集名不再由刮削 mark 文本冒充,季选择不被"该季第一集"覆盖,从播放页返回不覆盖手选。 - */ -public class DetailPlaybackHandoffSourceTest { - - private static final String DETAIL = "app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"; - - @Test - public void playbackEpisodeIdentityIsTheOnlySourceOfEpisodeNameAndUrl() throws Exception { - String detail = read(DETAIL); - String identity = method(detail, "private Episode playbackEpisode()"); - String name = method(detail, "private String playbackEpisodeName()"); - String url = method(detail, "private String playbackEpisodeUrl()"); - String mark = method(detail, "private String playbackMark()"); - String titles = method(detail, "private ArrayList fastPlaybackEpisodeTitles()"); - - assertFalse("playbackEpisodeName must not fall back to the scraped mark text", name.contains("playbackMark")); - assertTrue(name.contains("playbackEpisode()")); - assertTrue(name.contains("episode.getName()")); - assertTrue(url.contains("playbackEpisode()")); - assertTrue(url.contains("episode.getUrl()")); - assertTrue(mark.contains("playbackEpisode()")); - assertTrue(titles.contains("playbackEpisode()")); - assertFalse(titles.contains("episodeNumberForHistory(selectedEpisode)")); - - assertTrue("selected episode wins over every other identity channel", identity.contains("if (selectedEpisode != null) return selectedEpisode;")); - int intentUrl = identity.indexOf("findEpisodeByUrl(getIntentPlaybackEpisodeUrl()"); - int savedIdentity = identity.indexOf("history.getEpisode()"); - assertTrue("intent episode url is the first identity channel", intentUrl >= 0); - assertTrue("history episode identity is the fallback channel", savedIdentity > intentUrl); - assertTrue(identity.contains("selectedFlag.find(saved, true)")); - assertTrue("history lookup stays null-safe", identity.contains("if (saved == null || episodes == null || episodes.isEmpty()) return null;")); - } - - @Test - public void renderEpisodesNeverReplacesTheSelectedEpisodeWithTheFirstVisibleOne() throws Exception { - String render = method(read(DETAIL), "private void renderEpisodes()"); - - assertFalse("the first visible episode must never overwrite an existing selection", render.contains("selectedEpisode = visibleEpisodes.get(0)")); - assertTrue(render.contains("seasonForEpisode(selectedEpisode, episodes)")); - assertTrue("the season is derived from the selected episode and is -1 only when no season is available", - render.contains("availableSeasons.isEmpty() ? -1 : seasonForEpisode(selectedEpisode, episodes)")); - int intent = render.indexOf("findIntentPlaybackEpisode(selectedFlag)"); - int firstEpisode = render.indexOf("selectedEpisode = episodes.get(0)"); - assertTrue(intent >= 0 && firstEpisode > intent); - } - - @Test - public void reloadedHistorySelectionReDerivesTheEpisodeAfterFlagSelection() throws Exception { - String reloaded = method(read(DETAIL), "private void applyReloadedHistorySelection()"); - - assertTrue("manual selection, inline playback and started inline sessions still block the reload", - reloaded.contains("if (playbackSelectionTouched || detailPlayerActive || inlineStarted")); - int flagSelection = reloaded.indexOf("renderFlagSelection();"); - int episodes = reloaded.indexOf("renderEpisodes();"); - assertTrue(flagSelection >= 0); - assertTrue("the reloaded history must re-derive the episode instead of leaving it null", episodes > flagSelection); - } - - @Test - public void returningFromThePlayerKeepsTheManualDetailSelection() throws Exception { - String refresh = method(read(DETAIL), "private void refreshSelectionAfterExternalPlayback()"); - - assertTrue("a manual detail-page selection must survive the return from the player", - refresh.contains("if (playbackSelectionTouched || vod == null || vod.getFlags() == null || vod.getFlags().isEmpty()) return;")); - } - - private static String method(String source, String signature) { - int start = source.indexOf(signature); - assertTrue(signature + " must exist", start >= 0); - int end = source.indexOf("\n }", start); - assertTrue(signature + " must terminate", end > start); - return source.substring(start, end); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/FileBrowserEmptyStateContractTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/FileBrowserEmptyStateContractTest.java deleted file mode 100644 index c171ae2568..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/FileBrowserEmptyStateContractTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 本地文件浏览器空态的契约(设计文档 §9.9)。 - * - *

这是审计 P0 里最后一项空态缺口:两端 {@code FileActivity} 此前没有接入 - * {@code setEmptyContent},空态只显示布局里的默认单行文案。 - * - *

主动作裁定为「重新扫描当前目录」(用户 2026-10-13 决定),理由: - *

    - *
  • 根目录没有上级——"返回上级"在根目录会退出页面,与按钮文案不符;
  • - *
  • "重新扫描"在**任何**目录都成立,且能反映外部变化(刚用别的应用放进来的文件), - * 这正是本地文件浏览器最需要的能力。
  • - *
- * - *

本契约钉住四件事:三件套齐备;主动作调用 {@code update(dir)}(本页唯一的列表填充 - * 入口,既重算 list 又驱动状态机);**不得**把主动作接成返回/退出;三语字符串齐备。 - */ -public class FileBrowserEmptyStateContractTest { - - private static final String[] HOSTS = { - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java", - "app/src/mobile/java/com/fongmi/android/tv/ui/activity/FileActivity.java", - }; - private static final String STRINGS = "app/src/main/res/values/strings.xml"; - private static final String STRINGS_CN = "app/src/main/res/values-zh-rCN/strings.xml"; - private static final String STRINGS_TW = "app/src/main/res/values-zh-rTW/strings.xml"; - - @Test - public void bothFlavoursWireTheThreeParts() throws Exception { - for (String host : HOSTS) { - String source = read(host); - assertTrue(host + " 必须接入空态内容", source.contains("mBinding.progressLayout.setEmptyContent(")); - for (String string : new String[]{ - "file_empty_title", "file_empty_description", "file_empty_action"}) { - assertTrue(host + " 必须引用 " + string, source.contains(string)); - for (String table : new String[]{STRINGS, STRINGS_CN, STRINGS_TW}) { - assertTrue(table + " 必须声明 " + string, - read(table).contains("name=\"" + string + "\"")); - } - } - } - } - - /** - * 主动作必须复用 {@code update(dir)}——本页唯一的列表填充入口。 - * - *

若接成别的路径(例如只 {@code notifyDataSetChanged()}),"扫描后仍为空"就不会 - * 重新进入空态,用户会看到留白;而 {@code update} 既重算 list 又驱动状态机。 - */ - @Test - public void thePrimaryActionRescansThroughTheSingleListEntryPoint() throws Exception { - for (String host : HOSTS) { - String source = read(host); - String rescan = methodBody(source, "private void rescan()"); - assertTrue(host + " 的重新扫描必须走 update(...)", rescan.contains("update(")); - assertTrue(host + " 尚未拿到目录时必须退回根目录", - rescan.contains("dir == null ? Path.root() : dir")); - // update 必须仍然驱动状态机,否则"扫描后仍为空"会留白 - String update = methodBody(source, "private void update(File dir)"); - assertTrue(host + " 的 update 必须驱动状态机", update.contains("showContent(true,")); - } - } - - /** - * 反例守卫:主动作**不得**接成返回/退出。 - * - *

"返回上级"在根目录会退出页面,与"重新扫描"的按钮文案不符——这是本契约要防的 - * 具体错误,而不只是风格偏好。 - */ - @Test - public void thePrimaryActionDoesNotNavigateAway() throws Exception { - for (String host : HOSTS) { - String rescan = methodBody(read(host), "private void rescan()"); - assertFalse(host + " 的重新扫描不得调用 finish()", rescan.contains("finish()")); - assertFalse(host + " 的重新扫描不得调用 onBackPressed()", rescan.contains("onBackPressed")); - assertFalse(host + " 的重新扫描不得调用 getParentFile()(那是「返回上级」的做法)", - rescan.contains("getParentFile")); - } - } - - /** - * 空态文案必须解释"这里本来就没有可显示的项",而不是"加载失败"——两者是不同状态 - * (§9.9 把空态与错误态分开)。 - */ - @Test - public void theCopyDescribesAnEmptyFolderNotAFailure() throws Exception { - String cn = read(STRINGS_CN); - assertTrue("简中标题必须表达「这里没有内容」", cn.contains("这里没有内容")); - assertTrue("简中动作必须是「重新扫描」", cn.contains("重新扫描")); - assertFalse("空态文案不得使用「失败」措辞(那是错误态)", cn.contains("file_empty_description\">加载失败")); - } - - // ------------------------------------------------------------------ helpers - - private static String methodBody(String source, String signature) { - String cleaned = source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//.*$", " "); - int start = cleaned.indexOf(signature); - assertTrue("必须存在方法 " + signature, start > 0); - int open = cleaned.indexOf('{', start); - int depth = 0; - for (int i = open; i < cleaned.length(); i++) { - char c = cleaned.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return cleaned.substring(start, i + 1); - } - return cleaned.substring(start); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/FollowingUiSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/FollowingUiSourceTest.java deleted file mode 100644 index bdd61248d8..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/FollowingUiSourceTest.java +++ /dev/null @@ -1,363 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.assertFalse; - -public class FollowingUiSourceTest { - - @Test - public void followingTvHeaderScrollsWithContent() throws Exception { - String layout = read("app/src/leanback/res/layout/activity_following.xml"); - - assertTrue(layout.contains("androidx.core.widget.NestedScrollView")); - assertTrue(layout.contains("android:nestedScrollingEnabled=\"false\"")); - assertTrue(layout.contains("android:layout_height=\"wrap_content\"")); - } - - @Test - public void followingPageKeepsTvSafeTopInset() throws Exception { - String layout = read("app/src/main/res/layout/activity_following.xml"); - - assertTrue(layout.contains("android:paddingTop=\"28dp\"")); - assertFalse(layout.contains("android:paddingTop=\"12dp\"")); - } - - @Test - public void mobileFollowingHeaderWrapsControlsAndKeepsSummaryVisible() throws Exception { - // Windows 检出(core.autocrlf=true)下工作区是 CRLF;断言里的多行片段用 \n,需先归一化。 - String layout = read("app/src/mobile/res/layout/activity_following.xml").replace("\r\n", "\n"); - - assertTrue(layout.contains("com.google.android.flexbox.FlexboxLayout")); - assertTrue(layout.contains("android:id=\"@+id/actions\"")); - assertTrue(layout.contains("app:flexWrap=\"wrap\"")); - assertTrue(layout.contains("android:id=\"@+id/summary\"")); - assertTrue(layout.contains("android:layout_width=\"match_parent\"\n android:layout_height=\"wrap_content\"\n android:layout_marginTop=\"6dp\"")); - } - - @Test - public void mobileFollowingCardWrapsAllActionsInsteadOfClippingThem() throws Exception { - String layout = read("app/src/mobile/res/layout/item_following.xml"); - - assertTrue(layout.contains("com.google.android.flexbox.FlexboxLayout")); - assertTrue(layout.contains("app:flexWrap=\"wrap\"")); - assertTrue(layout.contains("app:justifyContent=\"flex_start\"")); - assertTrue(layout.contains("app:layout_flexGrow=\"0\"")); - assertTrue(layout.contains("app:layout_flexBasisPercent=\"@fraction/following_action_basis\"")); - assertTrue(read("app/src/mobile/res/values/following_fractions.xml").contains(">30%<")); - assertTrue(read("app/src/mobile/res/values-sw600dp/following_fractions.xml").contains(">18%<")); - assertTrue(layout.indexOf("@+id/error") < layout.indexOf("com.google.android.flexbox.FlexboxLayout")); - assertFalse(layout.contains("HorizontalScrollView")); - assertFalse(layout.contains("app:justifyContent=\"center\"")); - assertFalse(layout.contains("app:justifyContent=\"space_evenly\"")); - assertFalse(layout.contains("app:justifyContent=\"space_around\"")); - for (String id : List.of("nextSeason", "continuePlay", "check", "read", "notify", "sourceChange", "delete")) { - assertTrue(layout.contains("android:id=\"@+id/" + id + "\"")); - } - } - - @Test - public void mobileBottomNavigationPlacesFollowingBetweenLiveAndSettings() throws Exception { - String menu = read("app/src/mobile/res/menu/menu_nav.xml"); - String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"); - - assertTrue(menu.indexOf("@+id/live") < menu.indexOf("@+id/following")); - assertTrue(menu.indexOf("@+id/following") < menu.indexOf("@+id/setting")); - assertTrue(mobile.contains("FollowingSettings.isEnabled()")); - assertTrue(mobile.contains("FollowingActivity.start(this")); - assertTrue(mobile.contains("updateFollowingBadge()")); - assertTrue(mobile.contains("FollowingPlaybackBridge.cachedUnreadCount()")); - assertTrue(mobile.contains("FollowingPlaybackBridge.refreshUnreadCountAsync")); - assertFalse(mobile.contains("FollowingStore.unreadCount()")); - } - - @Test - public void leanbackHomeButtonUsesStableIdEightAndDefaultOrder() throws Exception { - String homeButton = read("app/src/leanback/java/com/fongmi/android/tv/bean/HomeButton.java"); - String func = read("app/src/leanback/java/com/fongmi/android/tv/bean/Func.java"); - String home = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"); - - assertTrue(homeButton.contains("new HomeButton(8, R.string.home_following)")); - assertTrue(homeButton.contains("ALL = \"0,1,2,3,8,4,5,6,7,9\"")); - assertTrue(homeButton.indexOf("ids.add(\"8\")") > homeButton.indexOf("ids.add(\"3\")")); - assertTrue(func.contains("ic_home_following")); - assertTrue(home.contains("FollowingActivity.start(this, null)")); - } - - @Test - public void followingContinueUsesFlavorPlaybackRouterToPreserveSourceBinding() throws Exception { - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); - int method = activity.indexOf("public void onContinue(Following item, FollowingSource source)"); - int end = activity.indexOf("public void onFollowNextSeason", method); - String body = activity.substring(method, end); - - assertTrue(body.contains("VideoActivity.startFromFollowingHistory(this, history)")); - assertFalse(body.contains("VideoActivity.startFromHistory(this, history)")); - assertFalse(body.contains("TmdbDetailActivity.startFromHistory(this, history)")); - - for (String path : List.of( - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java", - "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java")) { - String video = read(path); - int route = video.indexOf("public static void startFromFollowingHistory(Activity activity, History item)"); - int routeEnd = video.indexOf("public static void startFromHistory(Activity activity, History item)", route); - String routeBody = video.substring(route, routeEnd); - assertTrue(routeBody.contains("startFromHistory(activity, item)")); - } - } - - @Test - public void followingScreenExposesOfficialSourceAndUserStatesSeparately() throws Exception { - String adapter = read("app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java"); - assertTrue(adapter.contains("following_official")); - assertTrue(adapter.contains("following_season_total")); - assertTrue(adapter.contains("following_series_total")); - assertTrue(adapter.contains("following_weekday")); - assertTrue(adapter.contains("R.array.following_weekdays")); - assertTrue(adapter.contains("following_source")); - assertTrue(adapter.contains("following_watched")); - assertTrue(adapter.contains("following_unwatched")); - } - - @Test - public void followingScreenFollowsTheGlobalWallpaperLikeOtherContentPages() throws Exception { - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); - assertTrue("追更页必须复用全局壁纸视图", activity.contains("new CustomWallView(this, null)")); - assertTrue("壁纸必须插在内容根视图之下", activity.contains("content.addView(")); - assertTrue("壁纸必须早于页面初始化", activity.indexOf("addWallpaper();") < activity.indexOf("initView();")); - - String layout = read("app/src/main/res/layout/activity_following.xml"); - assertFalse("根布局必须保持透明,否则壁纸被整页盖住", - layout.contains("android:background=\"?attr/colorSurface\"")); - assertTrue("顶栏必须使用与首页按钮同源的 20% 黑纱", - layout.contains("android:background=\"@drawable/shape_following_bar\"")); - assertTrue("空态需要半透明面板承载文字", - layout.contains("android:background=\"@drawable/shape_following_panel\"")); - assertTrue("空态图标不能再是无条件白色 tint", - layout.contains("android:tint=\"?attr/colorOnSurfaceVariant\"")); - - String item = read("app/src/main/res/layout/item_following.xml"); - assertTrue("卡片必须改用半透明面板而不是不透明表面", - item.contains("app:cardBackgroundColor=\"@color/following_panel_bg\"")); - - String panel = read("app/src/main/res/color/following_panel_bg.xml"); - assertTrue("浅色模式卡片面板必须半透明", panel.contains("android:alpha=\"0.60\"")); - assertTrue("浅色模式卡片面板必须用语义浅色承载深色正文", - panel.contains("?attr/colorSurfaceContainerHigh")); - - String bar = read("app/src/main/res/color/following_bar_bg.xml"); - assertTrue("浅色模式顶栏必须半透明", bar.contains("android:alpha=\"0.45\"")); - assertTrue("浅色模式顶栏必须用语义浅色", bar.contains("?attr/colorSurfaceContainerHigh")); - - // 暗色模式必须回到与首页按钮同源的黑纱,否则深色模式下浅色面板会重新变成“贴板”。 - String darkPanel = read("app/src/main/res/color-night/following_panel_bg.xml"); - assertTrue("暗色模式卡片必须是低透明度黑纱", - darkPanel.contains("android:alpha=\"0.32\"") && darkPanel.contains("@color/black")); - String darkBar = read("app/src/main/res/color-night/following_bar_bg.xml"); - assertTrue("暗色模式顶栏黑纱必须与首页按钮 black_20 对齐", - darkBar.contains("android:alpha=\"0.20\"") && darkBar.contains("@color/black")); - - // 面板极性必须跟随模式,否则浅色深色会各坏一边。 - assertFalse("浅色面板不能使用黑纱", panel.contains("@color/black")); - assertFalse("暗色面板不能使用浅色语义表面", darkPanel.contains("colorSurfaceContainerHigh")); - } - - @Test - public void checkAllRefreshesEveryFollowingItemWithoutACountLimit() throws Exception { - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); - String coordinator = read("app/src/main/java/com/fongmi/android/tv/following/FollowingUpdateCoordinator.java"); - String dao = read("app/src/main/java/com/fongmi/android/tv/following/FollowingDao.java"); - - assertTrue(activity.contains("for (Following item : items) success |= coordinator.checkNow(item.identityKey, true);")); - assertFalse(activity.contains("Math.min(5, items.size())")); - assertTrue(coordinator.contains("findDue(now)")); - assertFalse(coordinator.contains("BATCH_SIZE")); - assertTrue(dao.contains("ORDER BY next_check_at ASC\"")); - assertFalse(dao.contains("LIMIT :limit")); - } - - @Test - public void followingHasUpdateFilterAndUserControlledSchedulerSwitch() throws Exception { - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); - String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java"); - String leanback = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingPersonalActivity.java"); - String mobileLayout = read("app/src/mobile/res/layout/fragment_setting_personal.xml"); - String leanbackLayout = read("app/src/leanback/res/layout/activity_setting_personal.xml"); - String func = read("app/src/leanback/java/com/fongmi/android/tv/bean/Func.java"); - String settings = read("app/src/main/java/com/fongmi/android/tv/following/FollowingSettings.java"); - String bridge = read("app/src/main/java/com/fongmi/android/tv/following/FollowingPlaybackBridge.java"); - String notifier = read("app/src/main/java/com/fongmi/android/tv/following/FollowingNotifier.java"); - - assertTrue(activity.contains("FILTER_UPDATES")); - assertTrue(activity.contains("FILTER_UNWATCHED")); - assertTrue(activity.contains("FILTER_ENDED")); - assertTrue(activity.contains("FILTER_FAILED")); - assertTrue(activity.contains("following_filter_empty")); - assertTrue(mobile.contains("FollowingScheduler.cancelAll(requireContext())")); - assertTrue(leanback.contains("FollowingScheduler.cancelAll(this)")); - assertTrue(mobile.contains("ConfigEvent.common();")); - assertTrue(leanback.contains("ConfigEvent.common();")); - assertTrue(mobileLayout.contains("@+id/following")); - assertTrue(mobileLayout.contains("@+id/followingText")); - assertTrue(settings.contains("Prefers.getBoolean(ENABLED, false)")); - assertTrue(leanbackLayout.contains("@+id/following")); - assertTrue(leanbackLayout.contains("@+id/followingText")); - assertTrue(bridge.contains("volatile int cachedUnreadCount")); - assertTrue(bridge.contains("FollowingStore.unreadCount()")); - assertTrue(func.contains("FollowingPlaybackBridge.cachedUnreadCount()")); - assertFalse(func.contains("FollowingStore.unreadCount()")); - assertTrue(func.contains(" + \" · \" + unread")); - assertTrue(notifier.contains("App.activity() != null")); - } - - @Test - public void newSeasonCreatesOnlyANewSeasonIdentity() throws Exception { - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); - String adapter = read("app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java"); - String layout = read("app/src/main/res/layout/item_following.xml"); - - assertTrue(activity.contains("onFollowNextSeason")); - assertTrue(activity.contains("FollowingIdentity.identityKey(item.seriesKey, item.latestReleasedSeason)")); - assertTrue(activity.contains("next.trackedSeason = item.latestReleasedSeason")); - assertTrue(adapter.contains("onFollowNextSeason")); - assertTrue(layout.contains("@+id/nextSeason")); - } - - @Test - public void cardOpensDetailActionsForContinueCheckReadSourceAndCancel() throws Exception { - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); - String adapter = read("app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java"); - - assertTrue(adapter.contains("onOpenDetail")); - assertTrue(activity.contains("onOpenDetail")); - assertTrue(activity.contains("following_continue")); - assertTrue(activity.contains("following_check")); - assertTrue(activity.contains("following_read")); - assertTrue(activity.contains("following_change_source")); - assertTrue(activity.contains("following_cancel")); - } - - @Test - public void followingReadStateIsManualAndSupportsMarkAllRead() throws Exception { - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); - String adapter = read("app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java"); - String leanbackLayout = read("app/src/leanback/res/layout/activity_following.xml"); - String mobileLayout = read("app/src/mobile/res/layout/activity_following.xml"); - String strings = read("app/src/main/res/values-zh-rCN/strings.xml"); - - assertFalse(activity.contains("VISIBLE_READ_DELAY_MS")); - assertFalse(activity.contains("markVisibleReadNow")); - assertFalse(activity.contains("addOnScrollListener(new RecyclerView.OnScrollListener()")); - assertTrue(activity.contains("binding.readAll.setOnClickListener(view -> markAllRead())")); - assertTrue(activity.contains("adapter.markReadLocally(keys)")); - assertTrue(activity.contains("FollowingPlaybackBridge.markReadAllAsync(keys")); - assertTrue(adapter.contains("getItems()")); - assertTrue(leanbackLayout.contains("@+id/readAll")); - assertTrue(mobileLayout.contains("@+id/readAll")); - int checkId = leanbackLayout.indexOf("android:id=\"@+id/check\""); - int readAllId = leanbackLayout.indexOf("android:id=\"@+id/readAll\"", checkId); - int filterId = leanbackLayout.indexOf("android:id=\"@+id/filter\"", readAllId); - assertTrue(leanbackLayout.substring(checkId, readAllId).contains("android:nextFocusRight=\"@id/readAll\"")); - assertTrue(leanbackLayout.substring(readAllId, filterId).contains("android:nextFocusLeft=\"@id/check\"")); - assertTrue(leanbackLayout.substring(readAllId, filterId).contains("android:nextFocusRight=\"@id/filter\"")); - assertTrue(leanbackLayout.substring(filterId).contains("android:nextFocusLeft=\"@id/readAll\"")); - assertTrue(strings.contains("全部已读")); - } - - @Test - public void detailAndPlaybackScreensWireFollowingActionsOffTheMainThread() throws Exception { - String detail = read("app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); - // Windows 检出(core.autocrlf=true)下工作区是 CRLF,而断言里的多行片段用 \n:先归一化再比较。 - String detailLf = detail.replace("\r\n", "\n"); - String store = read("app/src/main/java/com/fongmi/android/tv/following/FollowingStore.java"); - String header = read("app/src/main/res/layout/view_tmdb_header.xml"); - String mobile = read("app/src/mobile/res/layout/activity_video.xml") - + read("app/src/mobile/res/layout-land/activity_video.xml") - + read("app/src/mobile/res/layout-sw600dp/activity_video.xml") - + read("app/src/mobile/res/layout-sw600dp-land/activity_video.xml"); - String leanback = read("app/src/leanback/res/layout/activity_video.xml"); - String mobileActivity = read("app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - String leanbackActivity = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - String followingActivity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); - // 同上:多行片段断言必须先把 CRLF 归一化为 LF,否则 Windows 检出下会假失败。 - String followingActivityLf = followingActivity.replace("\r\n", "\n"); - String followingAdapter = read("app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java"); - String strings = read("app/src/main/res/values-zh-rCN/strings.xml"); - - assertTrue(detail.contains("FollowingPlaybackBridge.findAsync")); - assertTrue(detail.contains("FollowingPlaybackBridge.resolveTmdbAsync")); - assertTrue(detail.contains("followingSnapshot()")); - assertTrue(detail.contains("item.enabled = true")); - assertFalse(detail.contains("FollowingStore.findByTmdb")); - assertFalse(detail.contains("FollowingStore.findBySource")); - assertTrue(header.contains("@+id/tmdbFollowing")); - assertTrue(mobile.contains("@+id/following")); - assertTrue(leanback.contains("@+id/following")); - assertTrue(followingInitiallyGone(read("app/src/mobile/res/layout/activity_video.xml"))); - assertTrue(followingInitiallyGone(read("app/src/mobile/res/layout-land/activity_video.xml"))); - assertTrue(followingInitiallyGone(read("app/src/mobile/res/layout-sw600dp/activity_video.xml"))); - assertTrue(followingInitiallyGone(read("app/src/mobile/res/layout-sw600dp-land/activity_video.xml"))); - assertTrue(followingInitiallyGone(read("app/src/leanback/res/layout/activity_video.xml"))); - assertTrue(followingInitiallyGone(read("app/src/main/res/layout/view_tmdb_header.xml"))); - assertTrue(mobileActivity.contains("onFollowing()")); - assertTrue(leanbackActivity.contains("onFollowing()")); - // 播放页的追更按钮是就地开关:已追更时再次点击必须立刻取消,绝不跳转追更页打断播放。 - for (String playback : List.of(mobileActivity, leanbackActivity)) { - assertTrue(playback.contains("private void cancelFollowing(String identityKey)")); - assertTrue(playback.contains("FollowingPlaybackBridge.deleteAsync(identityKey")); - assertTrue(playback.contains("FollowingScheduler.cancelNext(this, identityKey)")); - assertTrue(playback.contains("Notify.show(R.string.following_canceled)")); - assertFalse(playback.contains("FollowingActivity.start(this, existing.identityKey)")); - } - assertTrue(strings.contains("已取消追更")); - // 详情页与播放页同源:点「已追更」必须就地取消并立即生效,绝不跳转追更页。 - assertFalse(detail.contains("FollowingActivity.start")); - assertFalse(detail.contains("isInlineFollowingPlaybackSurface")); - assertTrue(detailLf.contains("if (isFollowed(existing)) {\n // 详情页与播放页统一为就地开关:再次点击「已追更」立即取消,绝不跳转追更页。\n cancelFollowing(identityKey);")); - assertTrue(detail.contains("private void cancelFollowing(String identityKey)")); - assertTrue(detail.contains("FollowingScheduler.cancelNext(this, identityKey)")); - assertTrue(detail.contains("FollowingPlaybackBridge.deleteAsync(identityKey")); - assertTrue(detail.contains("Notify.show(R.string.following_canceled)")); - // 取消落库成功后必须立刻刷新按钮:updateFollowingState 走 isFollowed,墓碑行视为未追更。 - assertTrue(detailLf.contains("updateFollowingState();\n FollowingPlaybackBridge.refreshUnreadCountAsync(null);\n Notify.show(R.string.following_canceled);")); - // 详情页走 TMDB 身份迁移时 resolveTmdb 会为防复活故意返回墓碑行(C9 语义), - // 因此“已追更”判定必须排除墓碑,否则取消后按钮仍显示“已追更”且永远无法重新追更。 - assertTrue(detail.contains("private boolean isFollowed(Following item)")); - assertTrue(detail.contains("return item != null && !item.isDeleted();")); - assertTrue(detail.contains("if (isFollowed(existing)) {")); - assertTrue(detail.contains("applyFollowingButtonState(true, isFollowed(item));")); - assertFalse(detail.contains("applyFollowingButtonState(true, item != null);")); - assertTrue(followingActivity.contains("FollowingPlaybackBridge.deleteAsync")); - assertFalse(followingActivity.contains("FollowingStore.delete(item.identityKey)")); - assertTrue(followingActivityLf.contains("Task.execute(() -> {\n List items = FollowingStore.list();")); - assertFalse(followingActivity.contains("VISIBLE_READ_DELAY_MS")); - assertFalse(followingActivity.contains("markVisibleReadNow()")); - assertTrue(followingAdapter.contains("item.hasUpdate && item.unwatchedCount > 0")); - assertTrue(followingAdapter.contains("markReadLocally")); - assertTrue(strings.contains("标记已读")); - assertTrue(mobile.contains("ic_home_following_shadow")); - assertFalse(mobile.contains("drawable/ic_home_following\"")); - assertTrue(store.contains("history == null || !FollowingSettings.isEnabled()")); - assertTrue(store.contains("migrateSourceToTmdb")); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); - return Files.readString(path, StandardCharsets.UTF_8); - } - - private static boolean followingInitiallyGone(String xml) { - int id = xml.indexOf("@+id/following"); - if (id < 0) id = xml.indexOf("@+id/tmdbFollowing"); - if (id < 0) return false; - int next = xml.indexOf(">", id); - return next > id && xml.substring(id, next).contains("android:visibility=\"gone\""); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeFragmentCommitStateGuardTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeFragmentCommitStateGuardTest.java deleted file mode 100644 index bac6a4d0dc..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeFragmentCommitStateGuardTest.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 首页(TV/leanback)在宿主已保存状态的窗口里不能再提交 Fragment 事务。 - * - *

真机崩溃证据(TCL tcl_m7642 / Android 9,dev3 基线): - * - *

- * 08:19:15.710 [main] startup: home showContent start cost=231455ms
- * 08:19:15.718 [main] av-diag: process.recovery … message="Can not perform this action after onSaveInstanceState"
- * 08:19:15.737 [main] process-crash: java.lang.IllegalStateException: Can not perform this action
- *   after onSaveInstanceState
- *     at androidx.fragment.app.a.g            (FragmentTransaction.commit)
- *     at HomeActivity.clearCategoryContent
- *     at HomeActivity.syncCategorySite
- *     at HomeActivity.getVideo
- *     at HomeActivity.showContent
- *     at HomeActivity$4.success             (VodConfig 加载回调)
- *     at gd.run → Handler.handleCallback → Looper.loop
- * 
- * - *

配置加载回调来自进程级单例 {@code VodConfig},经 {@code App.post} 投递,与 Activity - * 生命周期解耦;而外观页改主题色/主题模式/UI 缩放都会 {@code recreate()}。两者叠加时, - * 这条延迟回调落在旧实例的已保存状态上,无条件 {@code commit()} 便抛异常。 - * - *

守卫拒绝的是「提交」,不是「工作」:实例仍存活(只是被保存了状态,例如用户在配置 - * 加载期间切到别的应用)时,分类页清理与首页内容刷新都要推迟到回到前台补做,否则会留下 - * 旧站点分类页与一个永远不再加载的空首页。 - */ -public class HomeFragmentCommitStateGuardTest { - - private static final String HOME = - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"; - - @Test - public void guardHelperRejectsFinishingDestroyedAndSavedState() throws Exception { - String home = read(HOME); - String helper = between(home, - "private boolean canCommitFragmentTransaction() {", - "private void clearCategoryContent()"); - - assertTrue("a saved-state guard must exist for delayed Fragment work", helper.contains("isFinishing()")); - assertTrue("a destroyed host must not commit Fragment work", helper.contains("isDestroyed()")); - assertTrue("a saved FragmentManager must reject the transaction", - helper.contains("getSupportFragmentManager().isStateSaved()")); - // 推迟只对「仍存活」的宿主成立;正在结束/已销毁的实例必须丢弃,不能再挂起工作。 - assertTrue("deferral must exist as a separate predicate", helper.contains("private boolean canDeferFragmentWork()")); - String defer = between(home, - "private boolean canDeferFragmentWork() {", - "private void clearCategoryContent()"); - assertTrue("deferral must reject a finishing host", defer.contains("!isFinishing()")); - assertTrue("deferral must reject a destroyed host", defer.contains("!isDestroyed()")); - } - - @Test - public void categoryStateIsClearedEvenWhenCommitIsSkipped() throws Exception { - String home = read(HOME); - String clear = between(home, - "private void clearCategoryContent() {", - "private void removeCategoryFragments() {"); - - int guard = clear.indexOf("if (!canCommitFragmentTransaction()) {"); - int remove = clear.indexOf("removeCategoryFragments();"); - assertTrue("clearing categories must consult the saved-state guard", guard >= 0); - assertTrue("the guard must be the statement that leads into the transaction", remove > guard); - assertTrue("a live host must defer the skipped removal instead of dropping it", - clear.contains("mPendingCategoryCleanup = true;")); - assertFalse("the guard must not fall back to state-loss commits", - clear.contains("commitAllowingStateLoss")); - - // 纯内存状态必须在守卫“之前”就已经丢弃:否则 isCurrentCategory() 会把一个即将 - // 消失的 Fragment 当成当前分类,重建窗口里到处误判。 - for (String reset : new String[]{ - "mCurrentType = null;", - "mFolder = null;", - "mBinding.categoryContainer.setVisibility(View.GONE);"}) { - int at = clear.indexOf(reset); - assertTrue("category reset must not be skipped with the transaction: " + reset, at >= 0); - assertTrue("category reset must happen before the commit guard: " + reset, at < guard); - } - } - - @Test - public void savedCategoryPageNeverCommitsWithoutTheGuard() throws Exception { - String home = read(HOME); - String clear = between(home, - "private void clearCategoryContent() {", - "private void removeCategoryFragments() {"); - String remove = between(home, - "private void removeCategoryFragments() {", - "private void clearStaleSiteTypes()"); - - assertEquals("clearCategoryContent must delegate the removal instead of committing itself", - 0, countOccurrences(clear, "transaction.commit();")); - assertEquals("the removal must hold exactly one commit", 1, countOccurrences(remove, "transaction.commit();")); - assertTrue("removing cached category pages must stay generation-safe", - remove.contains("folder.clearContentFocusRequest();")); - assertTrue("the removal must sweep every added page, not only hidden ones", - remove.contains("getSupportFragmentManager().getFragments()")); - } - - @Test - public void homeContentRefreshStopsBeforeReshapingASavedStateHost() throws Exception { - String home = read(HOME); - String getVideo = between(home, - "private void getVideo(boolean forceNative) {", - "private void showWebOverlay()"); - - int sync = getVideo.indexOf("syncCategorySite();"); - int guard = getVideo.indexOf("if (!canCommitFragmentTransaction()) {"); - int webLoad = getVideo.indexOf("WebHomeTarget.canLoad(getHome())"); - - assertTrue("the site key must still be committed before the guard", sync >= 0); - assertTrue("a delayed refresh must stop when the host state is already saved", guard > sync); - assertTrue("the guarded refresh must not start web or native content work", webLoad > guard); - assertTrue("a live host must not lose the refresh", getVideo.contains("mPendingContentRefresh = true;")); - assertTrue("a web-triggered refresh must keep its forceNative intent", - getVideo.contains("mPendingContentRefreshForceNative |= forceNative;")); - } - - @Test - public void deferredWorkIsResumedWhenTheHostReturnsToForeground() throws Exception { - String home = read(HOME); - String onResume = between(home, "protected void onResume() {", "protected void onPause() {"); - String resume = between(home, - "private void resumeDeferredFragmentWork() {", - "protected void onPause() {"); - - assertTrue("deferred work must run when the host returns to the foreground", - onResume.contains("resumeDeferredFragmentWork();")); - assertTrue("deferred work must still respect the commit guard", - resume.contains("if (!canCommitFragmentTransaction()) return;")); - for (String deferred : new String[]{ - "mPendingCategoryCleanup = false;", - "removeCategoryFragments();", - "mPendingContentRefresh = false;", - "getVideo(forceNative);"}) { - assertTrue("deferred work must be replayed once: " + deferred, resume.contains(deferred)); - } - } - - @Test - public void hidingTheHomeChromeNeverCommitsWithoutTheGuard() throws Exception { - String home = read(HOME); - String showHome = between(home, - "private void showHomeContent() {", - "private void showCategoryContent(Class item) {"); - - int commit = showHome.indexOf("beginTransaction().hide(mFolder).commit()"); - int guard = showHome.indexOf("if (canCommitFragmentTransaction())"); - - assertTrue("hiding the home chrome commit must remain present", commit >= 0); - assertTrue("hiding the home chrome must be guarded by the saved-state check", guard >= 0); - assertTrue("the guard must wrap the hide transaction", guard < commit); - assertTrue("local visibility state must still be cleared outside the transaction", - showHome.contains("mFolder.setUserVisibleHint(false);")); - } - - private static int countOccurrences(String source, String needle) { - int count = 0; - for (int at = source.indexOf(needle); at >= 0; at = source.indexOf(needle, at + needle.length())) count++; - return count; - } - - private static String between(String source, String start, String end) { - int from = source.indexOf(start); - assertTrue("Missing method: " + start, from >= 0); - int to = source.indexOf(end, from + start.length()); - assertTrue("Missing method boundary: " + end, to > from); - return source.substring(from, to); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); - return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeSiteSwitchClearsTypesTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeSiteSwitchClearsTypesTest.java deleted file mode 100644 index b7d93e7271..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeSiteSwitchClearsTypesTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertTrue; - -public class HomeSiteSwitchClearsTypesTest { - - @Test - public void siteSwitchClearsOldTypeButtonsBeforeHomeReload() throws Exception { - String home = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"); - String config = read("app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java"); - - int setSite = method(home, "public void setSite(Site item)"); - String setHomeCall = "VodConfig.get().setHome(item);"; - int setSiteEnd = home.indexOf(setHomeCall, setSite) + setHomeCall.length(); - int configSetHome = method(config, "public void setHome(Site site)"); - String configRefreshCall = "RefreshEvent.home();"; - int configSetHomeEnd = config.indexOf(configRefreshCall, configSetHome) + configRefreshCall.length(); - int refresh = method(home, "public void onRefreshEvent(RefreshEvent event)"); - int refreshEnd = home.indexOf("@Subscribe", refresh + 1); - int getVideo = method(home, "private void getVideo(boolean forceNative)"); - int getVideoEnd = method(home, "private void showWebOverlay()"); - int syncCategory = method(home, "private void syncCategorySite()"); - int syncCategoryEnd = method(home, "private void clearCategoryContent()"); - int clear = method(home, "private void clearCategoryContent()"); - int clearEnd = method(home, "private void clearStaleSiteTypes()"); - int helper = method(home, "private void clearStaleSiteTypes()"); - int helperEnd = method(home, "private void updateToolbarVisibility(boolean visible)"); - - assertTrue(setSite >= 0); - assertTrue(setSiteEnd > setSite); - assertTrue(home.substring(setSite, setSiteEnd).contains("VodConfig.get().setHome(item);")); - assertTrue(configSetHome >= 0); - assertTrue(configSetHomeEnd > configSetHome); - assertTrue(config.substring(configSetHome, configSetHomeEnd).contains("RefreshEvent.home();")); - assertTrue(refresh >= 0); - assertTrue(refreshEnd > refresh); - assertTrue(home.substring(refresh, refreshEnd).contains("getVideo();")); - assertTrue(getVideo >= 0); - assertTrue(getVideoEnd > getVideo); - assertTrue(home.substring(getVideo, getVideoEnd).contains("syncCategorySite();")); - assertTrue(syncCategory >= 0); - assertTrue(syncCategoryEnd > syncCategory); - assertTrue(home.substring(syncCategory, syncCategoryEnd).contains("if (TextUtils.equals(mCategorySiteKey, key)) return;")); - assertTrue(home.substring(syncCategory, syncCategoryEnd).contains("clearCategoryContent();")); - assertTrue(clear >= 0); - assertTrue(clearEnd > clear); - assertTrue(helper >= 0); - assertTrue(helperEnd > helper); - assertTrue(home.substring(helper, helperEnd).contains("mTypeAdapter.addAll(Collections.emptyList());")); - assertTrue(home.substring(helper, helperEnd).contains("mPendingTypePosition = -1;")); - assertTrue(home.substring(helper, helperEnd).contains("mBinding.typeRecycler.setVisibility(View.GONE);")); - } - - private static int method(String source, String signature) { - return source.indexOf(signature); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeTypeRecyclerLayoutGuardTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeTypeRecyclerLayoutGuardTest.java deleted file mode 100644 index a2272d1e0e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeTypeRecyclerLayoutGuardTest.java +++ /dev/null @@ -1,401 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -/** - * 只看源码文本,不引入任何新框架:本仓库既有的守卫测试都是这个风格。 - * - *

断言口径刻意避开局部变量名、注释内容和硬编码的调用次数,只钉住真正的契约: - * - *

    - *
  1. 守卫闭包自己复查布局并重新挂帧(崩溃根因);
  2. - *
  3. 守卫是「无返回值 + 自决执行或入槽」的单入口:任何调用点都不可能重复执行目标 - * (历史缺陷:boolean 返回值让非延后路径「方法内 run 一次 + 调用方再 run 一次」);
  4. - *
  5. 排队语义是「单槽合并 + 必执行」——后写者覆盖、同一时刻只有一个 runner、 - * 最新写入最终一定会被执行,绝不能只丢弃;
  6. - *
  7. 结构性写入只有守卫一条入口,没有任何绕过守卫的直接同步 addAll;
  8. - *
  9. 可见性的隐藏留在同步段,只有 adapter 换数据被延后(否则旧分类按钮至少多活一帧)。
  10. - *
- * - *

「单槽」「posted 标志」等字段名不写死:先按结构(把 update 参数赋给谁、在 - * postOnAnimation 之前把谁置 true)从守卫体里推出来,再拿推出来的名字做后续断言, - * 所以合法重命名不会误红,而语义被改掉一定红。 - */ -public class HomeTypeRecyclerLayoutGuardTest { - - private static final String HOME = "app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"; - /** 守卫的固定名字:它是本次修复的契约本体,不随重构变动。 */ - private static final String GUARD = "runTypeMutation"; - - @Test - public void deferredTypeUpdatesRecheckLayoutInsideTheCallback() throws Exception { - String home = read(HOME); - String guard = guardBody(home); - - String busy = busyHelperName(guard); - String busyBody = methodBody(home, "private boolean " + busy + "("); - assertNotNull("布局忙碌判定辅助方法 " + busy + " 必须存在:同一条件在守卫里重复两遍就会改一处漏一处", busyBody); - assertTrue("崩溃的根因是布局过程中做结构性修改:忙碌判定必须复查 isComputingLayout()", - busyBody.contains("isComputingLayout()")); - assertTrue("忙碌判定必须把内容行 recycler 的布局状态算进来,否则内容行布局时仍会崩", - busyBody.contains("mBinding.recycler.isComputingLayout()")); - assertTrue("忙碌判定必须把类型栏自己的布局状态算进来,否则类型栏布局时仍会崩", - busyBody.contains("mBinding.typeRecycler.isComputingLayout()")); - assertEquals("两个 recycler 的布局状态只允许在忙碌判定里各出现一次(消除重复条件)," - + "守卫体里不得再内联同一条件", 0, occurrences(guard, "isComputingLayout()")); - - int recheck = guard.indexOf("if (" + busy + "())"); - int repost = guard.indexOf("postOnAnimation(this)"); - assertTrue("只复查一次不够:下一帧动画里布局可能还没结束,未就绪必须重新 postOnAnimation 同一个 Runnable", - recheck >= 0 && repost > recheck); - assertTrue("复查必须排在重新挂帧之前,否则等于没守", - guard.indexOf("canDeferFragmentWork()") < recheck && recheck < repost); - assertTrue("延后任务在宿主正在结束或已销毁时必须丢弃,沿用本文件既有语义", - guard.contains("canDeferFragmentWork()")); - } - - /** - * 本轮缺陷的回归钉:守卫曾经返回 boolean,于是「同步分支自己 run 一次」+ - * 「调用方按返回值再 run 一次」=非延后路径双执行(addAll/notifyDataSetChanged 两遍、 - * setSelectedPosition(0) 两遍、showHomeContent() 两遍)。 - */ - @Test - public void theGuardIsVoidSoCallSitesCannotReExecuteTheTarget() throws Exception { - String home = read(HOME); - String guard = guardBody(home); - assertFalse("守卫一旦返回布尔值,调用方就会据此再执行一次目标(双执行缺陷本体)", - guard.contains("return true;") || guard.contains("return false;")); - assertFalse("守卫不得用「返回值告诉调用方该不该执行」的旧协议", - guard.contains("boolean " + GUARD + "(")); - - List callSites = guardCallSites(home); - assertFalse("守卫必须被真实调用,否则它只是死代码", callSites.isEmpty()); - for (String line : callSites) { - assertTrue("调用点必须是裸语句 " + GUARD + "(...);,不得写成 if (" + GUARD + "(...)) ...、" - + "不得赋值给变量、不得参与三元或布尔运算:任何取值形态都会让目标再被执行一次。实际:" + line, - isBareStatement(line)); - } - - assertFalse("调用点不得出现在 if/while/for 的条件里:条件成立时调用方会再执行一次目标", - Pattern.compile("(?:if|while|for)\\s*\\([^;{}]*" + GUARD + "\\(").matcher(home).find()); - assertFalse("调用点不得被赋值(等于重新引入返回值协议)", - Pattern.compile("=\\s*" + GUARD + "\\(").matcher(home).find()); - assertFalse("调用点不得被取反或参与布尔运算(等于重新引入返回值协议)", - Pattern.compile("[!&|]\\s*" + GUARD + "\\(").matcher(home).find()); - - Set targets = guardedTargets(home); - assertFalse("守卫必须真实接管结构性写入目标", targets.isEmpty()); - for (String target : targets) { - boolean covered = false; - for (String line : callSites) { - if (isBareStatement(line) && line.contains(target)) covered = true; - } - assertTrue("延后目标 " + target + " 必须由一个裸语句调用点接管,不得只出现在条件/赋值里", covered); - } - } - - /** - * 非延后路径的「只执行一次」结构钉:守卫体里只允许两个执行点——同步分支的 - * update.run() 与 runner 从槽位取出后的 pending.run()。多出第三处就是重复执行。 - */ - @Test - public void theSynchronousBranchRunsItsTargetExactlyOnce() throws Exception { - String guard = guardBody(read(HOME)); - assertEquals("守卫体里 update.run() 只能出现一次:出现两次意味着同步分支自己就对目标执行了两遍", - 1, occurrences(guard, "update.run();")); - String sync = guard.substring(0, guard.indexOf("postOnAnimation")); - assertEquals("同步分支只允许一个执行点:多出来的执行点就是重复执行", - 1, occurrences(sync, ".run();")); - String runner = guard.substring(guard.indexOf("postOnAnimation")); - assertEquals("runner 只允许一个执行点(从槽位取出的那个待写入),多出来的执行点就是重复执行", - 1, occurrences(runner, ".run();")); - assertTrue("守卫体总共只允许两个执行点,实测 " + occurrences(guard, ".run();"), - occurrences(guard, ".run();") == 2); - - int post = guard.indexOf("postOnAnimation"); - int syncRun = guard.indexOf("update.run();"); - assertTrue("同步执行必须排在首次 post 之前,语义才是「不忙就立刻落地」", syncRun >= 0 && syncRun < post); - } - - /** - * 缺陷 A 的回归钉:旧实现用「代际号丢弃」,一次布局中的写入会被随后的焦点失效静默作废, - * 类型栏就永久空白。新语义下最新一次写入必然被执行。 - */ - @Test - public void theNewestDeferredWriteIsAlwaysExecuted() throws Exception { - String guard = guardBody(read(HOME)); - String slot = deferralSlot(guard); - - String sync = guard.substring(0, guard.indexOf("postOnAnimation")); - assertTrue("布局没在跑时必须立刻执行更新", sync.contains("update.run();")); - assertTrue("立刻执行的分支必须清空待写入槽位,否则这条已落地的旧写入会在下一帧盖掉更新的写入", - sync.contains(slot + " = null;")); - assertTrue("必须先清空槽位再执行更新:更新自身可能再次排队,先 run 后清会把新排队的写入抹掉", - sync.indexOf(slot + " = null;") < sync.indexOf("update.run();")); - - String runner = guard.substring(guard.indexOf("postOnAnimation")); - assertTrue("销毁期清理必须把槽位一起清空,避免持有已失效的闭包", runner.contains(slot + " = null;")); - assertTrue("重启后的 runner 必须继续复查,布局没结束时重新挂帧而不是放弃", - runner.contains("postOnAnimation(this)")); - - String pending = captureFromSlot(runner, slot); - assertTrue("最新写入必被执行:runner 必须在最后取出槽位里的写入并调用它,只丢弃会让类型栏永久空白", - runner.contains(pending + ".run();")); - assertTrue("执行必须发生在重新挂帧判定之后,否则又变成布局中做结构性修改", - runner.indexOf(pending + ".run();") > runner.indexOf("postOnAnimation(this)")); - } - - /** 缺陷 A 的另一半:旧写入不得覆盖新写入,且同一时刻只允许一个 runner 在排。 */ - @Test - public void deferralMergesIntoOneSlotSoStaleWorkCannotOverwriteNewData() throws Exception { - String home = read(HOME); - String guard = guardBody(home); - String slot = deferralSlot(guard); - - assertTrue("待写入必须是可被覆盖的单个槽位:把本次 update 存进槽位,后写者自然覆盖前者", - guard.contains(slot + " = update;")); - - String posted = postedFlag(guard); - int dedupe = guard.indexOf("if (" + posted + ") return;"); - int setPosted = guard.indexOf(posted + " = true;"); - int post = guard.indexOf("postOnAnimation"); - assertTrue("同一时刻只允许一个 runner 在排队:已在排队时必须直接返回,不得重复 post", - dedupe >= 0 && dedupe < setPosted); - assertTrue("标记必须在真正 post 之前置位,否则重入会排出第二个 runner", - setPosted > dedupe && setPosted < post); - assertTrue("runner 开始时必须先清标记,否则一次执行之后就再也不会有新的 runner", - guard.substring(guard.indexOf("postOnAnimation")).contains(posted + " = false;")); - - assertFalse("不得再用代际号丢弃在途写入:丢弃会让布局中的写入变成无人执行的孤儿", - home.contains("mTypeUpdateGeneration")); - assertFalse("守卫体里不允许出现弃写式代际字段(只看字段名,注释措辞不算)", - guard.contains("mTypeUpdateGeneration")); - assertFalse("换源作废靠「后写者覆盖」自然实现,不需要额外代际号", - methodBody(home, "private void clearCategoryContent(").contains("mTypeUpdateGeneration")); - } - - /** 结构性写入只有一条入口:守卫自己执行,或由 runner 执行。 */ - @Test - public void everyStructuralTypeMutationGoesThroughTheLayoutGuard() throws Exception { - String home = read(HOME); - String guard = guardBody(home); - Set targets = guardedTargets(home); - assertTrue("守卫必须被真实使用,否则它只是死代码", !targets.isEmpty()); - assertTrue("站点切换清空旧分类是目标崩溃场景,必须经守卫", - methodBody(home, "private void clearStaleSiteTypes(").contains(GUARD + "(")); - - for (String target : targets) { - String body = methodBody(home, "private void " + target + "("); - assertNotNull("被守卫调用的 " + target + " 必须存在,否则守卫目标缺失", body); - assertTrue(target + " 必须承载结构性写入,否则它不该出现在守卫后面", - body.contains("mTypeAdapter.addAll(")); - assertEquals("延后目标 " + target + " 内部只允许一次 mTypeAdapter.addAll(:" - + "出现两次意味着一次调用就触发两遍 notifyDataSetChanged", 1, occurrences(body, "mTypeAdapter.addAll(")); - } - - // 语义化判定(不数次数):每一处 adapter 结构性写入所在的宿主方法,必须是某个被 - // 守卫接管的延后目标。多出来的一处同步直写就是绕过布局守卫,这条断言必红。 - int index = home.indexOf("mTypeAdapter.addAll("); - int seen = 0; - while (index >= 0) { - seen++; - String owner = enclosingMethod(home, index); - assertTrue("mTypeAdapter.addAll( 只能出现在被守卫接管的延后目标里,实际出现在 " + owner + ":" - + "这就是绕过布局守卫的直接同步写入", targets.contains(owner)); - index = home.indexOf("mTypeAdapter.addAll(", index + 1); - } - assertTrue("adapter 结构性写入不能被整体删掉", seen > 0); - - for (String target : targets) { - for (String raw : home.split("\n")) { - String line = raw.trim(); - if (!line.contains(target + "(")) continue; - if (line.contains(GUARD + "(")) continue; - if (line.startsWith("*") || line.startsWith("/*") || line.startsWith("//")) continue; - assertTrue("延后目标 " + target + " 不允许在守卫之外被单独调用:" + line, - line.contains("private void " + target + "(")); - } - } - assertFalse("守卫不得退化成 notifyDataSetChanged 式的全量刷新绕开结构性守卫", - guard.contains("notifyDataSetChanged(")); - } - - /** - * 缺陷 B 的回归钉:换源后旧分类按钮必须立刻不可见,否则 switchType() 会按旧 adapter 判定 - * 并打开旧 Class。延后整个 clearStaleTypeItems 会把 setVisibility(GONE) 一起顺延一帧。 - */ - @Test - public void staleTypeRowHidesSynchronouslyAndOnlyTheDataClearIsDeferred() throws Exception { - String home = read(HOME); - String clearStale = methodBody(home, "private void clearStaleSiteTypes("); - String deferred = methodBody(home, "private void clearStaleTypeItems("); - - assertTrue("换源清空必须交给布局守卫,否则就是崩溃根因", clearStale.contains(GUARD + "(")); - assertTrue("隐藏必须留在同步段:旧分类按钮不能多活一帧,否则仍可聚焦、仍能按旧 adapter 触发切换", - clearStale.contains("mBinding.typeRecycler.setVisibility(View.GONE);")); - assertTrue("选中的撤销同样必须同步,否则 switchType 还会按旧的待切位置判定", - clearStale.contains("mPendingTypePosition = -1;")); - assertTrue("同步隐藏必须发生在延后数据清空之前,语义才是「立刻移除旧分类」", - clearStale.indexOf("mBinding.typeRecycler.setVisibility(View.GONE);") - < clearStale.indexOf(GUARD + "(")); - - assertNotNull("延后目标必须存在", deferred); - assertFalse("可见性不是结构性修改,不得被延后:它一旦进了延后目标,缺陷 B 就复现", - deferred.contains("setVisibility(")); - assertFalse("选中位置是纯状态量,不得被延后", deferred.contains("mPendingTypePosition")); - assertTrue("延后目标只承载 adapter 的换数据写入", deferred.contains("mTypeAdapter.addAll(")); - // applyTypes 里的 updateTypeRecyclerVisibility() 依赖新数据算可见性,是合法例外; - // 换源清空这条路径没有任何依赖新数据的理由,可见性必须同步。 - assertTrue("换源清空链路必须仍然经守卫进入延后目标", - clearStale.contains(GUARD + "(this::clearStaleTypeItems)")); - } - - /** 缺陷 A 的落点钉:焦点失效与数据写入是两件事,焦点失效不得作废在途的类型栏写入。 */ - @Test - public void focusInvalidationNeverDropsPendingTypeWrites() throws Exception { - String home = read(HOME); - String slot = deferralSlot(guardBody(home)); - - String invalidate = methodBody(home, "private void invalidatePendingFocusRequests("); - assertNotNull(invalidate); - assertTrue("焦点失效仍然照旧推进自己的代际号", invalidate.contains("focusGeneration++")); - assertFalse("焦点失效不得动类型栏的待写入槽位,否则布局中的写入会被无谓作废", - invalidate.contains(slot)); - - String clearCategory = methodBody(home, "private void clearCategoryContent("); - assertTrue("换源必须仍然清掉上一站点的分类按钮", clearCategory.contains("clearStaleSiteTypes();")); - assertFalse("换源不需要额外作废在途写入:清理本身就是最新写入,靠后写者覆盖即已足够", - clearCategory.contains(slot)); - } - - private static String guardBody(String home) { - String guard = methodBody(home, "private void " + GUARD + "("); - assertNotNull("类型栏布局守卫必须是 private void " + GUARD + "(Runnable):" - + "无返回值是实现「不可能重复执行」的前提", guard); - return guard; - } - - /** 待写入槽位 = 守卫体里被赋值为本次 update 参数的那个字段,按结构推导,不写死名字。 */ - private static String deferralSlot(String guard) { - Matcher matcher = Pattern.compile("(\\w+)\\s*=\\s*update\\s*;").matcher(guard); - assertTrue("守卫必须把本次更新存进一个可被后写者覆盖的槽位", matcher.find()); - return matcher.group(1); - } - - /** 单 runner 去重标志 = 守卫体外层那句「已在排队就返回」里的字段。 */ - private static String postedFlag(String guard) { - Matcher matcher = Pattern.compile("if\\s*\\(\\s*(\\w+)\\s*\\)\\s*return\\s*;").matcher(guard); - assertTrue("守卫必须用「已在排队就直接返回」来去重 runner", matcher.find()); - return matcher.group(1); - } - - /** runner 从槽位取出的局部变量名。 */ - private static String captureFromSlot(String runner, String slot) { - Matcher matcher = Pattern.compile("Runnable\\s+(\\w+)\\s*=\\s*" + Pattern.quote(slot) + "\\s*;").matcher(runner); - assertTrue("runner 必须把槽位里的写入取出来再执行,否则最新写入会变成孤儿", matcher.find()); - return matcher.group(1); - } - - /** 布局忙碌判定的辅助方法名 = 同步分支里「不忙就立刻执行」那句取反调用的方法。 */ - private static String busyHelperName(String guard) { - Matcher matcher = Pattern.compile("if\\s*\\(\\s*!\\s*(\\w+)\\(\\)\\s*\\)").matcher(guard); - assertTrue("同步分支必须用一句取反的忙碌判定决定是否立刻执行(条件只定义一次)", matcher.find()); - return matcher.group(1); - } - - /** 文件里所有「直接调用守卫」的代码行(排除声明行、javadoc 与注释行),已去掉首尾空白。 */ - private static List guardCallSites(String home) { - List lines = new ArrayList<>(); - for (String raw : home.split("\n")) { - String line = raw.trim(); - if (!line.contains(GUARD + "(")) continue; - if (line.startsWith("private void " + GUARD + "(")) continue; - if (line.startsWith("*") || line.startsWith("/*") || line.startsWith("//")) continue; - lines.add(line); - } - return lines; - } - - /** - * 裸语句形态:整行只有一句 runTypeMutation(...); —— 不参与任何判定或赋值,也不夹带第二条语句。 - * - *

行内只允许一个分号:{@code runTypeMutation(this::x); x();} 这种「守卫先跑一遍、 - * 目标再跑一遍」的同行写法必须被判红,否则它同样会重现双执行。 - */ - private static boolean isBareStatement(String line) { - return line.matches(Pattern.quote(GUARD) + "\\(.*\\);") && occurrences(line, ";") == 1; - } - - private static int occurrences(String haystack, String needle) { - int count = 0; - int index = haystack.indexOf(needle); - while (index >= 0) { - count++; - index = haystack.indexOf(needle, index + needle.length()); - } - return count; - } - - /** - * 被守卫接管的延后目标名(this::name 与 () -> name(...) 两种写法都认)。 - * - *

必须扫整个文件而不是守卫体:调用点在 setTypes / clearStaleSiteTypes 里。 - */ - private static Set guardedTargets(String home) { - Set names = new LinkedHashSet<>(); - Matcher methodRef = Pattern.compile(GUARD + "\\(\\s*this::(\\w+)\\s*\\)").matcher(home); - while (methodRef.find()) names.add(methodRef.group(1)); - Matcher lambda = Pattern.compile(GUARD + "\\(\\s*\\(\\)\\s*->\\s*(\\w+)\\(").matcher(home); - while (lambda.find()) names.add(lambda.group(1)); - return names; - } - - /** 从方法签名处截到与之配对的右花括号,避免依赖具体的方法书写顺序。 */ - private static String methodBody(String source, String signature) { - int from = source.indexOf(signature); - if (from < 0) return null; - int body = source.indexOf('{', from); - if (body < 0) return null; - int depth = 0; - for (int i = body; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return source.substring(from, i + 1); - } - return source.substring(from); - } - - /** 定位某个偏移所在的方法名,只认类体内一级缩进的方法声明行。 */ - private static String enclosingMethod(String source, int offset) { - String name = ""; - int cursor = 0; - for (String line : source.split("\n", -1)) { - if (cursor > offset) break; - Matcher matcher = Pattern.compile("^ {4}(?:private|protected|public)\\s+[\\w<>\\[\\].]+\\s+(\\w+)\\(").matcher(line); - if (matcher.find()) name = matcher.group(1); - cursor += line.length() + 1; - } - return name; - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/PlaybackActivityResizeModeIdempotenceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/PlaybackActivityResizeModeIdempotenceTest.java deleted file mode 100644 index 2729e00745..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/PlaybackActivityResizeModeIdempotenceTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * 进入播放页时 {@code applyResizeMode} 会重复执行 14-16 次:每次调用自己先跑一次 - * {@code applyResizeModeNow},再 post 一个二次调用,而 {@code onPlayerRenderRequired} / - * {@code onPlayerRebuild} 又会各调一次。每次执行都请求两次 layout 并打两行长日志。 - * 这些测试锁住「参数没变就直接返回」这条幂等约束,同时锁住真正的应用动作没被删掉。 - */ -public class PlaybackActivityResizeModeIdempotenceTest { - - @Test - public void repeatedApplyWithTheSameParametersDoesNothing() throws Exception { - String source = readSource(); - String body = methodBody(source, "private void applyResizeModeNow(int resizeMode)"); - int guard = body.indexOf("if (isResizeModeApplied("); - assertTrue("applyResizeModeNow must consult the applied-parameter guard", guard >= 0); - int before = body.indexOf("logSurfaceState(\"applyResizeMode before"); - int setAspect = body.indexOf("setVideoAspect("); - int requestLayout = body.indexOf("view.requestLayout();"); - assertTrue("the guard must run before the surface-state log", before > guard); - assertTrue("the guard must run before the player aspect update", setAspect > guard); - assertTrue("the guard must run before the layout requests", requestLayout > guard); - assertTrue("the real resize must still be applied when parameters change", requestLayout >= 0); - } - - @Test - public void theGuardComparesEveryParameterThatChangesTheSurface() throws Exception { - String source = readSource(); - String body = methodBody(source, "private boolean isResizeModeApplied("); - assertTrue("resize mode must be part of the identity", body.contains("resizeAppliedMode == resizeMode")); - assertTrue("effective resize mode must be part of the identity", body.contains("resizeAppliedEffectiveMode == effectiveResizeMode")); - assertTrue("a float aspect ratio must be compared by bits, not by ==", body.contains("Float.floatToIntBits(targetAspectRatio)")); - assertTrue("the resolved content aspect ratio must be part of the identity", body.contains("Float.floatToIntBits(contentAspectRatio)")); - assertTrue("stretch must be part of the identity", body.contains("resizeAppliedStretch == stretch")); - assertTrue("the guard must report a hit only when everything matches", body.contains("if (resizeApplied &&")); - } - - @Test - public void surfaceRebindsInvalidateTheGuard() throws Exception { - String source = readSource(); - String decoderSwitch = methodBody(source, "private void resetVideoSurfaceForDecoderSwitch()"); - assertTrue("a decoder switch forces a rebind, so the cached resize state must be dropped", - decoderSwitch.contains("invalidateAppliedResizeMode();")); - String attach = methodBody(source, "private void attachSurface(boolean restoreExoShutter)"); - assertTrue("attaching a new surface must drop the cached resize state", - attach.contains("invalidateAppliedResizeMode();")); - int setPlayer = attach.indexOf("setPlayer("); - int invalidate = attach.indexOf("invalidateAppliedResizeMode();"); - assertTrue("the cache must be dropped right after the surface binding is replaced", invalidate > setPlayer); - } - - @Test - public void theDeferredSecondPassIsKept() throws Exception { - String body = methodBody(readSource(), "protected void applyResizeMode(int resizeMode)"); - assertTrue("applyResizeMode must still schedule the post-layout second pass", - body.contains("view.post(") && body.contains("requestedAspectMode == mode")); - assertFalse("applyResizeMode must not loop on itself", body.contains("applyResizeMode(mode);")); - } - - private static String readSource() throws Exception { - Path moduleRelative = Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "ui", "activity", "PlaybackActivity.java"); - Path path = Files.exists(moduleRelative) ? moduleRelative : Path.of("app").resolve(moduleRelative); - return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n"); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("source is missing " + signature, start >= 0); - int open = source.indexOf('{', start + signature.length()); - assertTrue("source is missing the body of " + signature, open > start); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char current = source.charAt(i); - if (current == '{') depth++; - else if (current == '}' && --depth == 0) return source.substring(start, i + 1); - } - throw new AssertionError("unclosed body for " + signature); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/PlaybackOwnershipSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/PlaybackOwnershipSourceTest.java deleted file mode 100644 index f959b75ea2..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/PlaybackOwnershipSourceTest.java +++ /dev/null @@ -1,449 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * 播放会话的归属令牌必须在会话内保持稳定。 - * - *

getPlaybackKey() 由子类从 intent 现算,而 intent 的 id 会在起播之后被详情结果改写 - * (TMDB 富集回来的 vodId,见 VideoActivity#updateVod)。播放器里的 key 是起播那一刻固化进 - * PlaySpec 的,之后无从更改。两者一旦不等,isOwner() 便永久为 false,同时打断三条链: - * STATE_READY 不再下发(转圈不收)、每秒进度采样直接返回(进度不落库、刷新回起点)、 - * 切集因 PlaybackService#isNavigationOwner 失配而不派发。 - */ -public class PlaybackOwnershipSourceTest { - - private static final String PLAYBACK = "app/src/main/java/com/fongmi/android/tv/ui/activity/PlaybackActivity.java"; - - @Test - public void ownershipReadsThePinnedSessionKeyNotTheLiveIntent() throws Exception { - String source = read(PLAYBACK); - - int owner = source.indexOf("protected boolean isOwner()"); - int ownerEnd = source.indexOf("\n }", owner); - String body = owner < 0 ? "" : source.substring(owner, ownerEnd); - - assertTrue("isOwner must exist", owner > 0); - assertTrue("ownership must read the pinned session key", body.contains("activePlaybackKey()")); - assertTrue("ownership must compare sessions, not raw strings", - body.contains("PlaybackOwnershipKeys.sameSession(")); - assertFalse("ownership must not recompute the key from the live intent", - body.contains("getPlaybackKey()")); - } - - @Test - public void theSessionKeyIsPinnedWhenPlaybackActuallyStarts() throws Exception { - String source = read(PLAYBACK); - - assertTrue("the pinned key must fall back to the intent before the first start", - source.contains("return pinnedPlaybackKey != null ? pinnedPlaybackKey : getPlaybackKey();")); - // 两条起播分支(需解析 / 直连)都要钉住,漏一条那条链路照旧丢归属; - // 重建恢复也写同一字段,但不是 startPlayer 内的赋值,所以要限定方法体统计 - int startPlayer = source.indexOf("protected void startPlayer(String key, Result result, boolean useParse, long timeout,"); - int startPlayerEnd = source.indexOf("private String lifecycleState()", startPlayer); - assertTrue("startPlayer must exist", startPlayer > 0 && startPlayerEnd > startPlayer); - assertEquals("both start branches must pin the session key from startPlayer", - 2, countOccurrences(source.substring(startPlayer, startPlayerEnd), "pinnedPlaybackKey = key;")); - - int route = source.indexOf("NovelRouter.isReaderUrl(result)"); - int firstPin = source.indexOf("pinnedPlaybackKey = key;", route); - int parse = source.indexOf("player().parse(", route); - int start = source.indexOf("player().start(", route); - - assertTrue("the reader route must return before anything is pinned", firstPin > route); - assertTrue("pinning must happen before the player is handed the spec", - firstPin < parse && firstPin < start); - } - - @Test - public void routingAndReadyReplayFollowTheSameSessionKey() throws Exception { - String source = read(PLAYBACK); - - // 导航 key 与归属 key 必须同源:PlaybackService#isNavigationOwner 拿它跟 - // player.getKey() 比,用现算值会让切集在 id 漂移后静默失效 - assertFalse("navigation must not be registered with a recomputed key", - source.contains("setNavigationCallback(getNavigationCallback(), getPlaybackKey())")); - assertEquals("every navigation registration must use the session key", - 3, countOccurrences(source, "setNavigationCallback(getNavigationCallback(), activePlaybackKey())")); - assertTrue("the READY replay reconciliation must compare the session key", - source.contains("PlaybackStateReconciliation.shouldReplayReady(activePlaybackKey()")); - } - - @Test - public void switchingEntryPointsKeepTheSessionKeyInsteadOfTheHistoryKey() throws Exception { - // 切内核/切解码会重建 PlaySpec;传 getHistoryKey() 会把播放器的 key 换成 - // 当前 intent 算出来的新值,等于在会话中途把归属令牌搬走 - for (String path : new String[] { - "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java", - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java", - "app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java" - }) { - String source = read(path); - assertFalse(path + " must not re-key the player with a freshly computed history key", - source.contains("switchPlayer(type, result, getHistoryKey()") - || source.contains("switchPlayer(playerType, result, getHistoryKey()") - || source.contains("switchDecode(result, getHistoryKey()")); - } - assertTrue("mobile decode switching must reuse the session key", - read("app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java") - .contains("switchDecode(result, activePlaybackKey()")); - } - - @Test - public void itemSwitchesInvalidateInFlightPlayerRequests() throws Exception { - String viewModel = read("app/src/main/java/com/fongmi/android/tv/model/SiteViewModel.java"); - - assertTrue("SiteViewModel must expose a player-content cancellation hook", - viewModel.contains("public void cancelPlayerContent()")); - assertTrue("cancellation must invalidate the old task id", - viewModel.indexOf("taskIds.get(TaskType.PLAYER).incrementAndGet();") >= 0); - assertTrue("cancellation must clear stale LiveData", - viewModel.indexOf("player.setValue(null);") >= 0); - for (String path : new String[] { - "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java", - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java" - }) { - String source = read(path); - int newIntent = source.indexOf("protected void onNewIntent(Intent intent)"); - int end = source.indexOf("protected void initView", newIntent); - int cancel = source.indexOf("cancelPlayerContent();", newIntent); - assertTrue(path + " must cancel player content on item switch", - newIntent >= 0 && cancel > newIntent && (end < 0 || cancel < end)); - } - } - - @Test - public void tmdbDetailRefreshesNavigationAfterResettingOldItemOwnership() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); - - int newIntent = source.indexOf("protected void onNewIntent(Intent intent)"); - int reset = source.indexOf("resetPlaybackOwnership();", newIntent); - int navigation = source.indexOf("updateNavigationKey();", newIntent); - - assertTrue("TMDB detail must reset stale ownership on item switch", newIntent >= 0 && reset > newIntent); - assertTrue("TMDB detail must refresh the service navigation key after that reset", navigation > reset); - } - - @Test - public void kernelRebuildBindsTheNewEngineToThePlayerViewByInstanceIdentity() throws Exception { - // 自动下一集先 clear() 把 Manager.spec 置空,再 preparePlayer() 重建引擎;此时 - // isOwner() 为 false,onPlayerRebuild() 里的 setRender() 整段被跳过,render 值不变。 - // 只按“PlayerView.player 是否为空”判断,就会把已释放的旧引擎留在 View 上, - // 新引擎从未拿到 Surface —— 画面全黑、声音正常,手动切内核/软硬解才恢复。 - // AUTONEXT-02 起判定收敛到 VideoSurfaceBinding,attachSurface 只执行它的结论。 - String source = read(PLAYBACK); - int attach = source.indexOf("private void attachSurface(boolean restoreExoShutter)"); - int end = source.indexOf("\n }", attach); - assertTrue("attachSurface must exist", attach > 0 && end > attach); - String body = source.substring(attach, end); - - assertTrue("the surface rebind must compare the live engine instance, not emptiness", - body.contains("Player currentPlayer = getExoView().getPlayer();") - && body.contains("Player nextPlayer = player().getPlayer();") - && body.contains("VideoSurfaceBinding.resolve(currentPlayer, nextPlayer);")); - assertTrue("the identity branch must hand the view the new engine", - body.contains("getExoView().setPlayer(binding.player());")); - int detach = body.indexOf("if (binding.detachedFirst()) getExoView().setPlayer(null);"); - int rebind = body.indexOf("getExoView().setPlayer(binding.player());"); - assertTrue("the released engine must be detached before the replacement is bound", - detach > 0 && rebind > detach); - assertFalse("attachment must no longer depend on the view merely being empty", - body.contains("if (getExoView().getPlayer() == null)")); - } - - @Test - public void staleSpinnerFallbackCannotExposeAnotherItemsPlayback() throws Exception { - for (String path : new String[] { - "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java", - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java" - }) { - String source = read(path); - int fallback = source.indexOf("private void hidePlaybackProgressIfStale()"); - int close = source.indexOf("showPlaybackContent();", fallback); - String guard = source.substring(fallback, close); - assertTrue(path + " must require a nonempty current owner before hiding the spinner", - guard.contains("player().isReleased()") - && guard.contains("player().isEmpty()") - && guard.contains("player().getPlaybackState() != Player.STATE_READY") - && guard.contains("!isOwner()")); - - int seekFallback = source.indexOf("private void hideSeekProgressIfReady()"); - int seekClose = source.indexOf("showPlaybackContent();", seekFallback); - String seekGuard = source.substring(seekFallback, seekClose); - assertTrue(path + " must keep the seek fallback owner-scoped", - seekGuard.contains("!isOwner()")); - } - } - - @Test - public void staleSwitchCallbacksRecheckPageAndPlaybackContextWithApplyGuard() throws Exception { - String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - String leanback = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - - assertTrue("mobile switch callbacks must use the captured request context", - mobile.contains("isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)")); - assertTrue("mobile decode failures must not clear a newer request", - mobile.contains("if (!isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return;")); - assertTrue("leanback switch callbacks must use the captured request context", - leanback.contains("isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)")); - assertTrue("leanback failures must not clear a newer request", - leanback.contains("if (!isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return;")); - } - - @Test - public void switchingEpisodesClearsPendingSwitchStateAfterRefresh() throws Exception { - String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - String leanback = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - - assertTrue("mobile episode playback must invalidate older switch requests", - mobile.contains("playerContentGeneration++;") - && mobile.contains("decodeSwitchRefreshing = false;")); - assertTrue("leanback episode playback must invalidate older switch requests", - leanback.contains("playerContentGeneration++;") - && leanback.contains("playerKernelSwitchRefreshing = false;")); - assertTrue("mobile item switch must discard a result waiting for the service", - mobile.contains("mPendingPlayerResult = null;")); - int mobileRequest = mobile.indexOf("private void beginPlayerContentRequest"); - int leanbackRequest = leanback.indexOf("private void beginPlayerContentRequest"); - assertTrue("mobile episode request must discard a result waiting for the service", - mobile.substring(mobileRequest, mobile.indexOf("\n }", mobileRequest)) - .contains("mPendingPlayerResult = null;")); - assertTrue("leanback episode request must discard a result waiting for the service", - leanback.substring(leanbackRequest, leanback.indexOf("\n }", leanbackRequest)) - .contains("mPendingPlayer = null;")); - } - - @Test - public void kernelSwitchReleasesRefreshingStateBeforeApplyGuard() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - int method = source.indexOf("private void switchPlayerKernelWithResult"); - int clear = source.indexOf("playerKernelSwitchRefreshing = false;", method); - int apply = source.indexOf("if (!canApplyPlayerContentRequest", method); - assertTrue("leanback must clear a current switch before checking temporary player availability", - method >= 0 && clear > method && apply > method && clear < apply); - } - - @Test - public void switchingToAnotherItemReleasesThePreviousSessionKey() throws Exception { - // onNewIntent 换的是条目,旧会话已作废;不清令牌会让新条目一直拿旧 key 比对 - assertTrue("the reset hook must exist", read(PLAYBACK).contains("protected final void resetPlaybackOwnership()")); - for (String path : new String[] { - "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java", - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java", - "app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java" - }) { - String source = read(path); - int newIntent = source.indexOf("protected void onNewIntent(Intent intent)"); - int reset = source.indexOf("resetPlaybackOwnership();", newIntent); - int end = source.indexOf("protected void initView", newIntent); - assertTrue(path + " must exist", newIntent > 0); - assertTrue(path + " must release the stale session key when the item changes", - reset > newIntent && (end < 0 || reset < end)); - } - } - - @Test - public void theSessionKeySurvivesActivityRecreation() throws Exception { - String source = read(PLAYBACK); - - int save = source.indexOf("protected void onSaveInstanceState(@NonNull Bundle outState)"); - int restore = source.indexOf("private void restorePlaybackKey(Bundle savedInstanceState)"); - int initViewBody = source.indexOf("restorePlaybackKey(savedInstanceState);"); - int bindService = source.indexOf("if (!shouldBindPlaybackService()) return;", initViewBody); - - assertTrue("playback activity must implement onSaveInstanceState", save > 0); - assertTrue("playback activity must restore the pinned key on recreation", restore > 0); - assertTrue("the pinned key must be restored before binding the service", - initViewBody > 0 && initViewBody < restore && bindService > initViewBody); - assertTrue("the state key must match between save and restore", - countOccurrences(source, "STATE_PLAYBACK_KEY") >= 3); - assertTrue("the session key must not be saved after playback exits", - source.substring(save, source.indexOf("\n }", save)) - .contains("!playbackExiting && pinnedPlaybackKey != null")); - } - - /** - * 加载圈的兜底必须与当前播放会话保持一致。 - */ - @Test - public void theSpinnerFallbackStaysOwnerScoped() throws Exception { - for (String path : new String[] { - "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java", - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java" - }) { - String source = read(path); - int fallback = source.indexOf("private void hidePlaybackProgressIfStale()"); - assertTrue(path + " must provide a spinner fallback", fallback > 0); - - String body = source.substring(fallback, source.indexOf("\n }", fallback)); - assertTrue(path + " the fallback must reject a stale playback owner", body.contains("!isOwner()")); - assertTrue(path + " the fallback must read the player state directly", - body.contains("player().getPlaybackState() != Player.STATE_READY")); - // 详情还没加载完时播放器是空的,那时的圈属于详情页自己,不能收 - assertTrue(path + " the fallback must not steal the detail page's own loading state", - body.contains("player().isEmpty()")); - assertTrue(path + " the fallback must only act while the spinner is actually up", - body.contains("getVisibility() != View.VISIBLE")); - - // 挂在网速自循环上:圈可见时每秒一跳,圈收了循环本就停,无额外开销 - int traffic = source.indexOf("private void setTraffic()"); - assertTrue(path + " must drive the fallback from the traffic ticker", - source.indexOf("hidePlaybackProgressIfStale();", traffic) > traffic); - } - } - - @Test - public void staleSwitchCallbacksRecheckPageAndPlaybackContext() throws Exception { - String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - String leanback = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - - assertTrue("mobile switch callbacks must use the captured request context", - mobile.contains("isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)")); - assertTrue("mobile switch failures must not clear a newer request", - mobile.contains("if (!isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return;")); - assertTrue("leanback switch callbacks must use the captured request context", - leanback.contains("isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)")); - assertTrue("leanback switch failures must not clear a newer request", - leanback.contains("if (!isCurrentPlayerContentRequest(requestId, generation, key, flag, episode)) return;")); - assertTrue("mobile switch callbacks must have an apply guard", - mobile.contains("private boolean canApplyPlayerContentRequest(int requestId, int generation,")); - assertTrue("leanback switch callbacks must have an apply guard", - leanback.contains("private boolean canApplyPlayerContentRequest(int requestId, int generation,")); - assertTrue("mobile switch callbacks must have a request-state helper", - mobile.contains("private int beginPlayerContentSwitch(int requestId, String key, String flag, String episode)")); - assertTrue("leanback switch callbacks must have a request-state helper", - leanback.contains("private int beginPlayerContentSwitch(int requestId, String key, String flag, String episode)")); - } - - @Test - public void switchingEpisodesClearsPendingSwitchState() throws Exception { - String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - String leanback = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); - - assertTrue("mobile item refresh must invalidate the player request generation", - mobile.contains("playerContentGeneration++;") - && mobile.contains("decodeSwitchRefreshing = false;")); - assertTrue("leanback item refresh must invalidate the player request generation", - leanback.contains("playerContentGeneration++;") - && leanback.contains("playerKernelSwitchRefreshing = false;")); - assertTrue("mobile item switch must discard a result waiting for the service", - mobile.contains("mPendingPlayerResult = null;")); - } - - /** - * 退出播放页必须确定性停播。 - * - *

旧实现只在「服务在且本页是归属方」时 shutdown,其余情况交给控制器。控制器是异步连上的, - * 播放又由服务侧直接起播,于是控制器未就绪时整次停播是空操作;播放器处于 IDLE(取流中退出)时 - * 会被会话里的 stop 直接忽略;这条路径也从不撤销前台通知。用户看到的就是 - * 「退回详情页后还有声音、通知栏还显示播放中」。 - */ - @Test - public void exitingThePlayerPageAlwaysStopsThroughTheService() throws Exception { - String source = read(PLAYBACK); - - int stop = source.indexOf("protected void stopPlayback()"); - assertTrue("stopPlayback must exist", stop > 0); - int end = source.indexOf("\n }", stop); - assertTrue("stopPlayback must be readable", end > stop); - String body = source.substring(stop, end); - - assertTrue("the owner must still shut the service down", body.contains("mService.shutdown();")); - assertTrue("a non-owner exit must stop through the service as well", - body.contains("mService.suspend();")); - assertTrue("the controller may only remain as the no-service fallback", - body.indexOf("mController.stop();") > body.indexOf("if (mService == null)")); - assertFalse("the non-owner stop must not stay gated on an asynchronous controller", - body.contains("else if (mController != null)")); - } - - /** - * 已经决定退出的播放页不能再起播。 - * - *

自动换源、换线路、取流结果这些链路最终都回到 startPlayer;迟到的一次起播会在服务里正常 - * 播放并挂上前台通知,而页面已经退出,没有人再停它。 - */ - @Test - public void anExitingPlayerPageNeverStartsPlaybackAgain() throws Exception { - String source = read(PLAYBACK); - - int startPlayer = source.indexOf("protected void startPlayer(String key, Result result, boolean useParse, long timeout,"); - assertTrue("the six-argument startPlayer must exist", startPlayer > 0); - int body = source.indexOf("MediaMetadata metadata, long startPositionMs) {", startPlayer); - assertTrue("the six-argument startPlayer body must be readable", body > startPlayer); - int route = source.indexOf("NovelRouter.isReaderUrl(result)", body); - assertTrue("the reader route must still be the first dispatch", route > body); - - String guard = source.substring(body, route); - assertTrue("startPlayer must refuse to start once the page is exiting", - guard.contains("isPlaybackExiting()")); - assertTrue("startPlayer must refuse to start once the page is finishing or destroyed", - guard.contains("isFinishing()") && guard.contains("isDestroyed()")); - } - - /** - * 归属判定两侧必须同源:页面与服务各用一份,任一侧退回字符串相等就会重现 AUTONEXT-02。 - */ - @Test - public void serviceNavigationOwnershipComparesTheSameSessions() throws Exception { - String service = read("app/src/main/java/com/fongmi/android/tv/service/PlaybackService.java"); - - int owner = service.indexOf("private boolean isNavigationOwner()"); - int ownerEnd = service.indexOf("\n }", owner); - assertTrue("isNavigationOwner must exist", owner > 0 && ownerEnd > owner); - assertTrue("service-side navigation ownership must compare sessions, not raw strings", - service.substring(owner, ownerEnd).contains("PlaybackOwnershipKeys.sameSession(")); - - // 选集键前缀在 browse 与 player 两个包里各写一份,必须同值,否则归属判定会静默失效。 - assertTrue("VodBrowse must keep the episode prefix", - read("app/src/main/java/com/fongmi/android/tv/browse/VodBrowse.java") - .contains("static final String VOD_EP = \"VE:\";")); - assertTrue("PlaybackOwnershipKeys must use the same episode prefix", - read("app/src/main/java/com/fongmi/android/tv/player/PlaybackOwnershipKeys.java") - .contains("static final String EPISODE_PREFIX = \"VE:\";")); - } - - /** - * Surface 补绑只能在用户实际在看的那一页执行,否则后台旧页面会把引擎抢走。 - */ - @Test - public void surfaceTopUpRunsOnlyWhileThePageIsResumedAndNeverUnbinds() throws Exception { - String source = read(PLAYBACK); - - int topUp = source.indexOf("private void ensureVideoSurfaceBound(String reason)"); - int end = source.indexOf("\n }", topUp); - assertTrue("ensureVideoSurfaceBound must exist", topUp > 0 && end > topUp); - String body = source.substring(topUp, end); - - assertTrue("only a RESUMED page may re-bind the shared engine", - body.contains("isAtLeast(Lifecycle.State.RESUMED)")); - assertTrue("an engine that is not ready must not unbind the view", - body.contains("if (nextPlayer == null) return;")); - assertTrue("the top-up must not drop the ASS/subtitle sessions", - !body.contains("detachSurface()")); - } - - private static int countOccurrences(String text, String needle) { - int count = 0; - int index = 0; - while ((index = text.indexOf(needle, index)) >= 0) { - count++; - index += needle.length(); - } - return count; - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of(path.substring("app/".length())), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/ReaderPlaybackRoutingSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/ReaderPlaybackRoutingSourceTest.java index f0235cd192..17d889ef10 100644 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/ReaderPlaybackRoutingSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/ReaderPlaybackRoutingSourceTest.java @@ -1,7 +1,5 @@ package com.fongmi.android.tv.ui.activity; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import org.junit.Test; @@ -9,7 +7,6 @@ import org.junit.Test; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.regex.Pattern; public class ReaderPlaybackRoutingSourceTest { @@ -26,127 +23,6 @@ public class ReaderPlaybackRoutingSourceTest { && (pipeline < 0 || unconditionalReturn < pipeline)); } - @Test - public void historyEntryUsesReaderRouteBeforeCreatingPlaybackPage() throws Exception { - String coordinator = read("app/src/main/java/com/fongmi/android/tv/ui/activity/HistoryResumeCoordinator.java"); - String router = read("app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java"); - - int readerRoute = coordinator.indexOf("NovelRouter.openHistory(activity, history"); - int playbackRoute = coordinator.indexOf("VideoActivity.startFromHistory(activity, history"); - int historyMethod = router.indexOf("public static boolean openHistory("); - int currentSourceGate = coordinator.indexOf("!Setting.isGlobalHistoryEnabled() || history.isCurrentSourceAvailable()"); - - assertTrue("history entry must try the reader route first", readerRoute >= 0); - assertTrue("reader history route must run before the playback fallback", - playbackRoute < 0 || readerRoute < playbackRoute); - assertTrue("cross-config history must resolve its source before trying the reader route", - currentSourceGate >= 0 && currentSourceGate < readerRoute); - assertTrue("history reader route must resolve the recorded book, not a blank playback page", - historyMethod >= 0 - && router.indexOf("history.getVodId()", historyMethod) > historyMethod - && router.indexOf("history.getEpisodeUrl()", historyMethod) > historyMethod); - } - - @Test - public void historyReaderRouteUsesTheCompleteResolvedPayload() throws Exception { - String router = read("app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java"); - - int resolve = router.indexOf("private static ReaderData resolveHistory("); - int payload = router.indexOf("String content = readerPayload(result);", resolve); - - assertTrue("history reader routing must pass the complete playUrl+url payload to WebReaderActivity", - resolve >= 0 && payload > resolve); - assertEquals("all Result reader entry points must use the same resolved payload", - 4, countOccurrences(router, "String payload = readerPayload(result);")); - } - - @Test - public void historyReaderRouteBindsTargetConfigAndSupportsCancellation() throws Exception { - String router = read("app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java"); - String coordinator = read("app/src/main/java/com/fongmi/android/tv/ui/activity/HistoryResumeCoordinator.java"); - - int open = router.indexOf("public static boolean openHistory(Activity activity, History history, Vod target,"); - assertTrue("history route must capture an immutable target config", open >= 0 - && router.indexOf("int targetCid", open) > open - && router.indexOf("targetCid != VodConfig.getCid()", open) > open); - assertTrue("history route must invalidate superseded and cancelled requests", - router.contains("AtomicBoolean canceled = new AtomicBoolean(false);") - && router.contains("request != HISTORY_REQUESTS.get()") - && router.contains("future.cancel(true);")); - assertTrue("cross-source resume must pass its target config into the reader route", - coordinator.contains("resolved.episode(), targetCid,")); - } - - @Test - public void readerHistoryIsNamespacedAwayFromPlaybackHistory() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/novel/ReaderHistory.java"); - - assertTrue("reader progress must use an explicit reader key namespace", - source.contains("private static final String KEY_NAMESPACE = AppDatabase.SYMBOL + MEDIA_TYPE;")); - assertTrue("reader rows must be typed before they can be reused", - source.contains("static boolean isReaderRecord(@Nullable History history)")); - assertTrue("saving reader progress must preserve a captured config id", - source.contains("private static void saveSync(int cid, Record record")); - } - - @Test - public void readerProgressRefreshesHistoryAfterEverySave() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/novel/ReaderHistory.java"); - - boolean savedThenRefreshed = Pattern.compile( - "saveRow\\(history\\);\\s*App\\.post\\(RefreshEvent::history\\);") - .matcher(source).find(); - - assertTrue("reader history must save and refresh the updated row together", - savedThenRefreshed); - assertTrue("reader history must refresh the history view after saving progress", - savedThenRefreshed); - } - - @Test - public void readerAsyncDialogsCancelTheirBackgroundLaunches() throws Exception { - String router = read("app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java"); - - assertEquals("route, openHistory and openSite must all wire cancellation", - 3, countOccurrences(router, "setOnCancelListener")); - assertTrue(router.contains("future.cancel(true);")); - assertTrue(router.contains("canceled.get()")); - } - - @Test - public void crossSourceReaderSeedMigratesKeyAndAlignsChapterProgress() throws Exception { - String router = read("app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java"); - - assertTrue(router.contains("target.replace(expectedKey);")); - assertTrue(router.contains("alignResolvedHistoryProgress(target, history, episode.getUrl());")); - assertTrue(router.contains("target.setMediaType(ReaderHistory.MEDIA_TYPE);")); - } - - @Test - public void crossSourceReaderRouteDoesNotCopyVideoTimeIntoReaderAnchors() throws Exception { - String router = read("app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java"); - - assertTrue("reader candidate gating must require a reader source or reader record", - router.contains("ReaderHistory.isReaderRecord(history)") - && router.contains("isReaderUrl(targetEpisode == null ? null : targetEpisode.getUrl())")); - assertTrue("progress alignment must check the source record type before copying anchors", - router.contains("ReaderHistory.isReaderRecord(source)") - && router.contains("source.hasPlaybackTime()")); - assertTrue("new target rows must align progress before saving", - router.contains("alignResolvedHistoryProgress(seed, history, episode.getUrl());")); - } - - @Test - public void cancelledHistoryRequestCannotWriteResolvedReaderHistory() throws Exception { - String router = read("app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java"); - - assertTrue("history resolution must carry its cancellation token", - router.contains("resolveHistory(history, target, targetFlag, targetEpisode, targetCid, canceled)")); - assertTrue("history seeding must recheck cancellation immediately before writing", - router.contains("if (canceled.get()) return;") - && router.contains("seedCrossSourceHistory(history, siteKey, vodId")); - } - @Test public void readerDispatchKeepsHostPageInBackstack() throws Exception { for (String path : new String[] { @@ -174,437 +50,13 @@ public class ReaderPlaybackRoutingSourceTest { }) { String source = read(path); - assertTrue(path + " must cancel the previous player-content request before starting another", - source.contains("if (mViewModel != null) mViewModel.cancelPlayerContent();")); assertTrue(path + " must track the result already applied to the player", source.contains("mAppliedPlayerResult")); - // 这里只钉「重复结果不得再起播一次」这个语义,不再钉死大括号风格: - // Leanback 需要在这个早退分支里顺带释放播放加载守卫(同一结果已在播就不会再有 - // READY 回调来收圈),所以它的分支是多行块;mobile 没有那套守卫状态,保持单行即可。 - // 两者共同且唯一必须成立的,是早退判定本身且它真的 return 掉了。 - int duplicate = source.indexOf("result == mAppliedPlayerResult && !player().isEmpty()"); - int applied = source.indexOf("mAppliedPlayerResult = result;", duplicate); assertTrue(path + " must ignore a duplicate player result while playback remains active", - duplicate >= 0 && applied > duplicate - && source.substring(duplicate, applied).contains("return")); + source.contains("if (result == mAppliedPlayerResult && !player().isEmpty()) return;")); } } - /** - * 返回键要能真正退出阅读器。 - * - * 生命周期顺序是 阅读器 onPause -> 宿主 onResume -> 阅读器 onStop -> 阅读器 onDestroy。 - * 宿主 onResume 会因 shouldReclaim() 重新派发上一次的 playerContent 结果, - * 关闭时间戳若等到 onDestroy 才写,那一刻 NovelRouter 的两道防线同时失效, - * 阅读器会被立刻重新拉起,返回键表现为完全无效。 - */ - @Test - public void readerMarksClosedBeforeHostResumes() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - int pause = source.indexOf("protected void onPause()"); - int markInPause = source.indexOf("markClosed();", pause); - int superPause = source.indexOf("super.onPause();", pause); - int destroy = source.indexOf("protected void onDestroy()"); - - assertTrue("onPause must exist", pause >= 0); - assertTrue("onPause must mark the reader closed before super.onPause()", - markInPause > pause && markInPause < superPause); - assertTrue("markClosed must run before onDestroy is reached", markInPause < destroy); - assertTrue("markClosed must clear the reader registration", - source.contains("NovelRouter.currentReader = null;")); - assertTrue("markClosed must stamp the close time", - source.contains("NovelRouter.markReaderClosed();")); - assertTrue("onDestroy must still mark closed for non-finish teardown", - source.indexOf("markClosed();", destroy) > destroy); - } - - @Test - public void readerMarksClosedBeforeClearingHostRequests() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - int pause = source.indexOf("protected void onPause()"); - int markInPause = source.indexOf("markClosed();", pause); - int clearRequests = source.indexOf("hostChapterRequests.getAndSet(0)", pause); - - assertTrue("onPause must mark the reader closed before clearing host requests", - markInPause > pause && clearRequests > pause && markInPause < clearRequests); - } - - @Test - public void hostResultClosesRequestBeforeWebViewGuard() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - int method = source.indexOf("private void onEpisodeResolved(int newKind, String payload, String title, boolean fromHost)"); - int webViewGuard = source.indexOf("if (webView == null) return;", method); - int closeRequest = source.indexOf("if (fromHost) endHostChapterRequest();", method); - - assertTrue("a host result must close its request even when the WebView was already destroyed", - method >= 0 && webViewGuard > method && closeRequest > method && closeRequest < webViewGuard); - } - - @Test - public void parseFallbackKeepsRestoreAnchorUntilHostResultArrives() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - int method = source.indexOf("private void resolveChapterSelf(String chapterUrl)"); - int dispatch = source.indexOf("if (fh && h != null)", method); - int clear = source.indexOf("restoreAnchor = 0;", dispatch); - int hostFlag = source.indexOf("boolean hostDispatched = false;", method); - int guard = source.indexOf("if (!hostDispatched)", dispatch); - - assertTrue("parse=1 已成功交给宿主时,必须保留历史恢复锚点直到结果注入", - method >= 0 && hostFlag > method && dispatch > method && clear > dispatch - && guard > dispatch && guard < clear); - } - - @Test - public void staleResultCannotBeDeliveredToAReaderWithoutPendingHostRequest() throws Exception { - String router = read("app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java"); - String reader = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - assertTrue("当前阅读器必须能报告自己是否有宿主切章请求在途", - reader.contains("public boolean hasPendingHostChapterRequest()")); - assertEquals("三个阅读器结果入口都必须拦截旧请求,避免串页", - 3, countOccurrences(router, - "!reader.hasPendingHostChapterRequest() && NovelRouter.consumeStaleChapterResult()")); - } - - /** - * 首帧不能把进度虚报到批次末尾。 - * - * 漫画首批只挂 5 张图,未解码时 bottom 全堆在同一 y 上,没有锚点跨过阈值, - * 循环末尾的 `idx = i` 就把序号推到最后一个锚点 —— 95 页漫画记成第 5 页、 - * 33 段小说记成第 33 段,下次进来直接跳到那里。 - * - * 注意不能用 rect.height 当「未就绪」信号:小说段落带 content-visibility:auto, - * 真机实测 height 恒为 0 而 bottom 有效,用 height 判断会让小说进度永远停在第 1 段。 - */ - @Test - public void readerAnchorIndexShortCircuitsAtTopOfChapter() throws Exception { - String source = read("app/src/main/assets/reader.html"); - - int fn = source.indexOf("function currentAnchorIndex()"); - int guard = source.indexOf("if(scrolled <= 0) return lastAnchorIndex = 0;", fn); - int loop = source.indexOf("for(", fn); - int degraded = source.indexOf("return lastAnchorIndex = idx;", loop); - - assertTrue("currentAnchorIndex must exist", fn >= 0); - assertTrue("stopping at the top must short-circuit to anchor 0 before scanning", - guard > fn && guard < loop); - assertTrue("a degraded measurement must not advance the anchor unless the document end is reached", - degraded > loop); - // #reader 的 min-height:105vh 让「图全未解码」时轻扫 32px 就到文末, - // 只判 atDocumentEnd 会把退化值当可信 —— 必须同时要求锚点已全部进 DOM - assertTrue("reaching the document end alone must not validate a degraded measurement", - source.contains("anchorsSettled() && atDocumentEnd()")); - assertFalse("rect.height is always 0 for content-visibility paragraphs; it must not gate the scan", - source.contains("if(rect.height <= 0) break;")); - assertTrue("progress bar must read the current anchor, not the loaded count", - source.contains("Math.min(total, effectiveAnchorIndex() + 1)")); - // 落库与进度条必须同一个真值,否则会出现「显示 33/33、重进回到第 30 段」的分叉 - assertTrue("the saved anchor must come from the same source as the progress bar", - source.contains("effectiveAnchorIndex(), anchorTotal());")); - // 小说正文下方还有 140px 内边距和章节导航,光靠 currentAnchorIndex 够不到最后一段, - // 历史列表就永远到不了 100% - assertTrue("a chapter read to its end must be able to record the final anchor", - source.contains("if(anchorsSettled() && atDocumentEnd()) return total - 1;")); - // 退化态统一由 anchorsSettled() 把关:漫画末页图加载失败时高度恒为 0、不占空间; - // PDF 的 canvas 先插入后绘制,未绘制时是固有 300x150(有高度但不是真实页高), - // 只判「到底」会把用户没看到的内容记成读完,下次直接跳到章末 - assertTrue("a degraded layout must be recognised before trusting the document end", - source.contains("function anchorsSettled()")); - // 漫画看末尾两页:图各自异步解码,末页不保证最后完成,只看末页仍会虚报 - assertTrue("a comic must require the last two pages to be laid out", - source.contains("for(var i = Math.max(0, total - 2); i < total; i++){")); - assertTrue("a PDF must require every page to be painted, not merely appended", - source.contains("return pdfDoc != null && pdfAppendedCount >= pdfDoc.numPages;")); - // 渲染失败也要计数,否则那一章永远达不到 numPages,永远记不成读完 - assertTrue("a failed page render must still count towards the settled total", - source.contains(".promise.then(pageSettled, pageSettled);")); - assertTrue("a failed getPage must also count", - source.contains("}, pageSettled);")); - // 换章会清零计数,上一章在途的回调若继续 ++ 会把新章撑到 numPages - assertTrue("stale render callbacks must not inflate the next chapter's count", - source.contains("var gen = pdfGen;") - && source.contains("if(gen !== pdfGen) return;")); - // #reader 是所有章节共用的容器:getDocument 的成功/失败回调也必须带代号, - // 否则上一章的慢加载会把新章内容整段替换成 pdf-error,或把旧 doc 塞进新章 - assertEquals("both getDocument handlers must be generation-guarded", - 3, countOccurrences(source, "if(gen !== pdfGen) return;")); - // 遮罩是全屏 z-index:10002,只有这里会隐藏它:放在代号判定之后, - // 跨类型换章时旧章早退,遮罩就永久盖在新章上吞掉所有点击 - assertTrue("the loading overlay must be hidden before the generation check", - source.contains("showLoading(false);") && source.contains("if(gen !== pdfGen) return;")); - // renderPdf 只在新章也是 PDF 时才调,从 PDF 切到漫画要靠 renderContent 作废代号 - assertTrue("switching away from a PDF chapter must invalidate its callbacks", - source.contains("pdfGen++;") && source.contains("pdfDoc = null; pdfAppendedCount = 0;")); - // fulfillment 里同步抛错不会被同一个 then 的 rejection 处理器捕获 - assertTrue("a throw inside the fulfillment handler must still count", - source.contains("} catch(e){") && source.contains("pageSettled();")); - assertTrue("currentAnchorIndex must use the same settled check", - source.contains("if(found || (anchorsSettled() && atDocumentEnd()))")); - // memo 有副作用:文末分支提前 return 前必须先让 currentAnchorIndex() 跑过一次 - int effFn = source.indexOf("function effectiveAnchorIndex()"); - int memoUpdate = source.indexOf("Math.min(total - 1, currentAnchorIndex());", effFn); - int endBranch = source.indexOf("if(anchorsSettled() && atDocumentEnd())", effFn); - assertTrue("the memo must be updated before the document-end shortcut returns", - memoUpdate > effFn && memoUpdate < endBranch); - } - - /** - * 漫画横向翻页不是滚动文档:#reader 里只有当前页一张图, - * 通用滚动锚点会把每一页都算成第 0 页,翻页也不上报,导致历史节点丢失。 - */ - @Test - public void horizontalComicProgressUsesItsOwnPageState() throws Exception { - String source = read("app/src/main/assets/reader.html"); - - assertTrue("horizontal comics must read the active page instead of scroll anchors", - source.contains("if(DATA.kind === 2 && comicMode === 'h') return comicCur;")); - assertTrue("horizontal comics must save their page and total explicitly", - source.contains("AndroidReader.saveProgress(DATA.current|0, ch.url || '', ch.name || DATA.title || '', comicCur, comicTotal);")); - assertTrue("page flips must report progress immediately", - source.contains("comicShowPage(i){\n comicCur = i;") - && source.contains("updateComicInfo();\n reportProgress();")); - assertTrue("restore must use comic page state, not the scroll restoration loop", - source.contains("comicShowPage(Math.max(0, Math.min(comicTotal - 1, Math.round(index || 0))));")); - } - - /** - * 恢复定位依赖单调时钟,升级 reader.html 时不能把函数定义漏掉。 - * 缺失时漫画重进会在 restoreAnchor 首行抛 ReferenceError,历史节点因此不生效。 - */ - @Test - public void readerDefinesTheMonotonicClockUsedByRestore() throws Exception { - String source = read("app/src/main/assets/reader.html"); - - assertTrue("the monotonic clock must exist", - source.contains("function nowMs(){\n return (window.performance && performance.now) ? performance.now() : Date.now();")); - assertTrue("restore must use the monotonic clock", - source.contains("var deadline = nowMs() + 60000;")); - } - - /** - * 小说分页模式只显示当前子分页,不能把文档到底误判成章节最后一页。 - */ - @Test - public void pagedNovelProgressMustUseCurrentSubPage() throws Exception { - String source = read("app/src/main/assets/reader.html"); - - int effective = source.indexOf("function effectiveAnchorIndex()"); - int pageGuard = source.indexOf("if(pageMode) return idx;", effective); - int documentEnd = source.indexOf("if(anchorsSettled() && atDocumentEnd()) return total - 1;", effective); - - assertTrue("paged novels must bypass the document-end completion shortcut", - effective >= 0 && pageGuard > effective && documentEnd > pageGuard); - assertTrue("paged novel progress must still be saved through the effective anchor", - source.contains("AndroidReader.saveProgress(DATA.current|0, ch.url || '', ch.name || DATA.title || '', effectiveAnchorIndex(), anchorTotal());")); - } - - /** - * 迟到的切章结果不能重新拉起已关闭的阅读器。 - * - * 1500ms 静默期只挡得住紧随返回的那一拨回调;用户点了下一章又马上返回时, - * 爬虫可能几秒后才回,这条结果落在窗口外就会另起一个阅读器压在宿主上面, - * 表现仍然是返回键无效。用关闭代号比对才能识别它属于已经关掉的那个阅读器。 - */ - @Test - public void staleChapterResultDoesNotRelaunchClosedReader() throws Exception { - String router = read("app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java"); - String reader = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - // 只数在途请求、不认身份:结果送达那一刻拿不到「这是哪一章的」, - // 按令牌 / 按最早 / 整表清空都会删错条目,反而放行别人的迟到结果。 - assertTrue("in-flight host requests must be counted", - router.contains("inFlightChapters.incrementAndGet();") - && router.contains("public static void endChapterRequest()")); - // 关闭那一刻在途的请求,其结果逐个拦下;没有在途请求时不留额度, - // 否则会无条件吞掉关闭后的第一次合法打开 - assertTrue("closing must convert in-flight requests into suppression credits", - router.contains("int pending = inFlightChapters.getAndSet(0);") - && router.contains("if (pending > 0) {")); - assertTrue("credits must accumulate and expire so silent failures cannot swallow opens forever", - router.contains("staleChapterResults.addAndGet(pending);") - && router.contains("staleUntil = Math.max(staleUntil, readerClosedAt + PENDING_CHAPTER_TTL);") - && router.contains("if (android.os.SystemClock.elapsedRealtime() > staleUntil) {")); - assertEquals("every relaunch site must consult the suppression guard", - 3, countOccurrences(router, "if (shouldSuppressRelaunch())")); - assertTrue("the guard must combine the silence window and the stale check", - router.contains("return justClosed() || stale;")); - // 两处宿主派发都要记账,漏一处整套判定就失效 - assertEquals("both host paths must register their request", - 2, countOccurrences(reader, "NovelRouter.noteChapterRequest();")); - // 宿主找不到章节时会静默返回,必须立刻收尾而不是等 45s 过期 - assertEquals("a dispatch that never happened must close out immediately", - 2, countOccurrences(reader, "if (!h.labPlayEpisode(chapterUrl)) chapterFailedWithToast(true);")); - } - - /** - * 阅读进度落库不需要数据迁移,且读完即 100%。 - * - * 历史列表按 position/duration 画进度条,若把「读到最后一个锚点」也存成序号, - * 2 页的漫画短章读完只显示 50%;但若整体改成 1 基,升级前写入的存量记录会被 - * 统一平移一个锚点。折中:只把「读完」编码为 duration,其余沿用序号原值。 - */ - @Test - public void readingProgressKeepsLegacyRowsAndStillReachesFullBar() throws Exception { - String history = read("app/src/main/java/com/fongmi/android/tv/ui/novel/ReaderHistory.java"); - String reader = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - // 非读完的锚点原样存序号 —— 存量 0 基记录读回来不偏移 - assertTrue("a mid-chapter anchor must be stored as-is so legacy rows still resolve", - history.contains("long value = Math.max(0, Math.min(duration - 1, anchor));")); - assertTrue("only the finished state is encoded as duration", - history.contains("return duration == SCALE ? duration - 1 : duration;")); - assertTrue("the finished encoding must convert back to the last anchor", - history.contains("if (position >= duration) return (int) (duration - 1);")); - assertTrue("restore must go through the converter", - reader.contains("ReaderHistory.toAnchor(h.getPosition(), restoreTotal)")); - // 旧版小说记录存的是百分比×SCALE,不能走锚点换算 - assertTrue("legacy percent records must bypass the anchor conversion", - reader.contains("restoreTotal == ReaderHistory.SCALE")); - // 百分比分支必须解锁 restoringPage,否则整个会话都不再上报,旧记录也永远迁不到新语义 - String html = read("app/src/main/assets/reader.html"); - int percentFn = html.indexOf("function restoreScrollPercent(p)"); - assertTrue("restoreScrollPercent must exist", percentFn > 0); - assertTrue("the legacy percent path must release the reporting lock", - html.indexOf("restoringPage = false;", percentFn) > percentFn - && html.indexOf("restoringPage = false;", percentFn) < html.indexOf("function restoreAnchor(index)")); - // max>0 挡不住:min-height:105vh 让它首帧就成立,上报会把 90% 的进度覆盖成章首 - // anchorsSettled() 对小说恒为真(段落一次性建完),首帧就放行等于没有护栏; - // content-visibility 让屏外段落先按 30px 估算,此刻 scrollHeight 还会长大 - // 小说的 content-visibility 估算高度在滚动前不变,「高度稳定」对它是空护栏; - // 判据必须是「滚过去之后换算回来的锚点也稳定」 - assertTrue("the percent restore must converge on the resolved anchor", - html.contains("if(!settled || lastMax === null || lastMax !== idx) stable = 0;")); - assertTrue("it must require three stable measurements like restoreAnchor", - html.contains("var ready = stable >= 3;")); - assertTrue("the percent restore must be invalidated when content is replaced", - html.contains("var gen = ++restoreGen;") && html.contains("restoringPage = true;")); - // 短章内容一屏放得下时高度一开始就稳定,滚到 min-height 撑出的 5vh 后锚点仍是 0, - // 盲目上报会把用户存的 90% 覆盖成章首 - assertTrue("migrating a percent record must not move the position backwards", - html.contains("if(effectiveAnchorIndex() >= expected) reportProgress();")); - // 漫画一批批懒加载,光等不会让剩下的页进 DOM,anchorsSettled 永远为假 —— - // 旧百分比记录既恢复不了也迁移不了,必须主动催加载 - assertTrue("the percent restore must drive lazy loading for comics and PDFs", - html.contains("if(DATA.kind === 3) renderPdfMore();") - && html.contains("else if(DATA.kind !== 1) loadMoreComic();")); - assertTrue("the percent restore budget must use the monotonic clock", - html.contains("var deadline = nowMs() + 60000;")); - } - - /** - * 换章必须清掉锚点记忆。 - * - * PDF 章在 pdfDoc 解析完成前 anchorTotal() 为 0,effectiveAnchorIndex() 会在 - * total<=0 处早退,不经过 currentAnchorIndex() 的归零短路 —— 上一章的高位值 - * 会残留,把新章第 1 页记成上一章的位置。 - */ - @Test - public void switchingChapterResetsTheAnchorMemo() throws Exception { - String html = read("app/src/main/assets/reader.html"); - - int render = html.indexOf("function renderContent()"); - int reset = html.indexOf("lastAnchorIndex = 0;", render); - int clear = html.indexOf("r.innerHTML = '';", render); - - assertTrue("renderContent must exist", render > 0); - assertTrue("the memo must be cleared when the content is replaced", - reset > render && reset < clear); - } - - /** - * 关闭静默期与在途标记的时限都必须用单调时钟。 - * - * wall clock 会被 NTP 校正或用户改时间往回跳,一旦往回跳,「现在 - 关闭时刻」 - * 变成大负数而恒小于窗口,静默期就永不结束,之后所有阅读打开都被当成残留回调拦掉。 - */ - @Test - public void relaunchGuardsUseMonotonicClock() throws Exception { - String router = read("app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java"); - - assertFalse("the silence window must not depend on wall-clock time", - router.contains("readerClosedAt = System.currentTimeMillis();")); - assertFalse("the pending-tag TTL must not depend on wall-clock time", - router.contains("pendingChapterAt = System.currentTimeMillis();")); - assertTrue("every timestamp and comparison must use elapsedRealtime", - countOccurrences(router, "android.os.SystemClock.elapsedRealtime()") >= 3); - } - - /** - * 只撤销自己发出的那次在途标记。 - * - * chapterFailed() 还会被空 URL、注入异常等与宿主请求无关的路径调用, - * 无条件撤销会把另一次仍在途的请求的标记抹掉,重新打开「返回键失效」的缺口。 - */ - @Test - public void onlyTheOwnHostRequestAbandonsItsPendingTag() throws Exception { - String reader = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - assertTrue("the reader must track how many of its requests are in flight", - reader.contains("hostChapterRequests = new java.util.concurrent.atomic.AtomicInteger()")); - // 只有自己确实发过请求才收尾:空 URL、注入异常与任何请求无关, - // 替别人收尾会让那一笔的迟到结果不再被拦(返回键失效) - assertTrue("closing out must be gated on owning a request", - reader.contains("if (ownHostRequest) endHostChapterRequest();")); - assertTrue("the empty-url path must not close out anyone else", - reader.contains("chapterFailed(false); return;")); - assertTrue("the reader must only decrement what it actually holds", - reader.contains("if (hostChapterRequests.getAndUpdate(n -> n > 0 ? n - 1 : 0) > 0) {")); - } - - /** - * 切章成功送达前台后必须清掉在途标记。 - * - * 「送达前台阅读器」这条路径提前 return,不经过 shouldSuppressRelaunch(), - * 所以标记不会被 isStaleChapterResult() 顺手清掉。留着它的后果是: - * 用户关掉阅读器、过一会儿主动打开另一本书时,那次合法打开会被误判成过期结果吞掉。 - */ - @Test - public void deliveredChapterResultClearsThePendingTag() throws Exception { - String router = read("app/src/main/java/com/fongmi/android/tv/ui/novel/NovelRouter.java"); - String reader = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - // 送达要收尾,但 router 侧不能自作主张删条目 —— 它不知道是哪一笔 - assertFalse("the router must not guess which request was delivered", - router.contains(" clearChapterRequest();")); - assertTrue("delivery must close out exactly one in-flight request", - reader.contains("if (fromHost) endHostChapterRequest();")); - // 自解析路径没发过请求,不能替宿主收尾 - assertTrue("the self-resolve path must not close out a host request", - reader.contains("onEpisodeResolved(fk, fp, at >= 0 ? chapters.get(at).getName() : \"\", false);")); - } - - /** - * 阅读器回到前台必须重新登记。 - * - * onPause 交还前台时会清掉注册,只在 onCreate 注册的话,两个阅读器叠栈时 - * 下层那个再次回到前台就永久失去注册,之后它自己的切章会另起一个实例压在自己上面。 - */ - @Test - public void readerReRegistersOnResume() throws Exception { - String source = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - int resume = source.indexOf("protected void onResume()"); - int register = source.indexOf("NovelRouter.currentReader = this;", resume); - int pause = source.indexOf("protected void onPause()"); - - assertTrue("onResume must exist", resume > 0); - assertTrue("onResume must re-register the reader", register > resume && register < pause); - } - - private static int countOccurrences(String text, String needle) { - int count = 0; - int index = 0; - while ((index = text.indexOf(needle, index)) >= 0) { - count++; - index += needle.length(); - } - return count; - } - private static String read(String path) throws Exception { Path direct = Path.of(path); if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/SpeechAdSettingSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/SpeechAdSettingSourceTest.java new file mode 100644 index 0000000000..a4427ef3d0 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/SpeechAdSettingSourceTest.java @@ -0,0 +1,55 @@ +package com.fongmi.android.tv.ui.activity; + +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class SpeechAdSettingSourceTest { + @Test + public void leanbackExposesAllSpeechAdControls() throws Exception { + String java = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java"); + String xml = read("app/src/leanback/res/layout/activity_setting_ad.xml"); + String presenter = read("app/src/main/java/com/fongmi/android/tv/ui/dialog/AdSkipPromptPresenter.java"); + assertTrue(xml.contains("@+id/speechAdEnabled")); + assertTrue(xml.contains("@+id/speechAdKeywords")); + assertTrue(xml.contains("@+id/speechAdSkipSeconds")); + assertTrue(xml.contains("@+id/speechAdSkipMode")); + assertTrue(java.contains("SpeechAdSetting.setEnabled")); + assertTrue(java.contains("SpeechAdSetting.setKeywords")); + assertTrue(java.contains("SpeechAdSetting.setSkipSeconds")); + assertTrue(java.contains("SpeechAdSetting.setMode")); + assertTrue(java.contains("reloadAdAudioSettings")); + assertTrue(presenter.contains("SpeechAdSignalProvider.RULE_ID")); + assertTrue(presenter.contains("ad_audio_speech_candidate_message")); + assertTrue(!presenter.contains("prompt.ruleId(), prompt.skipDurationSeconds()")); + } + + @Test + public void mobileExposesAllSpeechAdControls() throws Exception { + String java = read("app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAdFragment.java"); + String xml = read("app/src/mobile/res/layout/fragment_setting_ad.xml"); + assertTrue(xml.contains("@+id/speechAdEnabled")); + assertTrue(xml.contains("@+id/speechAdKeywords")); + assertTrue(xml.contains("@+id/speechAdSkipSeconds")); + assertTrue(xml.contains("@+id/speechAdSkipMode")); + assertTrue(xml.contains("@string/speech_ad_enabled")); + assertTrue(xml.contains("@string/speech_ad_keywords")); + assertTrue(xml.contains("@string/speech_ad_skip_seconds")); + assertTrue(xml.contains("@string/speech_ad_skip_mode")); + assertTrue(java.contains("SpeechAdSetting.setEnabled")); + assertTrue(java.contains("SpeechAdSetting.setKeywords")); + assertTrue(java.contains("SpeechAdSetting.setSkipSeconds")); + assertTrue(java.contains("SpeechAdSetting.setMode")); + assertTrue(java.contains("reloadAdAudioSettings")); + } + private static String read(String path) throws Exception { + Path direct = Path.of(path); + if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); + String modulePath = path.startsWith("app/") ? path.substring(4) : path; + return Files.readString(Path.of(modulePath), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityHantMatchTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityHantMatchTest.java deleted file mode 100644 index 3c6ba01f1d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityHantMatchTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 详情页的 TMDB 匹配必须先在比较前把简繁字形压到同一侧。 - * - *

根因:站源标题在繁体界面会被 {@code Vod.trans()} 转成繁体,而 TMDB 返回的标题字形由 - * {@code TmdbConfig.language}(默认 zh-CN)决定,是简体。详情页原来自己有一套 - * {@code normalize()},只做标点/大小写归一,于是「喜劇之王 vs 喜剧之王」逐字符比较必然失败, - * 日志表现为「候选过滤:标题不一致」刷屏后放弃匹配。 - * - *

同时这里锁住两条边界:噪声词表与类型关键词改成「归一后 + 单套繁体词表」, - * 而归一化结果绝不能被当成搜索关键词发给 TMDB(实测会把召回打成 0)。 - */ -public class TmdbDetailActivityHantMatchTest { - - @Test - public void normalizeAppliesScriptCanonicalization() throws Exception { - String source = readMainActivity(); - - assertTrue(source.contains("return plainNormalize(canonicalTitle(text));")); - assertTrue("归一必须显式传 pass=false,否则简体态会短路成 no-op", - source.contains("Trans.s2t(false, Objects.toString(text, \"\"))")); - } - - @Test - public void noiseTagDetectionCoversTraditionalTags() throws Exception { - String source = readMainActivity(); - String method = method(source, "private boolean isTmdbNoiseTag(String text)", "private Flag findInitialFlag"); - - assertTrue("标签判定要走归一后的字形", method.contains("String value = normalize(text);")); - assertTrue("繁体标签「高碼」必须被识别", method.contains("高碼")); - assertTrue("繁体标签「無水印」必须被识别", method.contains("無水印")); - assertTrue("繁体标签「未刪減」必须被识别", method.contains("未刪減")); - } - - @Test - public void searchQueryCleanupStripsTraditionalNoiseTags() throws Exception { - String source = readMainActivity(); - String query = method(source, "private String cleanTmdbSearchQuery(String text)", "private String removeNoiseBrackets"); - - assertTrue("清洗词表要同时覆盖繁体写法,否则繁体标题的标签会混进搜索词", query.contains("高碼")); - assertTrue(query.contains("無水印")); - assertTrue(query.contains("連載至") || query.contains("連載")); - } - - @Test - public void mediaTypeScoringMatchesBothScripts() throws Exception { - String source = readMainActivity(); - String method = method(source, "private int scoreTmdbMediaType", "private int scoreTmdbPeople(TmdbItem item"); - - assertTrue("类型判定要用归一后的字形", method.contains("normalize(sourceVod.getTypeName()")); - assertTrue("必须认得繁体「電視劇」", method.contains("電視劇")); - assertTrue("必须认得繁体「電影」", method.contains("電影")); - } - - @Test - public void normalizedTextIsNeverUsedAsTmdbQueryWord() throws Exception { - String source = readMainActivity(); - - assertFalse("归一化产物只能用于比较,不能当查询词", source.contains("tmdbService.search(normalize(")); - assertFalse(source.contains("tmdbService.search(canonicalTitle(")); - } - - private static String readMainActivity() throws Exception { - return readMain(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); - } - - private static String method(String source, String startMarker, String endMarker) { - int start = source.indexOf(startMarker); - int end = source.indexOf(endMarker, start); - assertTrue("missing method " + startMarker, start >= 0 && end > start); - return source.substring(start, end); - } - - private static String readMain(Path relative) throws Exception { - return Files.readString(root().resolve("app").resolve("src").resolve("main").resolve("java").resolve(relative), StandardCharsets.UTF_8); - } - - private static Path root() { - return Files.exists(Path.of("app", "src", "main")) ? Path.of(".") : Path.of(".."); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayTransitionSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayTransitionSourceTest.java deleted file mode 100644 index bb0da4f6a4..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayTransitionSourceTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -public class TmdbDetailDirectPlayTransitionSourceTest { - - private static final Path SOURCE = Paths.get("src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); - - @Test - public void firstDirectPlayEntersFullscreenBeforeStartingPlayback() throws Exception { - String source = Files.readString(SOURCE, StandardCharsets.UTF_8); - String playMethod = methodBody(source, "private void playDetailFullscreen()"); - - int enterFullscreen = playMethod.indexOf("enterInlineFullscreen();"); - int startPlayback = playMethod.indexOf("if (!current) playInline();"); - assertTrue(enterFullscreen >= 0); - assertTrue(startPlayback > enterFullscreen); - assertTrue(playMethod.contains("detailPlayerFullscreenPending = false;")); - assertFalse(playMethod.contains("detailPlayerFullscreenPending = !current;")); - assertFalse(playMethod.contains("revealDetailPlayerFullscreen();")); - assertTrue(source.contains("protected void onFirstFrameRendered()")); - } - - @Test - public void closingDirectPlayClearsPendingTransitionState() throws Exception { - String source = Files.readString(SOURCE, StandardCharsets.UTF_8); - String closeMethod = methodBody(source, "private void closeDetailFullscreenPlayer()"); - - assertTrue(closeMethod.contains("detailPlayerFullscreenPending = false;")); - assertTrue(closeMethod.contains("inlinePlaybackGeneration++;")); - assertTrue(closeMethod.contains("currentInlineResult = null;")); - } - - @Test - public void directPlaybackSnapshotUsesLanguageIdentityAndUnifiedOverviewPolicy() throws Exception { - String source = Files.readString( - Paths.get("src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"), StandardCharsets.UTF_8); - - assertTrue(source.contains("TmdbDetailCache.take(getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), getTmdbItem(), currentTmdbLanguage())")); - assertFalse(source.contains("TmdbDetailCache.take(getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), getTmdbItem())")); - assertTrue(source.contains("translatedOverview(detail, currentTmdbConfig())")); - assertFalse(source.contains("cachedTmdbOverviewForLanguage(translations, \"zh-CN\")")); - } - - /** - * 被作废的取流请求必须留着释放路径,不能留下永久加载标记。 - * - *

三条作废路径都没有结果回调可用来收圈:换内核/打开外部播放器会自增代际并取消任务 - * (回调可能根本不执行),选择面变更(详情重载/外部播放返回)会让回调因请求失效直接返回。 - * 标记残留会让详情页加载圈永久留在屏上(见 {@code updateInlineLoading} 的首个条件), - * 并让 {@code isSamePendingInlinePlayback} 恒真、同一集无法再次起播。 - */ - @Test - public void cancellingAnInlinePlaybackRequestReleasesItsLoadingFlag() throws Exception { - String source = Files.readString(SOURCE, StandardCharsets.UTF_8); - - // 自增代际、不接替请求的两处:必须在投递新请求之前同步释放 - String switchMethod = methodBody(source, "private boolean refreshAndSwitchInlinePlayer(int playerType)"); - assertFalse("refreshAndSwitchInlinePlayer must exist", switchMethod.isEmpty()); - int switchGeneration = switchMethod.indexOf("++inlinePlaybackGeneration"); - int switchRelease = switchMethod.indexOf("releaseInlinePlaybackPending();"); - int switchSubmit = switchMethod.indexOf("detailTasks.submit("); - assertTrue("refreshAndSwitchInlinePlayer must release the loading flag of the request it invalidates", - switchGeneration >= 0 && switchRelease > switchGeneration); - assertTrue("the release must happen synchronously, before the replacement request is submitted", - switchSubmit > switchRelease); - - String cancelMethod = methodBody(source, "private void cancelPendingInlinePlayerSwitch()"); - assertFalse("cancelPendingInlinePlayerSwitch must exist", cancelMethod.isEmpty()); - assertTrue("cancelPendingInlinePlayerSwitch must release the loading flag of the request it invalidates", - cancelMethod.contains("inlinePlaybackGeneration++;") - && cancelMethod.contains("releaseInlinePlaybackPending();")); - - // 选择面变更不自增代际,只能由回调按归属释放 - String playMethod = methodBody(source, "private void playInline(long resumePosition, String failedUrl, String failureMessage)"); - assertFalse("playInline must exist", playMethod.isEmpty()); - int markGeneration = playMethod.indexOf("++inlinePlaybackGeneration"); - assertTrue("playInline must record the generation that owns the pending flag", - markGeneration >= 0 - && playMethod.indexOf("inlinePlaybackPendingGeneration = generation;", markGeneration) > markGeneration); - int firstStaleRelease = playMethod.indexOf("releaseInlinePlaybackPending(generation);"); - assertTrue("both stale branches (success and failure) must release the flag they own", - firstStaleRelease >= 0 - && playMethod.indexOf("releaseInlinePlaybackPending(generation);", firstStaleRelease + 1) > firstStaleRelease); - - String ownedRelease = methodBody(source, "private void releaseInlinePlaybackPending(int generation)"); - assertFalse("the owned release must exist", ownedRelease.isEmpty()); - assertTrue("the owned release must refuse to clear a newer request's flag", - ownedRelease.contains("inlinePlaybackPendingGeneration != generation")); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - if (start < 0) return ""; - int brace = source.indexOf('{', start); - int depth = 0; - for (int i = brace; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - if (c == '}' && --depth == 0) return source.substring(start, i + 1); - } - return ""; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDisambiguationPerfSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDisambiguationPerfSourceTest.java deleted file mode 100644 index 02ba9ed8d7..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDisambiguationPerfSourceTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -/** - * 锁定 TMDB 详情加载的两项性能契约(不涉及判定阈值): - *

    - *
  1. 自动匹配消歧只拉 lite 详情(credits/aggregate_credits)并有界并行;赢家才回到完整详情;
  2. - *
  3. rail 缩略图走官方小档位,hero/viewer/保存仍拿 original。
  4. - *
- */ -public class TmdbDetailDisambiguationPerfSourceTest { - - private static final Path SOURCE = - Paths.get("src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); - - @Test - public void disambiguationFetchesLiteDetailsConcurrentlyAndUpgradesOnlyTheWinner() throws Exception { - String source = Files.readString(SOURCE, StandardCharsets.UTF_8); - - assertTrue("bounded concurrency constant must exist", - source.contains("private static final int DISAMBIGUATION_CONCURRENCY = 3;")); - - String fetch = methodBody(source, "private JsonObject[] fetchDisambiguationDetails("); - assertFalse("fetch helper must exist", fetch.isEmpty()); - assertTrue("candidate details must come from the lite endpoint", - fetch.contains("detailForDisambiguation(item, tmdbConfig)")); - assertTrue("candidate fetches must be bounded to the concurrency window", - fetch.contains("start += DISAMBIGUATION_CONCURRENCY")); - assertFalse("candidate fetches must not load the full bundle", fetch.contains("loadTmdbBundle(")); - - String strict = methodBody(source, "private TmdbBundle chooseTmdbBundle("); - assertFalse("chooseTmdbBundle must exist", strict.isEmpty()); - assertFalse("strict disambiguation must not fetch full details per candidate", strict.contains("loadTmdbBundle(")); - assertTrue(strict.contains("fetchDisambiguationDetails(candidates, count)")); - assertTrue(strict.contains("loadDisambiguationWinner(best.item)")); - - String smart = methodBody(source, "private TmdbBundle chooseSmartTmdbBundle("); - assertFalse("chooseSmartTmdbBundle must exist", smart.isEmpty()); - assertFalse("smart disambiguation must not fetch full details per candidate", smart.contains("loadTmdbBundle(")); - assertTrue(smart.contains("fetchDisambiguationDetails(candidates, count)")); - assertTrue(smart.contains("loadDisambiguationWinner(best.item)")); - - String upgrade = methodBody(source, "private TmdbBundle loadDisambiguationWinner("); - assertFalse("winner upgrade helper must exist", upgrade.isEmpty()); - assertTrue("the winner must be upgraded to the full detail bundle", upgrade.contains("loadTmdbBundle(item)")); - } - - @Test - public void railsUseOfficialSmallTiersWhileHeroViewerAndSavingKeepOriginal() throws Exception { - String source = Files.readString(SOURCE, StandardCharsets.UTF_8); - - String posters = methodBody(source, "private void setRailPosters("); - assertFalse("setRailPosters must exist", posters.isEmpty()); - assertTrue(posters.contains("TmdbImageSelector.posterTier(url)")); - - String photos = methodBody(source, "private void setRailPhotos("); - assertFalse("setRailPhotos must exist", photos.isEmpty()); - assertTrue(photos.contains("TmdbImageSelector.photoTier(url)")); - - String bundle = methodBody(source, "private void applyTmdbBundle("); - assertTrue("detail bundle must route rails through the tiering helpers", - bundle.contains("setRailPosters(") && bundle.contains("setRailPhotos(")); - - String initialArtwork = methodBody(source, "private void bindInitialArtwork("); - assertTrue("initial hero poster must use the tiered URL", initialArtwork.contains("heroPosterTier(")); - assertFalse("initial hero poster must not download the original", - initialArtwork.contains("TmdbImageSelector.originalUrl(")); - - String posterUrl = methodBody(source, "private String tmdbPosterUrl("); - assertTrue("hero poster URL must be tiered", posterUrl.contains("heroPosterTier(")); - - String highRes = methodBody(source, "private String highResTmdbImage("); - assertTrue("fullscreen viewer/save path must keep upgrading to original", - highRes.contains("TmdbImageSelector.originalUrl(url)")); - - String viewer = methodBody(source, "private void loadPhotoImage("); - assertTrue("photo viewer must still request the original", viewer.contains("highResTmdbImage(url)")); - assertTrue("saving must still request the original", - source.contains("TmdbImageSaver.save(this, highResTmdbImage(url)")); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - if (start < 0) return ""; - int brace = source.indexOf('{', start); - int depth = 0; - for (int i = brace; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - if (c == '}' && --depth == 0) return source.substring(start, i + 1); - } - return ""; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailGenerationTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailGenerationTest.java deleted file mode 100644 index 52cb477f90..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailGenerationTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertTrue; - -public class TmdbDetailGenerationTest { - - @Test - public void staleSourcePayloadNetworkResultCannotUpdateNewPage() throws Exception { - String body = loadContentBody(); - int request = body.indexOf("tmdbService.detailForSource(initialBundle.item(), sourcePayload.getSeasonNumber(), tmdbConfig, plan.missing())"); - int staleCheck = body.indexOf("if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return;", request); - int apply = body.indexOf("runOnAliveUi(() -> {", staleCheck); - int identityCheck = body.indexOf("!isSameTmdbItem(mergedBundle.item(), matchedTmdbItem)", apply); - - assertTrue("network fill must be discarded when the page generation changed", staleCheck > request); - assertTrue("UI apply must recheck generation, loaded source and TMDB identity", apply > staleCheck && identityCheck > apply); - } - - private static String loadContentBody() throws Exception { - Path sourcePath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); - String source = Files.readString(sourcePath, StandardCharsets.UTF_8); - int start = source.indexOf("private void loadContent(@Nullable TmdbBundle reusableBundle)"); - int end = source.indexOf("private boolean shouldLoadInitialStandaloneTmdbDetailInSinglePass", start); - assertTrue(sourcePath + " is missing the loadContent method", start >= 0 && end > start); - return source.substring(start, end); - } - - private static Path findMainJavaPath() { - Path root = Files.exists(Path.of("src", "main")) ? Path.of(".") : Path.of("app"); - return root.resolve(Path.of("src", "main", "java")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailInlineOsdTitleTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailInlineOsdTitleTest.java deleted file mode 100644 index 862449c61d..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailInlineOsdTitleTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbDetailInlineOsdTitleTest { - - @Test - public void inlineOsdUsesCurrentEpisodeTitleInsteadOfSeasonProgress() throws IOException { - String source = Files.readString(Path.of("src", "main", "java", - "com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"), - StandardCharsets.UTF_8); - int start = source.indexOf("private String getInlineOsdTitle()"); - int end = source.indexOf("\n private ", start + 1); - - assertTrue(start >= 0 && end > start); - String method = source.substring(start, end); - assertTrue(method.contains("historyEpisodeTitle(selectedEpisode)")); - assertTrue(method.contains("getString(R.string.detail_title, name, episodeTitle)")); - assertFalse(method.contains("selectedEpisode.getName()")); - assertFalse(method.contains("tmdbEpisodeInfo().compactText")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPoolBlockingSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPoolBlockingSourceTest.java deleted file mode 100644 index 3e7ae56abe..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPoolBlockingSourceTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -/** - * 锁定 W2:详情页等待 TMDB 结果的阻塞段不得占用共享 5 线程池。 - * - *

{@code Task.executor()} 只有 5 个线程且被首页、搜索、站点加载等共用;详情页一次等待可达 - * 1–3 秒。协调任务只负责发起请求并派发收尾任务,真正阻塞在 {@code tmdbFuture.get()} 的 - * {@code completeTmdbLoad} 必须提交到 {@code Task.largeExecutor()}。 - */ -public class TmdbDetailPoolBlockingSourceTest { - - private static final Path SOURCE = - Paths.get("src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); - - @Test - public void coordinatorDoesNotBlockTheSharedPoolOnTheTmdbFuture() throws Exception { - String source = Files.readString(SOURCE, StandardCharsets.UTF_8); - String loadContent = methodBody(source, "private void loadContent("); - - assertFalse("loadContent must exist", loadContent.isEmpty()); - assertFalse("the coordinator must not wait on the TMDB future itself", - loadContent.contains("tmdbFuture.get()")); - assertTrue("the waiting continuation must be dispatched to the large executor", - loadContent.contains("detailTasks.submit(Task.largeExecutor(),")); - assertTrue("the dispatched continuation must be completeTmdbLoad", - loadContent.contains("completeTmdbLoad(generation, mode, finalVod, finalError, singlePassStandaloneTmdb, tmdbFuture, decision, loadStart)")); - } - - @Test - public void continuationKeepsGenerationAndCredentialGuards() throws Exception { - String source = Files.readString(SOURCE, StandardCharsets.UTF_8); - String complete = methodBody(source, "private void completeTmdbLoad("); - - assertFalse("completeTmdbLoad must exist", complete.isEmpty()); - assertTrue("the continuation owns the blocking wait", complete.contains("tmdbFuture.get()")); - assertTrue("every UI handoff must still check the load generation", - count(complete, "generation != loadGeneration") >= 4); - assertTrue("the single-pass branch must still preload the initial season", - complete.contains("preloadInitialStandaloneSeason(loadedResult, finalVod)")); - assertTrue("the failure path must still fall back to the source detail", - complete.contains("tmdb wait failed")); - } - - private static int count(String text, String token) { - int total = 0; - for (int i = text.indexOf(token); i >= 0; i = text.indexOf(token, i + token.length())) total++; - return total; - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - if (start < 0) return ""; - int brace = source.indexOf('{', start); - int depth = 0; - for (int i = brace; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - if (c == '}' && --depth == 0) return source.substring(start, i + 1); - } - return ""; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPrefetchWiringSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPrefetchWiringSourceTest.java deleted file mode 100644 index 62fb69b087..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPrefetchWiringSourceTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -/** - * 锁定 TMDB 详情页的 W1 契约(`docs/C16-tmdb-source-detail-contract.md` §6.3): - * identity 已知时 TMDB 详情在站点详情之前并行预热;预热结果只在需要 TMDB 详情的路径被消费, - * 源 payload 路径丢弃;页面销毁/重载/换源时取消单槽。 - */ -public class TmdbDetailPrefetchWiringSourceTest { - - private static final Path SOURCE = - Paths.get("src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); - - @Test - public void tmdbDetailPrefetchStartsBeforeTheSiteDetailRequest() throws Exception { - String source = Files.readString(SOURCE, StandardCharsets.UTF_8); - String loadContent = methodBody(source, "private void loadContent("); - - int startPrefetch = loadContent.indexOf("startTmdbDetailPrefetch(initialTmdbItem, reusableBundle);"); - int submit = loadContent.indexOf("detailTasks.submit("); - assertTrue("prefetch start must exist", startPrefetch >= 0); - assertTrue("prefetch must start before the worker that performs SiteApi.detailContent", submit > startPrefetch); - - String starter = methodBody(source, "private void startTmdbDetailPrefetch("); - assertFalse("startTmdbDetailPrefetch must exist", starter.isEmpty()); - assertTrue("must not prefetch without a known identity or with a reusable bundle", - starter.contains("if (item == null || reusableBundle != null) return;")); - assertTrue("must require a ready TMDB config", starter.contains("!config.isReady()")); - assertTrue("must respect the per-site TMDB switch", starter.contains("isTmdbAllowedForCurrentSite()")); - assertTrue("prefetch must use the shared single-slot prefetch", - starter.contains("detailPrefetch.start(item,")); - assertTrue("prefetch must load the detail by identity", starter.contains("tmdbService.detail(item, config)")); - } - - @Test - public void identityPathConsumesThePrefetchedDetailInsteadOfRequestingAgain() throws Exception { - String source = Files.readString(SOURCE, StandardCharsets.UTF_8); - String loadResult = methodBody(source, "private TmdbLoadResult loadTmdbResult()"); - - assertTrue("identity path must consume the prefetch", - loadResult.contains("tmdbBundle = loadPrefetchedTmdbBundle(initialTmdbItem);")); - assertFalse("identity path must not issue a second detail request", - loadResult.contains("loadTmdbBundle(initialTmdbItem)")); - - String consume = methodBody(source, "private TmdbBundle loadPrefetchedTmdbBundle("); - assertFalse("loadPrefetchedTmdbBundle must exist", consume.isEmpty()); - assertTrue("must take the single-slot future for this identity", - consume.contains("detailPrefetch.take(item)")); - assertTrue("missing/unusable prefetch must fall back to the regular fetch", - consume.contains("return loadTmdbBundle(item);")); - assertTrue("prefetched details must be assembled by the shared bundle builder", - consume.contains("toTmdbBundle(")); - - String bundle = methodBody(source, "private TmdbBundle loadTmdbBundle("); - assertTrue("regular fetch must delegate to the shared bundle builder", - bundle.contains("toTmdbBundle(item, tmdbService.detail(item, tmdbConfig))")); - } - - @Test - public void sourcePayloadPathDiscardsThePrefetchAndLifecycleCancelsIt() throws Exception { - String source = Files.readString(SOURCE, StandardCharsets.UTF_8); - String loadContent = methodBody(source, "private void loadContent("); - - int sourceBranch = loadContent.indexOf("if (sourceBundle != null) {"); - int cancel = loadContent.indexOf("detailPrefetch.cancel();", sourceBranch); - int merge = loadContent.indexOf("TmdbSourceMerger.merge(sourceBundle, sourcePayload, reusableBundle", sourceBranch); - assertTrue("source payload branch must exist", sourceBranch >= 0); - assertTrue("source payload path must discard the prefetch", cancel > sourceBranch); - assertTrue("source payload path must not consume the prefetch before merging", merge > cancel); - - String destroy = methodBody(source, "protected void onDestroy()"); - assertTrue("onDestroy must cancel the in-flight prefetch", destroy.contains("detailPrefetch.cancel();")); - assertTrue("every load must reset the single slot before starting", - loadContent.indexOf("detailPrefetch.cancel();") < loadContent.indexOf("startTmdbDetailPrefetch(initialTmdbItem, reusableBundle);")); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - if (start < 0) return ""; - int brace = source.indexOf('{', start); - int depth = 0; - for (int i = brace; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - if (c == '}' && --depth == 0) return source.substring(start, i + 1); - } - return ""; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourceOnlyWiringTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourceOnlyWiringTest.java deleted file mode 100644 index 205a1d1d9f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourceOnlyWiringTest.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbDetailSourceOnlyWiringTest { - - @Test - public void standaloneRoutesUseConfiguredModeInsteadOfKeyReadiness() throws Exception { - String mobile = read("mobile", Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); - String leanback = read("leanback", Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); - - for (String source : new String[]{mobile, leanback}) { - String method = source.substring(source.indexOf("private static boolean shouldOpenLegacyTmdbDetail"), - source.indexOf("public static void startDirectResolved", source.indexOf("private static boolean shouldOpenLegacyTmdbDetail"))); - assertTrue(method.contains("Setting.isTmdbDetailModeConfigured()")); - assertFalse(method.contains("Setting.isTmdbDetailPage()")); - } - } - - @Test - public void loadContentResolvesRuntimePolicyBeforeCreatingNetworkTasks() throws Exception { - String source = readMainActivity(); - String load = method(source, "private void loadContent(@Nullable TmdbBundle reusableBundle)", "private boolean shouldLoadInitialStandaloneTmdbDetailInSinglePass"); - - int classify = load.indexOf("TmdbSourceAvailability.classify(finalVod, sourcePayload, sourceBundle)"); - int resolve = load.indexOf("DetailRuntimeModePolicy.resolve(new DetailRuntimeModePolicy.Input"); - int direct = load.indexOf("decision.runtimeMode() == Setting.DETAIL_OPEN_DIRECT"); - int sourceOnly = load.indexOf("if (!decision.networkAllowed()) return;"); - int future = load.indexOf("Future tmdbFuture = decision.networkAllowed()"); - - assertTrue("source state must be classified from normalized payload", classify >= 0 && resolve > classify); - assertTrue("direct/source-only decisions must precede task creation", direct > resolve && sourceOnly > direct && future > sourceOnly); - } - - @Test - public void sourceOnlyModeBlocksTmdbNetworkAndCacheWrites() throws Exception { - String source = readMainActivity(); - - assertTrue(source.contains("private boolean isTmdbNetworkAllowed()")); - assertTrue(source.contains("if (bundle != null && isTmdbNetworkAllowed()) saveTmdbMatch(bundle.item());")); - assertTrue(source.contains("if (isTmdbNetworkAllowed()) loadTmdbMediaBlocks(bundle);")); - assertTrue(source.contains("if (!isTmdbNetworkAllowed()) return;")); - String relatedVideos = method(source, "private void loadRelatedVideosForCurrentContext", "private void bindTmdbSection"); - assertTrue(relatedVideos.contains("if (isTmdbSourceOnly()) {")); - assertTrue(relatedVideos.contains("TmdbSourceAdapter.videos(")); - assertTrue(relatedVideos.contains("if (!isTmdbNetworkAllowed() || !tmdbConfig.isReady()) return;")); - assertTrue(source.contains("if (isTmdbNetworkAllowed()) {") || source.contains("if (!isTmdbNetworkAllowed()) {")); - } - - @Test - public void directFallbackReusesLoadedVodWithAnExplicitRuntimeMode() throws Exception { - String detail = readMainActivity(); - String fallback = method(detail, "private void fallbackToOriginalDetail", "private boolean isTmdbNetworkAllowed"); - assertTrue(fallback.contains("VideoActivity.startDirectResolved(this, loadedVod);")); - assertTrue(fallback.contains("finish();")); - - for (String flavor : new String[]{"mobile", "leanback"}) { - String source = read(flavor, Path.of("com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); - String start = method(source, "public static void startDirectResolved", "public static void start(Activity activity, String url)"); - assertTrue(start.contains("EXTRA_DETAIL_RUNTIME_MODE, Setting.DETAIL_OPEN_DIRECT")); - assertTrue(start.contains("putDetailVodCache(intent, vod);")); - assertTrue(start.contains("intent.putExtra(\"collect\", false);")); - assertFalse(start.contains("auto_play")); - } - } - - private static String readMainActivity() throws Exception { - return readMain(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); - } - - private static String method(String source, String startMarker, String endMarker) { - int start = source.indexOf(startMarker); - int end = source.indexOf(endMarker, start); - assertTrue("missing method " + startMarker, start >= 0 && end > start); - return source.substring(start, end); - } - - private static String readMain(Path relative) throws Exception { - return Files.readString(root().resolve("app").resolve("src").resolve("main").resolve("java").resolve(relative), StandardCharsets.UTF_8); - } - - private static String read(String flavor, Path relative) throws Exception { - return Files.readString(root().resolve("app").resolve("src").resolve(flavor).resolve("java").resolve(relative), StandardCharsets.UTF_8); - } - - private static Path root() { - return Files.exists(Path.of("app", "src", "main")) ? Path.of(".") : Path.of(".."); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourcePayloadTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourcePayloadTest.java deleted file mode 100644 index 20fd2495b8..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourcePayloadTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertTrue; - -public class TmdbDetailSourcePayloadTest { - - @Test - public void sourcePayloadIsAppliedBeforeAnyTmdbRequest() throws Exception { - String body = loadContentBody(); - int source = body.indexOf("Result result = SiteApi.detailContent(key, id);"); - int parse = body.indexOf("TmdbSourcePayloadParser.parse(finalVod.getTmdb())"); - int apply = body.indexOf("applyLoaded(finalVod, initialBundle, new ArrayList<>(), finalError, false);"); - int plan = body.indexOf("TmdbSourceCapabilityPlanner.plan(initialBundle, sourcePayload"); - int zeroRequest = body.indexOf("if (!plan.hasInitialNetworkGaps()) return;"); - int request = body.indexOf("tmdbService.detailForSource(initialBundle.item(), sourcePayload.getSeasonNumber(), tmdbConfig, plan.missing())"); - - assertTrue("source detail must be fetched before payload parsing", source >= 0 && parse > source); - assertTrue("source bundle must be applied before the capability plan", apply > parse && plan > apply); - assertTrue("complete initial capabilities must return without a TMDB request", zeroRequest > plan && request > zeroRequest); - } - - @Test - public void partialPayloadUsesIdentityRequestAndFillOnlyMerge() throws Exception { - String body = loadContentBody(); - int request = body.indexOf("tmdbService.detailForSource(initialBundle.item(), sourcePayload.getSeasonNumber(), tmdbConfig, plan.missing())"); - int adapt = body.indexOf("TmdbSourceAdapter.fromNetwork(initialBundle.item(), detail, tmdbConfig)"); - int merge = body.indexOf("TmdbSourceMerger.fillOnly(initialBundle, sourcePayload, networkBundle"); - int apply = body.indexOf("applyTmdbResultNow(new TmdbLoadResult(mergedBundle, List.of()));", merge); - - assertTrue("missing capabilities must request by the embedded TMDB identity", request >= 0 && adapt > request); - assertTrue("network data must be merged fill-only and then applied", merge > adapt && apply > merge); - } - - @Test - public void legacyMatchingPathStartsOnlyAfterSourcePayloadDecision() throws Exception { - String body = loadContentBody(); - int sourcePayload = body.indexOf("if (sourceBundle != null)"); - int legacyFuture = body.indexOf("Future tmdbFuture = decision.networkAllowed()"); - - assertTrue("legacy title matching must not be scheduled before source/payload parsing", sourcePayload > 0 && legacyFuture > sourcePayload); - } - - @Test - public void delayedCapabilitiesReuseCompleteEmbeddedSourceData() throws Exception { - String source = loadActivitySource(); - - assertTrue("complete season groups must suppress season fetches", - source.contains("if (!refresh && hasCompleteSourceSeason(seasonNumber)) return;") - && source.contains("|| hasCompleteSourceSeason(firstSeason)) return;")); - assertTrue("complete episode groups must suppress episode detail requests", - source.contains("if (hasCompleteSourceEpisode(detailSeasonNumber, detailEpisodeNumber))")); - assertTrue("complete video groups must use the embedded resolver instead of the network service", - source.contains("boolean sourceComplete = hasCompleteSourceVideos(item, requestSeasonNumber, requestEpisodeNumber);") - && source.contains("? TmdbSourceAdapter.videos(sourceDetail, item.getMediaType(), requestSeasonNumber, requestEpisodeNumber, tmdbConfig.getLanguage())") - && source.contains(": tmdbService.relatedVideos(item, requestSeasonNumber, requestEpisodeNumber, tmdbConfig);")); - } - - @Test - public void sourceOnlyUsesEmbeddedVideosWithoutStartingNetworkAggregation() throws Exception { - String source = loadActivitySource(); - int start = source.indexOf("private void loadRelatedVideosForCurrentContext()"); - int end = source.indexOf("private void bindTmdbSection()", start); - assertTrue("missing source-only related video path", start >= 0 && end > start); - - String method = source.substring(start, end); - assertTrue(method.contains("if (isTmdbSourceOnly()) {")); - assertTrue(method.contains("TmdbSourceAdapter.videos(")); - assertTrue(method.indexOf("TmdbSourceAdapter.videos(") < method.indexOf("tmdbService.relatedVideos(")); - } - - private static String loadContentBody() throws Exception { - Path sourcePath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); - String source = Files.readString(sourcePath, StandardCharsets.UTF_8); - int start = source.indexOf("private void loadContent(@Nullable TmdbBundle reusableBundle)"); - int end = source.indexOf("private boolean shouldLoadInitialStandaloneTmdbDetailInSinglePass", start); - assertTrue(sourcePath + " is missing the loadContent method", start >= 0 && end > start); - return source.substring(start, end); - } - - private static String loadActivitySource() throws Exception { - Path sourcePath = findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); - String source = Files.readString(sourcePath, StandardCharsets.UTF_8); - assertTrue(sourcePath + " is missing the activity source", !source.isBlank()); - return source; - } - - private static Path findMainJavaPath() { - Path root = Files.exists(Path.of("src", "main")) ? Path.of(".") : Path.of("app"); - return root.resolve(Path.of("src", "main", "java")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbSourceOnlyInteractionTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbSourceOnlyInteractionTest.java deleted file mode 100644 index 60a2763e67..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbSourceOnlyInteractionTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbSourceOnlyInteractionTest { - - @Test - public void standaloneSourceOnlyHidesRematchAndRemoteRatings() throws Exception { - String source = Files.readString(root().resolve(Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")), StandardCharsets.UTF_8); - - assertTrue(source.contains("private void applySourceOnlyActionVisibility()")); - assertTrue(source.contains("binding.rematch.setVisibility(View.GONE);")); - assertTrue(source.contains("if (!isTmdbSourceOnly()) {")); - assertTrue(method(source, "private void fetchDoubanRating", "private void fetchOmdbRating").contains("if (isTmdbSourceOnly()) return;")); - assertTrue(method(source, "private void fetchOmdbRating", "private void addOmdbRatingChips").contains("if (isTmdbSourceOnly()) return;")); - assertTrue(source.contains("boolean tmdbSectionData = hasPhotos")); - assertTrue(source.contains("!isTmdbSourceOnly() && matchedTmdbDetail != null")); - } - - @Test - public void nativeVideoSourceOnlyHidesNetworkActionsInBothFlavors() throws Exception { - String mobile = read("mobile"); - String leanback = read("leanback"); - - assertTrue(mobile.contains("private void applySourceOnlyActionVisibility()")); - assertTrue(mobile.contains("rematch.setVisibility(View.GONE);")); - assertTrue(mobile.contains("&& !runtimeSourceOnly")); - assertTrue(method(mobile, "private void showManualTmdbMatchDialog", "private void showTmdbMatchDialog").contains("if (runtimeSourceOnly) return;")); - assertTrue(method(mobile, "private void showManualTmdbSeasonDialog", "private void analyzeTmdbSeasonWithAi").contains("if (runtimeSourceOnly) return;")); - - assertTrue(leanback.contains("setTmdbRematchVisible(loadTmdbDetail && !runtimeSourceOnly);")); - assertTrue(leanback.contains("&& !runtimeSourceOnly")); - assertTrue(method(leanback, "private void showManualTmdbMatchDialog", "private void searchTmdb").contains("if (runtimeSourceOnly) return;")); - assertTrue(method(leanback, "private void showManualTmdbSeasonDialog", "private void analyzeTmdbSeasonWithAi").contains("if (runtimeSourceOnly) return;")); - assertTrue(leanback.contains("if (runtimeSourceOnly) {\n renderTmdbRatingChips(label, container, buildTmdbRatingChips());")); - } - - @Test - public void sourceOnlyNeverShowsNullablePlaceholdersInProductionCode() throws Exception { - String standalone = Files.readString(root().resolve(Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")), StandardCharsets.UTF_8); - String mobile = read("mobile"); - String leanback = read("leanback"); - - for (String source : new String[]{standalone, mobile, leanback}) { - assertFalse(source.contains("sourceOnlyMissingPlaceholder")); - } - } - - private static String method(String source, String startMarker, String endMarker) { - int start = source.indexOf(startMarker); - int end = source.indexOf(endMarker, start); - assertTrue("missing method " + startMarker, start >= 0 && end > start); - return source.substring(start, end); - } - - private static String read(String flavor) throws Exception { - return Files.readString(root().resolve(Path.of("app", "src", flavor, "java", "com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")), StandardCharsets.UTF_8); - } - - private static Path root() { - return Files.exists(Path.of("app", "src", "main")) ? Path.of(".") : Path.of(".."); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/VideoActivityRuntimeModeWiringTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/VideoActivityRuntimeModeWiringTest.java deleted file mode 100644 index 00e03537ec..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/VideoActivityRuntimeModeWiringTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class VideoActivityRuntimeModeWiringTest { - - @Test - public void mobileAndLeanbackInitializeRuntimeModeBeforeAdapterSetup() throws Exception { - for (String flavor : new String[]{"mobile", "leanback"}) { - String source = read(flavor); - int init = source.indexOf("initializeRuntimeDetailMode();"); - int adapter = source.indexOf("initTmdbMode();", init); - assertTrue(flavor + " must initialize runtime mode before adapter setup", init >= 0 && adapter > init); - if (flavor.equals("leanback")) { - int prepare = source.indexOf("prepareInitialDetailShell();", init); - assertTrue("leanback must initialize runtime mode before shell setup", prepare > init); - } - assertTrue(source.contains("private int runtimeDetailMode = Setting.getDetailOpenMode();")); - assertTrue(source.contains("private boolean isRuntimeFusionMode()")); - assertTrue(source.contains("private boolean isRuntimeOriginalEnhancedMode()")); - assertTrue(source.contains("private boolean isRuntimeDirectMode()")); - } - } - - @Test - public void detailLoadUsesRuntimePolicyAndLoadsSourceWithoutNetwork() throws Exception { - String adapter = Files.readString(root().resolve(Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "ui", "helper", "TmdbUIAdapter.java")), StandardCharsets.UTF_8); - String loadSource = method(adapter, "public void loadSource(TmdbBundle bundle", "private void applySourceBundle"); - String applySource = method(adapter, "private void applySourceBundle", "private void fillInitialSourceGaps"); - - assertTrue(loadSource.contains("resetLoadState();")); - assertTrue(loadSource.contains("sourceOnly = !tmdbConfig.isReady();")); - assertTrue(applySource.contains("enrichVod(sourceVod, tmdbItem, tmdbDetail);")); - assertTrue(loadSource.contains("notifyVodChanged(sourceVod, generation, RefreshEvent.Type.VOD_CORE);")); - assertFalse("source-only load must not call the network service", loadSource.contains("tmdbService.")); - - for (String flavor : new String[]{"mobile", "leanback"}) { - String source = read(flavor); - String setDetail = method(source, "private void setDetail(Vod item)", flavor.equals("mobile") - ? "private void setText(Vod item)" : "private boolean shouldLoadTmdbDetail()"); - assertTrue(flavor + " must resolve runtime policy from normalized source data", - setDetail.contains("DetailRuntimeModePolicy.resolve(new DetailRuntimeModePolicy.Input(")); - assertTrue(setDetail.contains("runtimeSourceOnly = decision.sourceOnly();")); - assertTrue(setDetail.contains("mTmdbUIAdapter.loadSource(sourceBundle, item, sourcePayload);")); - assertTrue(setDetail.contains("sourceState == TmdbSourceState.RENDERABLE && sourceBundle != null")); - } - } - - @Test - public void directRuntimeModeKeepsNativeEpisodeAndShellSemantics() throws Exception { - for (String flavor : new String[]{"mobile", "leanback"}) { - String source = read(flavor); - assertFalse(source.contains("Setting.isFusionDetailPage()")); - assertFalse(source.contains("Setting.isOriginalEnhancedDetailPage()")); - assertFalse(source.contains("Setting.isDirectDetailPage()")); - assertTrue(source.contains("isRuntimeFusionMode()")); - assertTrue(source.contains("isRuntimeOriginalEnhancedMode()")); - assertTrue(source.contains("isRuntimeDirectMode()")); - } - } - - private static String method(String source, String startMarker, String endMarker) { - int start = source.indexOf(startMarker); - int end = source.indexOf(endMarker, start); - assertTrue("missing method " + startMarker, start >= 0 && end > start); - return source.substring(start, end); - } - - private static String read(String flavor) throws Exception { - return Files.readString(root().resolve(Path.of("app", "src", flavor, "java", "com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")), StandardCharsets.UTF_8); - } - - private static Path root() { - return Files.exists(Path.of("app", "src", "main")) ? Path.of(".") : Path.of(".."); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBindingTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBindingTest.java deleted file mode 100644 index d301ccfbf9..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBindingTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -public class VideoSurfaceBindingTest { - - @Test - public void sameEngineIsNotRebound() { - Object engine = new Object(); - VideoSurfaceBinding.Result result = VideoSurfaceBinding.resolve(engine, engine); - assertFalse("stable playback must not touch the binding", result.applied()); - assertSame(engine, result.player()); - } - - @Test - public void replacedEngineIsReboundAfterDetachingTheOldOne() { - Object oldEngine = new Object(); - Object newEngine = new Object(); - VideoSurfaceBinding.Result result = VideoSurfaceBinding.resolve(oldEngine, newEngine); - assertTrue("a rebuilt engine must be handed to the view", result.applied()); - assertTrue("the released engine must be detached first", result.detachedFirst()); - assertSame(newEngine, result.player()); - } - - @Test - public void emptyViewBindsTheFirstEngineWithoutDetaching() { - Object engine = new Object(); - VideoSurfaceBinding.Result result = VideoSurfaceBinding.resolve(null, engine); - assertTrue(result.applied()); - assertFalse("nothing to detach when the view is empty", result.detachedFirst()); - assertSame(engine, result.player()); - } - - @Test - public void emptiedSessionUnbindsTheStaleEngine() { - Object engine = new Object(); - VideoSurfaceBinding.Result result = VideoSurfaceBinding.resolve(engine, null); - assertTrue("a view must never keep an engine the session no longer has", result.applied()); - assertTrue(result.detachedFirst()); - assertNull(result.player()); - } - - @Test - public void emptyViewAndEmptySessionIsANoOp() { - VideoSurfaceBinding.Result result = VideoSurfaceBinding.resolve(null, null); - assertFalse(result.applied()); - assertNull(result.player()); - } - - @Test - public void boundEngineIsReportedAsItself() { - Object engine = new Object(); - assertEquals(engine, VideoSurfaceBinding.resolve(engine, engine).player()); - } -} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/ui/adapter/ListStateConvergenceContractTest.java b/app/src/test/java/com/fongmi/android/tv/ui/adapter/ListStateConvergenceContractTest.java deleted file mode 100644 index 788be337e1..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/adapter/ListStateConvergenceContractTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.fongmi.android.tv.ui.adapter; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 「列表数量变化 → 状态机」的收敛契约。 - * - *

为什么需要它。本仓库反复出现过同一类缺陷(共 5 处,跨电视端与手机端): - * 删除/清空之后宿主忘了驱动状态机,于是列表空了但页面**留白**——连空态都不出现。 - * 逐页发现是不可靠的,所以把这件事从"每个调用点都要记得写"改成"适配器统一通知": - * {@code BaseDiffAdapter} 在 diff 应用完成后回调 {@link BaseDiffAdapter.OnCountChanged}, - * 宿主只注册一次。 - * - *

本契约钉住三件事: - *

    - *
  1. 钩子必须从**两个** {@code setItems} 重载都会触发(少一个就仍有遗漏路径);
  2. - *
  3. 触发时机必须在 diff **应用之后**({@code AsyncListDiffer} 是异步的, - * 提前读 {@code getItemCount()} 会拿到旧值);
  4. - *
  5. 宿主必须注册钩子,且**不得再手写** {@code showContent(true, ...)}—— - * 手写正是此前漏掉"删除全部"那条路径的原因。
  6. - *
- */ -public class ListStateConvergenceContractTest { - - private static final String BASE = "app/src/main/java/com/fongmi/android/tv/ui/adapter/BaseDiffAdapter.java"; - - /** 已接入该收敛的宿主:源文件(相对 app/src)。 */ - private static final String[] HOSTS = { - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java", - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java", - "app/src/mobile/java/com/fongmi/android/tv/ui/activity/KeepActivity.java", - "app/src/mobile/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java", - }; - - @Test - public void baseAdapterExposesACountChangedHook() throws Exception { - String source = read(BASE); - assertTrue("必须声明 OnCountChanged", source.contains("interface OnCountChanged")); - assertTrue("必须提供注册入口", source.contains("public void setOnCountChanged(OnCountChanged listener)")); - assertTrue("必须有一个统一的收尾方法", source.contains("private void afterUpdate(Runnable runnable)")); - } - - /** - * 两个 setItems 重载都必须经过 afterUpdate:漏掉任何一个就仍有"改了列表但状态没跟上"的路径。 - */ - @Test - public void bothSetItemsOverloadsFireTheHook() throws Exception { - String source = read(BASE); - int calls = count(source, "afterUpdate("); - // 一处定义 + 两处调用(Runnable 重载、Callback 重载的两个分支) - assertTrue("两个 setItems 重载都必须触发钩子,实测 afterUpdate 出现 " + calls + " 次", calls >= 4); - assertTrue("Runnable 重载必须在 diff 回调里触发", - source.contains("differ.submitList(Objects.requireNonNullElseGet(items, ArrayList::new), () -> afterUpdate(runnable))")); - assertTrue("Callback 重载的变化分支必须在 diff 回调里触发", - source.contains("differ.submitList(newItems, () -> {")); - assertTrue("Callback 重载的无变化分支也要触发", source.contains("afterUpdate(null);")); - // 钩子必须在回调里读数量,不能提前读。 - assertFalse("不得在 submitList 之前读取数量", source.contains("int count = getItemCount();")); - } - - @Test - public void hostsRegisterTheHookOnceAndNoLongerHandRollStateDriving() throws Exception { - for (String host : HOSTS) { - String source = withoutJavaComments(read(host)); - assertTrue(host + " 必须注册 OnCountChanged 钩子", - source.contains("mAdapter.setOnCountChanged(count -> mBinding.progressLayout.showContent(true, count))")); - // 唯一允许出现 showContent(true, ...) 的地方就是那行注册。 - assertEquals(host + " 不得再手写状态驱动(只允许注册钩子那一处)", - 1, count(source, "showContent(true,")); - } - } - - /** - * "删除全部"这条路径尤其容易漏:它此前是直接 {@code mAdapter.clear()}。 - * 现在它靠钩子自动驱动,宿主侧不应再有额外调用。 - */ - @Test - public void deleteAllReliesOnTheHookNotOnHandWrittenCalls() throws Exception { - for (String host : HOSTS) { - String source = withoutJavaComments(read(host)); - int at = source.indexOf("mAdapter.clear()"); - if (at < 0) continue; - // clear() 之后紧跟的应当是 lambda 收尾,而不是一串状态驱动语句。 - String after = source.substring(at, Math.min(source.length(), at + 120)); - assertFalse(host + " 的 mAdapter.clear() 之后不得再手写状态驱动", after.contains("showContent(true,")); - } - } - - // ------------------------------------------------------------------ helpers - - private static int count(String source, String needle) { - Matcher matcher = Pattern.compile(Pattern.quote(needle)).matcher(source); - int total = 0; - while (matcher.find()) total++; - return total; - } - - private static String withoutJavaComments(String source) { - return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//.*$", " "); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/custom/FocusRestorerTest.java b/app/src/test/java/com/fongmi/android/tv/ui/custom/FocusRestorerTest.java deleted file mode 100644 index e00243d8ea..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/custom/FocusRestorerTest.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.fongmi.android.tv.ui.custom; - -import androidx.recyclerview.widget.RecyclerView; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class FocusRestorerTest { - - @Test - public void removedItemKeepsFocusOnTheSameSlotWhenLaterItemsShiftUp() { - // 3 列,删掉第 0 行第 1 列(下标 1),后面的条目补位,焦点留在原位 - assertEquals(1, FocusRestorer.nextPosition(1, 5, 3)); - assertEquals(0, FocusRestorer.nextPosition(0, 5, 3)); - } - - @Test - public void removedLastItemOfARowFallsBackWithinTheSameRow() { - // 3 列 5 条:删掉下标 4(第 1 行第 1 列,行内末位)后剩 4 条,退到新末位 3——仍在第 1 行 - assertEquals(3, FocusRestorer.nextPosition(4, 4, 3)); - } - - @Test - public void removedOnlyItemOfTheLastRowMovesUpOneRowKeepingTheColumn() { - // 3 列 4 条:下标 3 是第 1 行唯一条目,删除后整行消失。 - // 一维 clamp 会给出 2(第 0 行第 2 列,横跨整屏),正确目标是同列上一行的 0 - assertEquals(0, FocusRestorer.nextPosition(3, 3, 3)); - // 3 列 7 条:下标 6 独占第 2 行第 0 列,删除后回到第 1 行第 0 列 - assertEquals(3, FocusRestorer.nextPosition(6, 6, 3)); - } - - @Test - public void nextPositionNeverEscapesTheNewListBounds() { - // 不只覆盖「删一条」,也覆盖聚合删除后一次少掉多条的情况 - for (int span = 1; span <= 6; span++) { - for (int position = 0; position <= 40; position++) { - for (int count = 0; count <= 40; count++) { - int next = FocusRestorer.nextPosition(position, count, span); - if (count == 0) { - assertEquals("空列表必须返回 NO_POSITION", RecyclerView.NO_POSITION, next); - continue; - } - assertTrue("span=" + span + " count=" + count + " position=" + position - + " 返回了越界下标 " + next, next >= 0 && next < count); - // 焦点只允许留在原位或往前退,绝不能往后跳到用户没去过的条目 - assertTrue("span=" + span + " count=" + count + " position=" + position - + " 让焦点前进到了 " + next, next <= position); - boolean clamped = position >= count && position - span > count - 1; - if (position >= count && !clamped && (count - 1) / span != position / span) { - assertEquals("span=" + span + " count=" + count + " position=" + position - + " 整行删空后必须保持同列", position % span, next % span); - } - } - } - } - } - - @Test - public void emptyOrUnknownPositionYieldsNoPosition() { - assertEquals(RecyclerView.NO_POSITION, FocusRestorer.nextPosition(RecyclerView.NO_POSITION, 5, 3)); - assertEquals(RecyclerView.NO_POSITION, FocusRestorer.nextPosition(0, 0, 3)); - } - - @Test - public void singleColumnListBehavesLikeALinearList() { - assertEquals(2, FocusRestorer.nextPosition(2, 5, 1)); - assertEquals(3, FocusRestorer.nextPosition(4, 4, 1)); - } - - @Test - public void restoreGuardsAgainstHiddenListsAndScrollsToUnlaidOutTargets() throws Exception { - String source = read(findMainJavaPath().resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "custom", "FocusRestorer.java"))); - assertTrue("恢复焦点前必须确认列表可见,否则 requestFocus 会被 INVISIBLE 的祖先拒绝", - source.contains("!recycler.isShown()")); - assertTrue("目标行未 layout 时必须先滚动,并再等一次 pre-draw 才能拿到 holder(post 会跑在 layout 之前)", - source.contains("recycler.scrollToPosition(position);") - && source.contains("schedule(recycler, position, scheduled, false);")); - assertFalse("不能用 post 做滚动重试:它的消息会在重新 layout 之前执行,重试必然拿不到 holder", - source.contains("recycler.post(")); - assertTrue("重试仍失败时必须把焦点兜底交回列表本身", - source.contains("recycler.requestFocus()")); - assertTrue("必须检查 requestFocus 的返回值,而不是假定成功", - source.contains("return holder != null && holder.itemView.requestFocus();")); - assertTrue("排队的恢复必须可取消,避免退出删除模式或离开页面后抢焦点", - source.contains("public void cancel()") - && source.contains("pending.removeListener();")); - assertTrue("滚动重试不受 removeListener 管辖,必须用代际号让 cancel 也能作废它", - source.contains("if (scheduled != generation) return;")); - } - - @Test - public void leanbackHistoryPageRestoresFocusByItemIdentityAndCancelsOnExit() throws Exception { - String source = read(findLeanbackJavaPath().resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "activity", "HistoryActivity.java"))); - assertTrue("删除单条后必须按网格规则恢复焦点,而不是一维 clamp", - source.contains("FocusRestorer.nextPosition(target, mAdapter.getItemCount(), Product.getColumn())")); - assertFalse("不能对网格下标做一维 clamp——列删空时会把焦点甩到上一行另一端", - source.contains("mAdapter.getItemCount() - 1)")); - assertTrue("删除位置必须来自 holder 的绑定下标,而不是重新 indexOf 扫描", - source.contains("public void onItemDelete(History item, int position)")); - assertTrue("去抖刷新会重排列表,恢复焦点必须按条目身份重新定位", - source.contains("indexOf(focused)")); - assertTrue("聚焦条目已不在新列表时必须退回原位置,而不是放弃恢复让焦点掉给删除按钮", - source.contains("reposition(focused, position)") - && source.contains("if (found >= 0) return found;")); - assertTrue("holder 绑定下标失效时必须退回当前聚焦位置", - source.contains("position >= 0 ? position : focusedPosition()")); - assertTrue("恢复焦点前必须做生命周期守卫", - source.contains("if (isFinishing() || isDestroyed()) return;")); - assertTrue("退出删除模式、返回与 onStop 都必须取消排队中的恢复", - source.contains("mFocusRestorer.cancel();") - && source.contains("protected void onStop()")); - } - - @Test - public void leanbackHistoryAdapterPassesTheBoundPositionOnDelete() throws Exception { - String source = read(findLeanbackJavaPath().resolve(Path.of( - "com", "fongmi", "android", "tv", "ui", "adapter", "HistoryAdapter.java"))); - assertTrue("删除回调必须带上 holder 的绑定下标", - source.contains("void onItemDelete(History item, int position);") - && source.contains("listener.onItemDelete(item, holder.getBindingAdapterPosition());")); - } - - private static String read(Path path) throws Exception { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - private static Path findMainJavaPath() { - Path moduleRelative = Path.of("src", "main", "java"); - if (Files.exists(moduleRelative)) return moduleRelative; - return Path.of("app", "src", "main", "java"); - } - - private static Path findLeanbackJavaPath() { - Path moduleRelative = Path.of("src", "leanback", "java"); - if (Files.exists(moduleRelative)) return moduleRelative; - return Path.of("app", "src", "leanback", "java"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/custom/ProgressLayoutContractTest.java b/app/src/test/java/com/fongmi/android/tv/ui/custom/ProgressLayoutContractTest.java deleted file mode 100644 index 389c802adf..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/custom/ProgressLayoutContractTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.fongmi.android.tv.ui.custom; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * 加载圈期间的卡顿有一部分来自 {@link ProgressLayout} 自己:每进一次 CONTENT 都会重放一段 - * 内容淡入(setAlpha(0) + 新建 ViewPropertyAnimator),而 EMPTY/ERROR 与 CONTENT 之间 - * 的切换在详情页/播放页非常频繁。这些测试锁住「切换幂等且不再白建动画」这条约束, - * 同时锁住淡入本身没被删掉——去掉淡入是另一种行为回退。 - */ -public class ProgressLayoutContractTest { - - @Test - public void showingAnAlreadyVisibleContentDoesNotReplayTheFade() throws Exception { - String body = methodBody(readMainSource("ui/custom/ProgressLayout.java"), "private void showView(View view)"); - assertTrue("showView must skip the fade when the view is already fully visible, or every" - + " state flip re-requests a frame during the loading spinner", - body.contains("if (view.getVisibility() == VISIBLE && view.getAlpha() == 1f) return;")); - } - - @Test - public void showingCancelsThePreviousAnimatorBeforeRestarting() throws Exception { - String body = methodBody(readMainSource("ui/custom/ProgressLayout.java"), "private void showView(View view)"); - int cancel = body.indexOf("view.animate().cancel();"); - int fadeIn = body.indexOf("view.animate().alpha(1f).setDuration(100);"); - assertTrue("showView must cancel the previous animator", cancel >= 0); - assertTrue("showView must still fade the content in", fadeIn >= 0); - assertTrue("showView must cancel before it starts a new fade", cancel < fadeIn); - } - - @Test - public void hidingAnAlreadyHiddenContentIsFree() throws Exception { - String body = methodBody(readMainSource("ui/custom/ProgressLayout.java"), "private void hideView(View view)"); - assertTrue("hideView must skip work when the view is already INVISIBLE", - body.contains("if (view.getVisibility() == INVISIBLE) return;")); - int cancel = body.indexOf("view.animate().cancel();"); - int invisible = body.indexOf("view.setVisibility(INVISIBLE);"); - assertTrue("hideView must cancel a fade that is still running", cancel >= 0); - assertTrue("hideView must still hide the view", invisible >= 0); - assertTrue("hideView must cancel before it hides", cancel < invisible); - assertFalse("hideView must not animate the content away, it is a state switch not a transition", - body.contains("setDuration(")); - } - - @Test - public void contentVisibilityStillDrivesEveryContentChild() throws Exception { - String source = readMainSource("ui/custom/ProgressLayout.java"); - String body = methodBody(source, "private void setContentVisibility(boolean visible)"); - assertTrue("setContentVisibility must keep routing VISIBLE through showView", body.contains("if (visible) showView(view);")); - assertTrue("setContentVisibility must keep routing hidden through hideView", body.contains("else hideView(view);")); - String stateBody = methodBody(source, "public void switchState(State state)"); - assertTrue("CONTENT must still reveal the content views", stateBody.contains("setContentVisibility(true);")); - } - - private static String methodBody(String source, String signature) { - // 工作区被 core.autocrlf 转成 CRLF,而断言按 LF 写:先归一化再切片, - // 否则同样会撞上本仓库既有那批「行尾冤案」。 - String text = source.replace("\r\n", "\n"); - int start = text.indexOf(signature); - assertTrue("source is missing " + signature, start >= 0); - int end = text.indexOf("\n }", start); - assertTrue("source is missing the end of " + signature, end > start); - return text.substring(start, end); - } - - private static String readMainSource(String file) throws Exception { - Path app = Files.exists(Path.of("src", "main")) ? Path.of(".") : Path.of("app"); - Path path = app.resolve(Path.of("src", "main", "java", "com", "fongmi", "android", "tv", file)); - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/detail/EnhancedDetailControllerTest.java b/app/src/test/java/com/fongmi/android/tv/ui/detail/EnhancedDetailControllerTest.java deleted file mode 100644 index 308665fc31..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/detail/EnhancedDetailControllerTest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.fongmi.android.tv.ui.detail; - -import static org.junit.Assert.assertFalse; - -import org.junit.Test; - -public class EnhancedDetailControllerTest { - - @Test - public void enhancedMode_hasNoInlinePlayerOrAutoPlay() { - TmdbDetailModeController controller = new EnhancedDetailController(null); - - assertFalse(controller.shouldShowInlinePlayer()); - assertFalse(controller.shouldAutoPlay()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/detail/PlayerDetailControllerSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/detail/PlayerDetailControllerSourceTest.java deleted file mode 100644 index 71f044f6ae..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/detail/PlayerDetailControllerSourceTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.fongmi.android.tv.ui.detail; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -public class PlayerDetailControllerSourceTest { - - private static final Path SOURCE = Paths.get("src/main/java/com/fongmi/android/tv/ui/detail/PlayerDetailController.java"); - - @Test - public void directPlayWaitsForExplicitUserAction() throws Exception { - String source = Files.readString(SOURCE, StandardCharsets.UTF_8); - assertTrue(source.contains("protected boolean autoPlay()")); - assertTrue(source.contains("return false; // 详情直放必须由用户点击播放")); - assertFalse(source.contains("return true; // 详情直放模式自动播放")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeControllerTest.java b/app/src/test/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeControllerTest.java deleted file mode 100644 index 4b53acdb60..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeControllerTest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.fongmi.android.tv.ui.detail; - -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -public class TmdbDetailModeControllerTest { - - @Test - public void concreteControllers_implementModeControllerContract() { - assertTrue(TmdbDetailModeController.class.isAssignableFrom(FusionDetailController.class)); - assertTrue(TmdbDetailModeController.class.isAssignableFrom(EnhancedDetailController.class)); - assertTrue(TmdbDetailModeController.class.isAssignableFrom(PlayerDetailController.class)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/dialog/CacheTotalLimitApplyContractTest.java b/app/src/test/java/com/fongmi/android/tv/ui/dialog/CacheTotalLimitApplyContractTest.java deleted file mode 100644 index 510d532c3a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/dialog/CacheTotalLimitApplyContractTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertTrue; - -/** - * 改「总上限」必须让面板上的「收至」重新开出来。 - * - *

播放缓存空闲态的目标值由总上限推导({@code desiredCapacityBytes(IDLE)} 读 - * {@code CachePolicyStore.getTotalLimitBytes()}),而摘要行里的该数字是在 - * {@code render(CacheSnapshot)} 里现算的。改上限若只调 {@code updatePolicyButtons()}, - * 就只刷新三个按钮文案,摘要行会继续显示改之前的目标值——用户看到的是一个已经 - * 不成立的承诺。

- * - *

因此 {@code chooseTotalLimit()} 的回调必须与 EXO 单项上限走同一条应用路径 - * ({@code applyPlaybackLimit()}),而不是仅重绘按钮。

- */ -public class CacheTotalLimitApplyContractTest { - - private static final String DIALOG = - "app/src/main/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialog.java"; - - @Test - public void totalLimitChangeAppliesAndRepaintsTheSummary() throws Exception { - String body = methodBody(read(DIALOG), "private void chooseTotalLimit()"); - assertTrue("改总上限必须落到播放缓存的应用路径", - body.contains("applyPlaybackLimit()")); - assertTrue("改总上限仍要重绘上限按钮", - body.contains("updatePolicyButtons()")); - assertTrue("先落盘再应用", body.indexOf("putTotalLimitBytes") < body.indexOf("applyPlaybackLimit()")); - } - - @Test - public void bothLimitEntriesShareThePlaybackApplyPath() throws Exception { - String source = read(DIALOG); - assertTrue("总上限走应用路径", - methodBody(source, "private void chooseTotalLimit()").contains("applyPlaybackLimit()")); - assertTrue("EXO 单项上限也走同一条应用路径", - methodBody(source, "private void chooseLimit(CacheModuleId id)").contains("applyPlaybackLimit()")); - } - - private static String methodBody(String source, String signature) { - String cleaned = source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//.*$", " "); - int start = cleaned.indexOf(signature); - assertTrue("必须存在方法 " + signature, start > 0); - int open = cleaned.indexOf('{', start); - int depth = 0; - for (int i = open; i < cleaned.length(); i++) { - char c = cleaned.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return cleaned.substring(start, i + 1); - } - return cleaned.substring(start); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/dialog/ChoiceDialogTest.java b/app/src/test/java/com/fongmi/android/tv/ui/dialog/ChoiceDialogTest.java deleted file mode 100644 index 6f0a2880ea..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/dialog/ChoiceDialogTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -public class ChoiceDialogTest { - - @Test - public void keepsFocusedItemInsideViewportWhenMovingDown() { - assertEquals(84, ChoiceDialog.calculateItemScrollY(0, 240, 540, 270, 324)); - assertEquals(262, ChoiceDialog.calculateItemScrollY(84, 240, 540, 448, 502)); - } - - @Test - public void scrollsBackWhenMovingUpAndClampsToContent() { - assertEquals(120, ChoiceDialog.calculateItemScrollY(180, 240, 540, 120, 174)); - assertEquals(0, ChoiceDialog.calculateItemScrollY(20, 240, 200, 0, 54)); - assertEquals(300, ChoiceDialog.calculateItemScrollY(999, 240, 540, 300, 354)); - } - - @Test - public void doesNotScrollAlreadyVisibleItems() { - assertEquals(40, ChoiceDialog.calculateItemScrollY(40, 240, 540, 80, 134)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchIntentTest.java b/app/src/test/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchIntentTest.java deleted file mode 100644 index 9ed6778046..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/dialog/DanmakuSearchIntentTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class DanmakuSearchIntentTest { - - @Test - public void completedRequestPublishesSubmittedKeyword() { - Object request = new Object(); - DanmakuSearchIntent intent = new DanmakuSearchIntent(); - - intent.begin(request, " searched title "); - - assertTrue(intent.complete(request)); - assertEquals("searched title", intent.getResultKeyword()); - } - - @Test - public void staleRequestCannotReplaceLatestResultKeyword() { - Object stale = new Object(); - Object current = new Object(); - DanmakuSearchIntent intent = new DanmakuSearchIntent(); - - intent.begin(stale, "stale title"); - intent.begin(current, "current title"); - - assertFalse(intent.complete(stale)); - assertEquals("", intent.getResultKeyword()); - assertTrue(intent.complete(current)); - assertEquals("current title", intent.getResultKeyword()); - } - - @Test - public void cancelInvalidatesPendingRequest() { - Object request = new Object(); - DanmakuSearchIntent intent = new DanmakuSearchIntent(); - - intent.begin(request, "title"); - intent.cancel(); - - assertFalse(intent.isCurrent(request)); - assertFalse(intent.complete(request)); - } - - @Test - public void selectedResultsUseKeywordSubmittedWithTheCurrentRequest() throws Exception { - Path root = Files.isDirectory(Path.of("src", "main", "java")) ? Path.of(".") : Path.of("app"); - for (String name : new String[]{"DanmakuSearchDialog", "DanmakuSearchInputDialog"}) { - Path source = root.resolve("src/main/java/com/fongmi/android/tv/ui/dialog/" + name + ".java"); - String code = Files.readString(source, StandardCharsets.UTF_8); - int method = code.indexOf("private void rememberManualDanmaku("); - assertTrue(name + " must remember manual selections", method >= 0); - int end = code.indexOf("\n }", method); - String body = code.substring(method, end); - assertTrue(name + " must remember the submitted keyword", body.contains("searchIntent.getResultKeyword()")); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/dialog/DialogRoundedCornerSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/dialog/DialogRoundedCornerSourceTest.java deleted file mode 100644 index ab09f789ab..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/dialog/DialogRoundedCornerSourceTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertTrue; - -public class DialogRoundedCornerSourceTest { - - @Test - public void outerDialogsUseTheUnifiedTwentyTwoDpRadius() throws Exception { - String[] drawables = { - "src/main/res/drawable/shape_shell_proxy_dialog.xml", - "src/main/res/drawable/shape_display_dialog_panel.xml", - "src/main/res/drawable/shape_one_key_sync_dialog.xml", - "src/leanback/res/drawable/shape_config_history_dialog.xml", - "src/leanback/res/drawable/shape_episode_dialog_panel.xml", - "src/main/res/drawable/shape_site_dialog.xml", - "src/main/res/drawable/shape_dialog_glass_toolbar.xml", - "src/main/res/drawable/shape_audio_playlist_panel.xml", - "src/main/res/drawable/shape_disc_menu_panel.xml", - "src/leanback/res/drawable/shape_exit_confirm_dialog.xml", - "src/mobile/res/drawable/selector_control_sheet_button.xml", - "src/mobile/res/drawable/selector_player_child_sheet_button.xml", - "src/mobile/res/drawable/selector_live_action_button.xml", - "src/main/res/drawable/selector_dialog_step_button.xml" - }; - - for (String drawable : drawables) { - Path path = Path.of(drawable); - String source = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - assertTrue(drawable + " should use the unified 22dp corner radius", - source.contains("")); - } - } - - @Test - public void materialDialogShapeUsesTheUnifiedTwentyTwoDpRadius() throws Exception { - String styles = Files.readString(Path.of("src/main/res/values/styles.xml"), StandardCharsets.UTF_8) - .replace("\r\n", "\n"); - assertTrue("Material dialog shape should use the unified 22dp corner radius", - styles.contains("")); - - String baseDialog = Files.readString(Path.of("src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java"), StandardCharsets.UTF_8); - assertTrue("BaseAlertDialog must apply a theme overlay, not leak widget tint into the floating toolbar", - baseDialog.contains("new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog)")); - - String themes = Files.readString(Path.of("src/main/res/values/webhtv_styles.xml"), StandardCharsets.UTF_8); - int overlayStart = themes.indexOf("") - .matches("(?s).*#[0-9A-Fa-f]{6,8}.*")); - assertTrue("真属性的 hex 必须保留(剔除不得过度)", - withoutComments("#FF123456") - .matches("(?s).*#[0-9A-Fa-f]{6,8}.*")); - assertTrue("剔除不得吃掉声明本身", - withoutComments("", - java.util.regex.Pattern.DOTALL) - .matcher(styles); - assertTrue("Widget.WebHTV.Input must exist", matcher.find()); - assertFalse("a TextInputLayout container style cannot be applied to a bare edit field", - matcher.group(1).contains("TextInputLayout")); - assertTrue("a standalone field must draw its own box", - matcher.group(2).contains("@drawable/selector_dialog_input")); - assertTrue("the standalone field must set its own text colours", - matcher.group(2).contains("android:textColor")); - } - - private static String value(String source, String name) { - java.util.regex.Matcher matcher = java.util.regex.Pattern - .compile("(#[0-9A-Fa-f]{6,8})") - .matcher(source); - assertTrue("missing token " + name, matcher.find()); - return matcher.group(1); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/web/CatWebWiringTest.java b/app/src/test/java/com/fongmi/android/tv/ui/web/CatWebWiringTest.java index 8b8ca23fce..dd299a8cd6 100644 --- a/app/src/test/java/com/fongmi/android/tv/ui/web/CatWebWiringTest.java +++ b/app/src/test/java/com/fongmi/android/tv/ui/web/CatWebWiringTest.java @@ -130,76 +130,10 @@ public class CatWebWiringTest { int store = source.indexOf("VodDetailCache.putContent(sourceKey, id, content)"); assertTrue("SiteApi 必须有详情缓存写入", store >= 0); - int guard = source.lastIndexOf("CatAction.blank(result.getVod())", store); + int guard = source.lastIndexOf("!CatAction.blank(result.getVod())", store); assertTrue("写缓存前必须排除「什么都没有」的详情", guard > 0 && guard < store); } - /** - * 只有占位线路、没有元数据的详情不得进缓存。 - * - *

设置类站点(网盘配置一类)把每个设置项做成条目,真正的工作在 {@code detailContent} - * 里发生——弹输入框、写配置;返回值只是个占位假线路,实测形如 - * {@code {"list":[{"vod_play_from":"Config","vod_play_url":"Config$Config"}]}}。 - * 缓存住它,第二次点击 spider 不被调用,弹窗不出,宿主拿着假地址直奔播放页。 - * - *

{@code blank} 挡不住:占位线路会生成 Flag,{@code getFlags()} 非空。所以判定要看 - * 元数据(名字/封面/简介)而不看线路——线路是占位结果也能凑出来的东西。 - */ - @Test - public void detailWithoutMetadataIsNotCached() throws IOException { - String source = read("com/fongmi/android/tv/api/SiteApi.java"); - int store = source.indexOf("VodDetailCache.putContent(sourceKey, id, content)"); - assertTrue("SiteApi 必须有详情缓存写入", store > 0); - - int guard = source.lastIndexOf("!hasMetadata(result.getVod())", store); - assertTrue("写缓存前必须排除「只有占位线路、没有元数据」的详情", guard > 0 && guard < store); - - int helper = source.indexOf("private static boolean hasMetadata(Vod vod)"); - assertTrue("必须有 hasMetadata 判定", helper > 0); - String body = source.substring(helper, source.indexOf('}', source.indexOf("return", helper))); - assertTrue("判定必须看名字", body.contains("getName()")); - assertTrue("判定必须看封面", body.contains("getPic()")); - assertTrue("判定必须看简介", body.contains("getContent()")); - assertTrue("判定绝不能看线路——占位结果也有线路,看了就挡不住设置类站点", - !body.contains("getFlags()")); - } - - /** 带元数据但没有可播放线路的设置动作也不得缓存。 */ - @Test - public void metadataOnlyActionDetailIsNotCached() throws IOException { - String source = read("com/fongmi/android/tv/api/SiteApi.java"); - int store = source.indexOf("VodDetailCache.putContent(sourceKey, id, content)"); - assertTrue("SiteApi 必须有详情缓存写入", store > 0); - - int guard = source.lastIndexOf("result.getVod().getFlags().isEmpty()", store); - assertTrue("写缓存前必须排除没有可播放线路的动作详情", guard > 0 && guard < store); - assertTrue("跳过原因必须可从设备日志验证", - source.substring(guard, store).contains("reason=noPlayableContent")); - } - - /** - * 这次 spider 调用顺带开了网页,这条详情就绝不能进缓存。 - * - *

开页是 spider 调用的副作用(bundle 反向调 {@code /msg}),缓存命中时不调 spider, - * 副作用也就不再发生——第二次点击网页不开、还会停在空详情页上,即「只有第一次生效」。 - * - *

与 {@link #detailWithoutMetadataIsNotCached} 互补:那条看结果形状,这条看副作用。 - * 形状千变万化,副作用是确凿的,两类都要。 - */ - @Test - public void webOpeningDetailIsNotCached() throws IOException { - String source = read("com/fongmi/android/tv/api/SiteApi.java"); - int sample = source.indexOf("long beforeSpider = System.currentTimeMillis()"); - assertTrue("必须在调 spider 之前先取一次时刻", sample > 0); - - int spider = source.indexOf("site.recent().spider().detailContent(", sample); - assertTrue("取样必须在 spider 调用之前,否则跨不过副作用发生的那一刻", spider > sample); - - int store = source.indexOf("VodDetailCache.putContent(sourceKey, id, content)"); - int compare = source.lastIndexOf("CatWebEvent.requestedAfter(beforeSpider)", store); - assertTrue("写缓存前必须判定这次调用有没有开过页", compare > sample && compare < store); - } - @Test public void activityRegistersNoJavascriptBridge() throws IOException { String source = read("com/fongmi/android/tv/ui/web/CatWebActivity.java"); @@ -232,48 +166,6 @@ public class CatWebWiringTest { !source.contains("getOnBackPressedDispatcher().onBackPressed()")); } - @Test - public void tmdbExternalLinksPreferInAppBrowserOnTv() throws IOException { - String source = read("com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); - int method = source.indexOf("private void openExternalLink(String url)"); - int nextMethod = source.indexOf("private void addRatingChip(String key", method); - String body = method >= 0 && nextMethod > method ? source.substring(method, nextMethod) : ""; - - assertTrue("TMDB 外链必须区分 TV 场景", method >= 0 && body.contains("Util.isLeanback()")); - assertTrue("TV 端必须先弹打开方式选择", body.contains("ChoiceDialog.showSingle(") - && body.contains("detail_external_open_builtin") - && body.contains("detail_external_open_external")); - assertTrue("TV 选择内置后才走 CatWebActivity", body.contains("openExternalLinkBuiltIn(url)") - && body.contains("WebViewUtil.support()") - && body.contains("CatWebActivity.browserIntent(")); - assertTrue("内置页启动失败后仍要保留系统浏览器兜底", - body.indexOf("Intent.ACTION_VIEW") > body.indexOf("openExternalLinkBuiltIn(url)")); - assertTrue("外部链接失败提示必须使用本地化资源", - body.contains("R.string.detail_external_open_failed")); - } - - @Test - public void catWebActivityUsesDesktopViewportForTvBrowser() throws IOException { - String source = read("com/fongmi/android/tv/ui/web/CatWebActivity.java"); - int configure = source.indexOf("private void configure()"); - int client = source.indexOf("private WebViewClient client()", configure); - String body = configure >= 0 && client > configure ? source.substring(configure, client) : ""; - - assertTrue("内置浏览器必须使用桌面 UA,避免 TMDB 等响应式站点按手机窄视口降级", - body.contains("DESKTOP_UA") && body.contains("s.setUserAgentString(DESKTOP_UA)")); - assertTrue("内置浏览器必须固定宽视口和 100% 文本缩放", - body.contains("s.setUseWideViewPort(true)") - && body.contains("s.setLoadWithOverviewMode(true)") - && body.contains("s.setTextZoom(100)")); - assertTrue("内置浏览器必须把初始缩放固定在 100%,避免 WebView 自动放大破坏布局", - body.contains("webView.setInitialScale(100)")); - assertTrue("内置浏览器必须按 WebView 能力关闭系统算法深色化,避免浅色网页的加载框变成黑底灰字", - body.contains("WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)") - && body.contains("WebSettingsCompat.setAlgorithmicDarkeningAllowed(s, false)") - && body.contains("WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)") - && body.contains("WebSettingsCompat.setForceDark(s, WebSettingsCompat.FORCE_DARK_OFF)")); - } - @Test public void bothFlavorsYieldDetailToWebview() throws IOException { for (String flavor : new String[]{"leanback", "mobile"}) { diff --git a/app/src/test/java/com/fongmi/android/tv/ui/web/ReaderTtsBridgeSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/web/ReaderTtsBridgeSourceTest.java deleted file mode 100644 index e77e702669..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/web/ReaderTtsBridgeSourceTest.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.fongmi.android.tv.ui.web; - -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashSet; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * 小说朗读的 JS 桥契约测试。 - * - * 朗读功能横跨三处:reader.html(面板与段落收集)、WebReaderActivity(JS 桥)、 - * ReaderTtsService(后台服务与通知)。三者靠字符串名字相连,任何一处改名都不会 - * 被编译期发现,只会在真机上表现为「点了没反应」,因此这里用源码契约把它们钉住。 - */ -public class ReaderTtsBridgeSourceTest { - - private static final Pattern BRIDGE_CALL = Pattern.compile("AndroidReader\\.([A-Za-z0-9_]+)"); - private static final Pattern JAVA_BRIDGE = Pattern.compile("@JavascriptInterface\\s+public\\s+[\\w<>\\[\\].]+\\s+(\\w+)\\s*\\("); - private static final Pattern JS_CALLBACK_DEF = Pattern.compile("window\\.(__onTts\\w+)\\s*="); - private static final Pattern JAVA_CALLBACK_CALL = Pattern.compile("window\\.(__onTts\\w+)\\s*&&\\s*window\\.\\1"); - - @Test - public void everyReaderBridgeCallHasANativeImplementation() throws Exception { - String html = read("app/src/main/assets/reader.html"); - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - Set called = new LinkedHashSet<>(); - Matcher calls = BRIDGE_CALL.matcher(html); - while (calls.find()) called.add(calls.group(1)); - - Set implemented = new LinkedHashSet<>(); - Matcher methods = JAVA_BRIDGE.matcher(activity); - while (methods.find()) implemented.add(methods.group(1)); - - assertTrue("reader.html 必须调用朗读桥", called.contains("ttsStart")); - for (String name : called) { - assertTrue("JS 调用了 AndroidReader." + name + ",但原生没有 @JavascriptInterface 实现", - implemented.contains(name)); - } - } - - @Test - public void everyNativeCallbackIsHandledByTheReader() throws Exception { - String html = read("app/src/main/assets/reader.html"); - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - - Set defined = new LinkedHashSet<>(); - Matcher defs = JS_CALLBACK_DEF.matcher(html); - while (defs.find()) defined.add(defs.group(1)); - - Set dispatched = new LinkedHashSet<>(); - Matcher calls = JAVA_CALLBACK_CALL.matcher(activity); - while (calls.find()) dispatched.add(calls.group(1)); - - assertTrue("原生必须回调朗读状态", dispatched.contains("__onTtsState")); - assertTrue("原生必须回调朗读进度", dispatched.contains("__onTtsProgress")); - assertTrue("原生必须回调章末事件", dispatched.contains("__onTtsChapterEnd")); - for (String name : dispatched) { - assertTrue("原生回调 window." + name + ",但 reader.html 没有定义", defined.contains(name)); - } - } - - @Test - public void readerHtmlNoLongerTreatsWebSpeechAsThePrimaryPath() throws Exception { - String html = read("app/src/main/assets/reader.html"); - - int nativeGate = html.indexOf("function ttsNative()"); - int webSpeak = html.indexOf("function ttsWebSpeak("); - int firstSpeakCall = html.indexOf("speechSynthesis.speak("); - - assertTrue("reader.html 必须有原生朗读开关", nativeGate >= 0); - assertTrue("必须有浏览器兜底实现", webSpeak >= 0); - assertTrue("speechSynthesis 只能出现在兜底实现里", - firstSpeakCall > webSpeak); - } - - @Test - public void sourceIdsMatchTheNativeRegistry() throws Exception { - String html = read("app/src/main/assets/reader.html"); - String engines = read("app/src/main/java/com/fongmi/android/tv/tts/TtsEngines.java"); - - for (String id : new String[]{"system", "edge", "baidu", "custom"}) { - assertTrue("reader.html 缺少音源入口: " + id, html.contains("data-tts-source=\"" + id + "\"")); - assertTrue("原生缺少音源常量: " + id, engines.contains("SOURCE_" + id.toUpperCase() + " = \"" + id + "\"")); - } - } - - @Test - public void readAloudServiceIsDeclaredForBackgroundPlayback() throws Exception { - String manifest = read("app/src/main/AndroidManifest.xml"); - String service = read("app/src/main/java/com/fongmi/android/tv/service/ReaderTtsService.java"); - - int declared = manifest.indexOf("android:name=\".service.ReaderTtsService\""); - assertTrue("必须在清单里声明朗读前台服务", declared >= 0); - int block = manifest.indexOf("/>", declared); - String declaration = manifest.substring(declared, block); - assertTrue("朗读服务必须是 mediaPlayback 前台服务: " + declaration, - declaration.contains("foregroundServiceType=\"mediaPlayback\"")); - - assertTrue("朗读服务必须上报前台通知", service.contains("startForeground(")); - assertTrue("朗读服务必须处理音频焦点", service.contains("requestAudioFocus")); - assertTrue("朗读服务必须注册拔耳机广播并在路由丢失时暂停", - service.contains("ACTION_AUDIO_BECOMING_NOISY") && service.contains("onAudioBecomingNoisy()")); - } - - @Test - public void controllerOwnsParagraphCursorAndChapterEnd() throws Exception { - String controller = read("app/src/main/java/com/fongmi/android/tv/tts/ReaderTtsController.java"); - - assertTrue("控制器必须持有朗读片段队列", controller.contains("TtsTextSplitter.split(")); - assertTrue("控制器必须支持上一段", controller.contains("public void previous()")); - assertTrue("控制器必须支持下一段", controller.contains("public void next()")); - assertTrue("控制器必须支持定时停止", controller.contains("timerTick")); - assertTrue("章末必须通知上层续读", controller.contains("listener.onTtsChapterEnd()")); - } - - @Test - public void readAloudControlBridgesDispatchToTheMainThread() throws Exception { - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); - String[] names = {"ttsStart", "ttsPause", "ttsResume", "ttsToggle", "ttsPrev", "ttsNext", - "ttsSeek", "ttsSetRate", "ttsSetPitch", "ttsSetVoice", "ttsSetTimer", "ttsStop"}; - for (String name : names) { - int start = activity.indexOf("public void " + name + "("); - assertTrue("朗读桥缺少方法: " + name, start >= 0); - int end = activity.indexOf("@JavascriptInterface", start); - String body = activity.substring(start, end > start ? end : activity.length()); - // @JavascriptInterface 方法跑在 WebView 的 JavaBridge 线程,而控制器/前台服务 - // 要求主线程语义(引擎回调、前台通知、MediaPlayer 都在主线程),必须显式切回主线程。 - assertTrue("朗读桥 " + name + " 没有切回主线程(控制器状态会与主线程引擎回调并发)", - body.contains("postToService(") || body.contains("runOnUiThread(")); - } - } - - private static String read(String path) throws Exception { - // 单测工作目录随 Gradle 版本落在仓库根或 app/ 模块目录,两种都兼容 - Path direct = Path.of(path); - if (Files.exists(direct)) return new String(Files.readAllBytes(direct), StandardCharsets.UTF_8); - Path moduleRelative = Path.of(path.startsWith("app/") ? path.substring("app/".length()) : path); - assertTrue("缺少文件: " + path, Files.exists(moduleRelative)); - return new String(Files.readAllBytes(moduleRelative), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/update/OciAuthChallengeTest.java b/app/src/test/java/com/fongmi/android/tv/update/OciAuthChallengeTest.java deleted file mode 100644 index 51908a7a2e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/update/OciAuthChallengeTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.fongmi.android.tv.update; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; - -import org.junit.Test; - -public class OciAuthChallengeTest { - - @Test - public void parsesDockerBearerChallenge() { - OciAuthChallenge challenge = OciAuthChallenge.parse("Bearer realm=\"https://auth.docker.io/token\",service=\"registry.docker.io\",scope=\"repository:fish2018/webhtv-apk:pull\""); - assertEquals("https://auth.docker.io/token", challenge.realm); - assertEquals("registry.docker.io", challenge.service); - assertEquals("repository:fish2018/webhtv-apk:pull", challenge.scope); - } - - @Test - public void rejectsMissingRealmAndBasicAuth() { - assertThrows(IllegalArgumentException.class, () -> OciAuthChallenge.parse("Bearer service=\"registry.docker.io\"")); - assertThrows(IllegalArgumentException.class, () -> OciAuthChallenge.parse("Basic realm=\"registry\"")); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/update/OciRegistryClientTest.java b/app/src/test/java/com/fongmi/android/tv/update/OciRegistryClientTest.java deleted file mode 100644 index 7271ed59bd..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/update/OciRegistryClientTest.java +++ /dev/null @@ -1,233 +0,0 @@ -package com.fongmi.android.tv.update; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThrows; - -import java.io.IOException; -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.security.MessageDigest; -import java.util.Locale; -import java.util.concurrent.atomic.AtomicInteger; - -import org.junit.Test; - -import okhttp3.Call; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Protocol; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import okio.Buffer; -import okio.BufferedSource; - -public class OciRegistryClientTest { - - private final OciArtifact artifact = new OciArtifact( - "registry-1.docker.io", - "fish2018/webhtv-apk", - "v1-mobile-arm64_v8a", - "sha256:" + "1".repeat(64), - "sha256:" + "2".repeat(64), - 1024); - - @Test - public void acceptsExactlyOnePinnedApkLayer() throws Exception { - new OciRegistryClient(null).validateManifest(manifest(layer()), artifact); - } - - @Test - public void rejectsMultipleLayersAndWrongMediaType() { - String twoLayers = layer() + "," + layer(); - assertThrows(IOException.class, () -> new OciRegistryClient(null).validateManifest(manifest(twoLayers), artifact)); - assertThrows(IOException.class, () -> new OciRegistryClient(null).validateManifest( - manifest(layer().replace(OciRegistryClient.APK_MEDIA_TYPE, "application/octet-stream")), artifact)); - } - - @Test - public void rejectsDescriptorDigestOrSizeMismatch() { - assertThrows(IOException.class, () -> new OciRegistryClient(null).validateManifest( - manifest(layer().replace(artifact.layerDigest, "sha256:" + "3".repeat(64))), artifact)); - assertThrows(IOException.class, () -> new OciRegistryClient(null).validateManifest( - manifest(layer().replace("\"size\":1024", "\"size\":1025")), artifact)); - } - - @Test - public void pullsThroughBearerAuthAndStripsAuthorizationOnCdnRedirect() throws Exception { - byte[] apk = "signed-apk-bytes".getBytes(StandardCharsets.UTF_8); - String layerDigest = digest(apk); - OciArtifact provisional = new OciArtifact( - "registry-1.docker.io", - "fish2018/webhtv-apk", - "v1-mobile-arm64_v8a", - "sha256:" + "0".repeat(64), - layerDigest, - apk.length); - byte[] manifest = manifest(layer(provisional)); - OciArtifact target = new OciArtifact( - provisional.registry, - provisional.repository, - provisional.reference, - digest(manifest), - provisional.layerDigest, - provisional.size); - AtomicInteger tokenCalls = new AtomicInteger(); - AtomicInteger manifestCalls = new AtomicInteger(); - AtomicInteger blobCalls = new AtomicInteger(); - OkHttpClient client = new OkHttpClient.Builder().addInterceptor(chain -> { - Request request = chain.request(); - String host = request.url().host(); - if ("mirror.example".equals(host)) { - return new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(307) - .message("Temporary Redirect") - .header("Location", "https://registry.example" + request.url().encodedPath()) - .body(new BytesBody(new byte[0], null)) - .build(); - } - if ("registry.example".equals(host) && "/token".equals(request.url().encodedPath())) { - tokenCalls.incrementAndGet(); - return response(request, 200, "OK", "{\"token\":\"test-token\"}".getBytes(StandardCharsets.UTF_8), "application/json"); - } - if ("cdn.example.com".equals(host)) { - assertNull(request.header("Authorization")); - return response(request, 200, "OK", apk, "application/octet-stream"); - } - if (request.url().encodedPath().contains("/manifests/")) { - manifestCalls.incrementAndGet(); - if (request.header("Authorization") == null) return unauthorized(request, artifact.repository); - assertEquals("Bearer test-token", request.header("Authorization")); - return response(request, 200, "OK", manifest, OciRegistryClient.MANIFEST_MEDIA_TYPE); - } - if (request.url().encodedPath().contains("/blobs/")) { - blobCalls.incrementAndGet(); - if (request.header("Authorization") == null) return unauthorized(request, artifact.repository); - return new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(302) - .message("Found") - .header("Location", "https://cdn.example.com/apk") - .body(new BytesBody(new byte[0], null)) - .build(); - } - throw new IOException("Unexpected request: " + request.url()); - }).followRedirects(false).build(); - File file = File.createTempFile("oci-update", ".apk"); - try { - new OciRegistryClient(client).pull("https://mirror.example", target, file, new Observer()); - assertArrayEquals(apk, Files.readAllBytes(file.toPath())); - assertEquals(2, tokenCalls.get()); - assertEquals(2, manifestCalls.get()); - assertEquals(2, blobCalls.get()); - } finally { - Files.deleteIfExists(file.toPath()); - } - } - - @Test - public void rejectsBearerScopeForAnotherRepository() { - OkHttpClient client = new OkHttpClient.Builder().addInterceptor(chain -> { - Request request = chain.request(); - return unauthorized(request, "other/repository"); - }).followRedirects(false).build(); - - assertThrows(IOException.class, () -> new OciRegistryClient(client).pull( - "https://mirror.example", artifact, new File("unused.apk"), new Observer())); - } - - private byte[] manifest(String layers) { - String json = "{" + - "\"schemaVersion\":2," + - "\"mediaType\":\"" + OciRegistryClient.MANIFEST_MEDIA_TYPE + "\"," + - "\"artifactType\":\"" + OciRegistryClient.ARTIFACT_TYPE + "\"," + - "\"config\":{\"mediaType\":\"" + OciRegistryClient.CONFIG_MEDIA_TYPE + "\",\"digest\":\"sha256:" + "0".repeat(64) + "\",\"size\":2}," + - "\"layers\":[" + layers + "]}"; - return json.getBytes(StandardCharsets.UTF_8); - } - - private String layer() { - return "{\"mediaType\":\"" + OciRegistryClient.APK_MEDIA_TYPE + "\",\"digest\":\"" + artifact.layerDigest + "\",\"size\":1024}"; - } - - private String layer(OciArtifact value) { - return "{\"mediaType\":\"" + OciRegistryClient.APK_MEDIA_TYPE + "\",\"digest\":\"" + value.layerDigest + "\",\"size\":" + value.size + "}"; - } - - private String digest(byte[] value) throws Exception { - byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value); - StringBuilder builder = new StringBuilder("sha256:"); - for (byte item : bytes) builder.append(String.format(Locale.ROOT, "%02x", item)); - return builder.toString(); - } - - private Response unauthorized(Request request, String repository) { - return new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(401) - .message("Unauthorized") - .header("WWW-Authenticate", "Bearer realm=\"https://registry.example/token\",service=\"registry.example\",scope=\"repository:" + repository + ":pull\"") - .body(new BytesBody(new byte[0], null)) - .build(); - } - - private Response response(Request request, int code, String message, byte[] body, String mediaType) { - Response.Builder builder = new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(code) - .message(message) - .body(new BytesBody(body, mediaType)); - if (mediaType != null) builder.header("Content-Type", mediaType); - return builder.build(); - } - - private static final class Observer implements OciRegistryClient.Observer { - - @Override - public boolean isCanceled() { - return false; - } - - @Override - public void onCall(Call call) { - } - - @Override - public void onProgress(int progress, long bytes, long total, long speed, long elapsed) { - } - } - - private static final class BytesBody extends ResponseBody { - - private final byte[] bytes; - private final MediaType mediaType; - - private BytesBody(byte[] bytes, String mediaType) { - this.bytes = bytes; - this.mediaType = mediaType == null ? null : MediaType.parse(mediaType); - } - - @Override - public MediaType contentType() { - return mediaType; - } - - @Override - public long contentLength() { - return bytes.length; - } - - @Override - public BufferedSource source() { - return new Buffer().write(bytes); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/update/UpdateRoutePlannerTest.java b/app/src/test/java/com/fongmi/android/tv/update/UpdateRoutePlannerTest.java deleted file mode 100644 index e705d338d7..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/update/UpdateRoutePlannerTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.fongmi.android.tv.update; - -import com.fongmi.android.tv.utils.GithubProxy; - -import static org.junit.Assert.assertEquals; - -import java.util.List; - -import org.junit.Test; - -public class UpdateRoutePlannerTest { - - private final OciArtifact artifact = new OciArtifact( - "registry-1.docker.io", - "fish2018/webhtv-apk", - "v1-mobile-arm64_v8a", - "sha256:" + "1".repeat(64), - "sha256:" + "2".repeat(64), - 1024); - private final GithubProxy.Config direct = GithubProxy.config("", GithubProxy.MODE_FULL_URL, false); - - @Test - public void ociTriesOciThenGithub() { - List routes = UpdateRoutePlanner.plan( - UpdateSource.OCI, - "https://github.com/fish2018/webhtv/releases/download/v1/app.apk", - artifact, - direct, - "https://dockerproxy.net"); - assertEquals(2, routes.size()); - assertEquals(UpdateTarget.Kind.OCI, routes.get(0).kind); - assertEquals(UpdateTarget.Kind.GITHUB, routes.get(1).kind); - } - - @Test - public void githubFallsBackToOci() { - List routes = UpdateRoutePlanner.plan( - UpdateSource.GITHUB, - "https://github.com/fish2018/webhtv/releases/download/v1/app.apk", - artifact, - direct, - "https://dockerproxy.net"); - assertEquals(2, routes.size()); - assertEquals(UpdateTarget.Kind.GITHUB, routes.get(0).kind); - assertEquals(UpdateTarget.Kind.OCI, routes.get(1).kind); - } - - @Test - public void legacyAutoNormalizesToOci() { - List routes = UpdateRoutePlanner.plan( - "auto", - "https://github.com/fish2018/webhtv/releases/download/v1/app.apk", - artifact, - direct, - "https://dockerproxy.net"); - assertEquals(2, routes.size()); - assertEquals(UpdateTarget.Kind.OCI, routes.get(0).kind); - assertEquals(UpdateTarget.Kind.GITHUB, routes.get(1).kind); - } - - @Test - public void missingOciMetadataFallsBackToGithub() { - List routes = UpdateRoutePlanner.plan( - UpdateSource.OCI, - "https://github.com/fish2018/webhtv/releases/download/v1/app.apk", - null, - direct, - "https://dockerproxy.net"); - assertEquals(1, routes.size()); - assertEquals(UpdateTarget.Kind.GITHUB, routes.get(0).kind); - } - - @Test - public void missingGithubStillUsesOci() { - List routes = UpdateRoutePlanner.plan( - UpdateSource.OCI, - "", - artifact, - direct, - "https://dockerproxy.net"); - assertEquals(1, routes.size()); - assertEquals(UpdateTarget.Kind.OCI, routes.get(0).kind); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/AdBlockTimeFormatterTest.java b/app/src/test/java/com/fongmi/android/tv/utils/AdBlockTimeFormatterTest.java deleted file mode 100644 index 2e9d09acc4..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/AdBlockTimeFormatterTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.fongmi.android.tv.utils; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -public class AdBlockTimeFormatterTest { - - @Test - public void formatsPlayerStyleClockWithTenths() { - assertEquals("00:25:29.1", AdBlockTimeFormatter.formatSeconds(1529.1)); - assertEquals("00:00:03.7", AdBlockTimeFormatter.formatSeconds(3.733)); - } - - @Test - public void carriesRoundedTenthsAcrossClockFields() { - assertEquals("00:01:00.0", AdBlockTimeFormatter.formatSeconds(59.96)); - assertEquals("01:00:00.0", AdBlockTimeFormatter.formatSeconds(3599.96)); - } - - @Test - public void clampsInvalidOrNegativeValuesToZero() { - assertEquals("00:00:00.0", AdBlockTimeFormatter.formatSeconds(-1)); - assertEquals("00:00:00.0", AdBlockTimeFormatter.formatSeconds(Double.NaN)); - assertEquals("00:00:00.0", AdBlockTimeFormatter.formatSeconds(Double.POSITIVE_INFINITY)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/AppVersionBuildStampTest.java b/app/src/test/java/com/fongmi/android/tv/utils/AppVersionBuildStampTest.java deleted file mode 100644 index 3924ebb7dc..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/AppVersionBuildStampTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.fongmi.android.tv.utils; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Regression coverage for the box update loop: the stable and beta channel manifests of one release - * line share a {@code versionCode}, so their build stamps are the only way to tell which one is the - * newer build. Treating "different tag" as "newer" made the app offer the older stable package to a - * beta install (and the other way round), which overwrote data forever. - */ -public class AppVersionBuildStampTest { - - private static final String STABLE = "v5.6.0-202609221328"; - private static final String BETA = "v5.6.0-beta-202610091755"; - - @Test - public void buildStampReadsTheTrailingReleaseMoment() { - assertEquals("202609221328", AppVersion.buildStamp(STABLE)); - assertEquals("202609221328", AppVersion.buildStamp(AppVersion.stripPrefix(STABLE))); - assertEquals("202610091755", AppVersion.buildStamp(BETA)); - assertEquals("202610111200", AppVersion.buildStamp("5.6.0-fast-202610111200")); - assertEquals("", AppVersion.buildStamp("5.6.0")); - assertEquals("", AppVersion.buildStamp(null)); - } - - @Test - public void newerBetaIsAnUpdateForAStableInstall() { - assertTrue(AppVersion.isNewer(STABLE, BETA)); - } - - @Test - public void olderStableIsNeverAnUpdateForABetaInstall() { - assertFalse("An older stable package must not be offered over a newer beta install", - AppVersion.isNewer(BETA, STABLE)); - } - - @Test - public void identicalBuildIsNotAnUpdate() { - assertFalse(AppVersion.isNewer(BETA, BETA)); - assertFalse(AppVersion.isNewer(STABLE, AppVersion.stripPrefix(STABLE))); - assertFalse(AppVersion.isNewer(BETA, "")); - assertFalse(AppVersion.isNewer(BETA, null)); - } - - @Test - public void newerStampWinsAcrossChannels() { - assertTrue(AppVersion.isNewer(STABLE, "v5.6.0-beta-202609221329")); - assertTrue(AppVersion.isNewer(BETA, "v5.6.1-202610101755")); - assertFalse(AppVersion.isNewer(BETA, "v5.6.1-202610081755")); - } - - @Test - public void stampsOfDifferentLengthsAreStillOrdered() { - String minuteInstall = "v5.6.0-beta-202610091755"; - String secondInstall = "v5.6.0-beta-20261009175522"; - - assertFalse("An older minute stamp must not look newer than a second-resolution install", - AppVersion.isNewer(secondInstall, "v5.6.0-202609221328")); - assertTrue(AppVersion.isNewer(secondInstall, "v5.6.0-20261009175530")); - assertTrue(AppVersion.isNewer(minuteInstall, "v5.6.0-20261009175530")); - assertFalse("The same minute without seconds is not a later build", - AppVersion.isNewer(secondInstall, minuteInstall)); - } - - @Test - public void tagsWithoutAComparableStampKeepTheHistoricalBehavior() { - assertTrue(AppVersion.isNewer("5.6.0", "5.6.1")); - assertFalse(AppVersion.isNewer("5.6.0", "5.6.0")); - } - - @Test - public void runningBuildNameIsReadFromTheBuildTag() { - assertFalse(AppVersion.fullName().startsWith("v")); - assertTrue(AppVersion.isCurrent(AppVersion.fullName())); - assertTrue(AppVersion.isCurrent("v" + AppVersion.fullName())); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/CrashLogTextTest.java b/app/src/test/java/com/fongmi/android/tv/utils/CrashLogTextTest.java deleted file mode 100644 index 7687f12645..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/CrashLogTextTest.java +++ /dev/null @@ -1,154 +0,0 @@ -package com.fongmi.android.tv.utils; - -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 CrashLogTextTest { - - private static CrashLogText.Facts facts(String stack) { - return new CrashLogText.Facts( - "5.6.0-test", - "2026-10-21T20:00:00.000+0800", - 12345L, - "com.silent.android.webhtv:error_activity", - 4242, - 10123, - "main", - false, - "Xiaomi TV", - "Android 14 (sdk 34)", - "java.lang.IllegalStateException", - "boom", - stack); - } - - @Test - public void reportCarriesIdentityContextAndStack() { - String report = CrashLogText.format(facts("java.lang.IllegalStateException: boom\n\tat a.b.C.d(C.java:12)\n\tat e.f.G.h(G.java:3)")); - - assertTrue(report.startsWith(CrashLogText.REPORT_HEADER)); - assertTrue(report.contains("appVersion=5.6.0-test")); - assertTrue(report.contains("elapsedMs=12345")); - assertTrue(report.contains("process=com.silent.android.webhtv:error_activity")); - assertTrue(report.contains("pid=4242")); - assertTrue(report.contains("thread=main")); - assertTrue(report.contains("debugLogEnabled=false")); - assertTrue(report.contains("javaClass=java.lang.IllegalStateException")); - assertTrue(report.contains("--- stack ---")); - assertTrue(report.contains("at a.b.C.d(C.java:12)")); - assertTrue(report.contains("at e.f.G.h(G.java:3)")); - } - - @Test - public void reportRedactsUrlsPathsAndSecrets() { - String report = CrashLogText.format(facts( - "at leak(Secret.java:1)\n or http://secret.example/path?token=abc123\n at /data/user/0/app/files/report.txt\n socket 192.168.1.5:9978")); - - assertFalse(report.contains("secret.example")); - assertFalse(report.contains("abc123")); - assertFalse(report.contains("/data/user/0/")); - assertFalse(report.contains("192.168.")); - } - - @Test - public void oversizedStackIsBoundedAndMarked() { - StringBuilder stack = new StringBuilder(); - while (stack.length() < 300_000) stack.append("at big.Stack.line(File.java:1)\n"); - - String report = CrashLogText.format(facts(stack.toString())); - - assertTrue(report.length() <= CrashLogText.MAX_REPORT_CHARS + 4096); - assertTrue(report.contains("[truncated")); - } - - @Test - public void reportElapsedAndPidAreParsedFromHeader() { - String report = CrashLogText.format(facts("at a.b(C.java:1)")); - - assertEquals(12345L, CrashLogText.reportElapsed(report)); - assertEquals(4242, CrashLogText.reportPid(report)); - assertEquals(-1L, CrashLogText.reportElapsed("no header")); - assertEquals(-1, CrashLogText.reportPid(null)); - } - - @Test - public void freshnessRequiresAPositiveBoundedWindow() { - assertTrue(CrashLogText.isFresh(1_000L, 2_000L, 5_000L)); - assertTrue(CrashLogText.isFresh(1_000L, 6_000L, 5_000L)); - assertFalse(CrashLogText.isFresh(1_000L, 6_001L, 5_000L)); - assertFalse(CrashLogText.isFresh(0L, 2_000L, 5_000L)); - assertFalse(CrashLogText.isFresh(1_000L, 500L, 5_000L)); - assertFalse(CrashLogText.isFresh(1_000L, 2_000L, 0L)); - } - - @Test - public void logcatFilterKeepsOnlyTheCrashedProcessTail() { - List lines = List.of( - "10-21 20:15:01.100 100 200 I Other: a", - "10-21 20:15:01.200 4242 200 I Crash: b", - "10-21 20:15:01.300 4242 200 E Crash: c"); - - List kept = CrashLogText.filterLogcat(lines, 4242, 2, 4096); - assertEquals(2, kept.size()); - assertTrue(kept.get(0).contains("Crash: b")); - assertTrue(kept.get(1).contains("Crash: c")); - - assertEquals(1, CrashLogText.filterLogcat(lines, 4242, 1, 4096).size()); - assertEquals(2, CrashLogText.filterLogcat(lines, -1, 2, 4096).size()); - assertTrue(CrashLogText.filterLogcat(lines, 4242, 2, 10).size() <= 1); - assertEquals(4242, CrashLogText.linePid(lines.get(1))); - assertEquals(-1, CrashLogText.linePid("--------- beginning of main")); - } - - @Test - public void sessionStatesMissingEvidenceInsteadOfClaimingCoverage() { - String body = CrashLogText.composeSession(null, List.of(), false, false, false); - - assertTrue(body.startsWith(CrashLogText.SESSION_HEADER)); - assertTrue(body.contains("report=missing-or-stale")); - assertTrue(body.contains("logcat=unavailable")); - assertTrue(body.contains("debugLog=off")); - assertTrue(body.contains("调试日志在崩溃前未开启")); - assertTrue(body.contains("未找到本次崩溃的堆栈记录")); - assertTrue(body.contains("# 系统日志不可用。")); - } - - @Test - public void sessionIncludesFreshReportAndLogcat() { - String report = CrashLogText.format(facts("at a.b(C.java:1)")); - String body = CrashLogText.composeSession(report, List.of("10-21 20:15:01.200 4242 200 E Crash: x"), true, true, true); - - assertTrue(body.contains("report=fresh")); - assertTrue(body.contains("logcat=own-uid-tail")); - assertTrue(body.contains("debugLog=on")); - assertFalse(body.contains("调试日志在崩溃前未开启")); - assertTrue(body.contains("Crash: x")); - assertTrue(body.contains(CrashLogText.REPORT_HEADER)); - } - - @Test - public void oversizedSessionIsTruncated() { - String[] lines = new String[2000]; - for (int i = 0; i < lines.length; i++) lines[i] = "10-21 20:15:01.200 4242 200 I Crash: " + "x".repeat(200); - - String body = CrashLogText.composeSession(null, List.of(lines), true, false, true); - - assertTrue(body.length() <= CrashLogText.MAX_SESSION_CHARS + 4096); - assertTrue(body.contains("# [truncated crash session]")); - } - - @Test - public void displayUrlKeepsTheRealAddressCopyable() { - String url = "http://192.168.1.5:9978/debug/logs"; - String display = CrashLogText.wrapUrlForDisplay(url); - - assertTrue(display.contains("\u200B")); - assertEquals(url, display.replace("\u200B", "")); - assertEquals("", CrashLogText.wrapUrlForDisplay(null)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/CrashLogWiringTest.java b/app/src/test/java/com/fongmi/android/tv/utils/CrashLogWiringTest.java deleted file mode 100644 index 8e5786ebad..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/CrashLogWiringTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.fongmi.android.tv.utils; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** Source contracts for the crash-time LAN log session; the flow spans processes and XML. */ -public class CrashLogWiringTest { - - @Test - public void crashWriterIsInstalledRightAfterTheCrashHandlerConfiguration() throws Exception { - String startup = read("app/src/main/java/com/fongmi/android/tv/Startup.java"); - int config = startup.indexOf("CaocConfig.Builder.create()"); - int install = startup.indexOf("CrashReport.install(context)"); - - assertTrue("Startup must install the crash report writer", install >= 0); - assertTrue("the writer must wrap the handler installed by CustomActivityOnCrash", config >= 0 && install > config); - } - - @Test - public void crashScreenStartsTheSessionBeforeServingTheAddress() throws Exception { - String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java"); - int prepare = activity.indexOf("CrashLogSession.prepare(this)"); - int start = activity.indexOf("Server.get().start()", prepare); - - assertTrue(activity.contains("mBinding.copyLogAddress.setOnClickListener")); - assertTrue(activity.contains("mBinding.openLog.setOnClickListener")); - assertTrue(activity.contains("Server.get().getAddress(false)")); - assertTrue("the crash screen must keep the log service available", activity.contains("Server.get().shutdown()")); - assertTrue("back must not leave the LAN service running unseen", activity.contains("protected void onBackInvoked()")); - assertTrue("the crash session must be prepared before the address is offered", prepare >= 0 && start > prepare); - } - - @Test - public void bothCrashLayoutsExposeTheSameLogAddressViews() throws Exception { - for (String file : new String[]{ - "app/src/leanback/res/layout/activity_crash.xml", - "app/src/mobile/res/layout/activity_crash.xml"}) { - String layout = read(file); - assertTrue(file + " must scroll, so a small or landscape screen still reaches every entry", - layout.contains("= 0 && append > begin); - assertFalse("a crash session must not change the user's debug-log preference", - store.substring(begin, append).contains("Prefers.put")); - } - - @Test - public void debugLogPageLabelsTheCrashSessionAndHidesItsControls() throws Exception { - String page = read("app/src/main/java/com/fongmi/android/tv/server/process/DebugLogs.java"); - - assertTrue(page.contains("DebugLogStore.isCrashSessionTemporary()")); - assertTrue(page.contains("崩溃会话")); - assertTrue(page.contains("crash-banner")); - assertTrue("the poll refresh must not overwrite the crash-session label with 记录中", - page.contains("crashSession") && page.contains("'崩溃会话 · 自动开启'")); - assertTrue(page.contains("崩溃会话临时日志:本页自动开启,重启后恢复原设置。")); - } - - @Test - public void crashSessionCapturesTheSystemLogcatTail() throws Exception { - String session = read("app/src/main/java/com/fongmi/android/tv/utils/CrashLogSession.java"); - - assertTrue(session.contains("CrashReport.readRecent(context)")); - assertTrue(session.contains("\"logcat\"")); - assertTrue("coverage must not claim a logcat tail when the tail is empty", - session.contains("!logcat.isEmpty()")); - assertTrue(session.contains("DebugLogStore.appendCrashContext")); - assertTrue(session.contains("CrashLogText.filterLogcat")); - } - - @Test - public void newCrashStringsExistInEveryLocale() throws Exception { - String[] keys = {"crash_log_title", "crash_log_hint", "crash_log_preparing", "crash_log_unavailable", - "crash_log_no_lan", "crash_log_service_failed", "crash_log_copy", "crash_log_copied", "crash_log_open"}; - for (String file : new String[]{ - "app/src/main/res/values/strings.xml", - "app/src/main/res/values-zh-rCN/strings.xml", - "app/src/main/res/values-zh-rTW/strings.xml"}) { - String strings = read(file); - for (String key : keys) assertTrue(file + " must define " + key, strings.contains("name=\"" + key + "\"")); - } - } - - private static String read(String file) throws Exception { - Path root = Files.exists(Path.of("app")) ? Path.of("") : Path.of(".."); - return Files.readString(root.resolve(file), StandardCharsets.UTF_8).replace("\r\n", "\n"); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/FileChooserImportTest.java b/app/src/test/java/com/fongmi/android/tv/utils/FileChooserImportTest.java deleted file mode 100644 index b7374333f9..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/FileChooserImportTest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.fongmi.android.tv.utils; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; - -/** - * 无法解析真实路径的 content URI 要复制到应用内部目录。这个目录不是 cache,系统「清理缓存」 - * 清不掉,所以复制策略必须自己保证不会无界增长——同时又不能删掉已被配置引用的文件。 - * - *

这里测的是真实行为(真建文件、真比对内容),不是源码文本。 - */ -public class FileChooserImportTest { - - @Rule - public TemporaryFolder folder = new TemporaryFolder(); - - private static InputStream stream(String body) { - return new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)); - } - - @Test - public void importWritesContentAndReturnsPathInsideTargetDir() throws IOException { - File dir = folder.newFolder("imports"); - String path = FileChooser.persistentImport(stream("module.exports={};"), "pkg.zip", dir); - - File file = new File(path); - assertTrue("导入结果必须真的落盘", file.isFile()); - assertEquals("内容必须完整写入", "module.exports={};", new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)); - assertEquals("必须落在指定的持久目录里", dir.getCanonicalFile(), file.getParentFile().getCanonicalFile()); - } - - /** - * 反复导入同一个包是最常见的操作(改完本地包再导一次)。若每次都留新副本, - * 内部存储会被几十 MB 的 zip 一路吃光,而用户在设置里看不到也删不掉。 - */ - @Test - public void reimportingIdenticalContentReusesOneFile() throws IOException { - File dir = folder.newFolder("imports"); - String first = FileChooser.persistentImport(stream("same-bytes"), "pkg.zip", dir); - String second = FileChooser.persistentImport(stream("same-bytes"), "pkg.zip", dir); - - assertEquals("相同内容必须复用同一份文件", first, second); - assertEquals("目录里只该有一个文件", 1, countFiles(dir)); - } - - /** 同名但内容不同的两个包必须各自独立,否则换包后仍加载旧内容。 */ - @Test - public void sameNameWithDifferentContentDoesNotOverwrite() throws IOException { - File dir = folder.newFolder("imports"); - String first = FileChooser.persistentImport(stream("bundle-a"), "pkg.zip", dir); - String second = FileChooser.persistentImport(stream("bundle-b"), "pkg.zip", dir); - - assertNotEquals("同名不同内容不能相互覆盖", first, second); - assertEquals("bundle-a", new String(Files.readAllBytes(new File(first).toPath()), StandardCharsets.UTF_8)); - assertEquals("bundle-b", new String(Files.readAllBytes(new File(second).toPath()), StandardCharsets.UTF_8)); - } - - /** 空内容说明流已失效(文档被删/权限被撤),落盘一个 0 字节文件只会让后续解析莫名失败。 */ - @Test - public void emptyStreamIsRejectedAndLeavesNothingBehind() throws IOException { - File dir = folder.newFolder("imports"); - assertThrows(IOException.class, () -> FileChooser.persistentImport(stream(""), "pkg.zip", dir)); - assertEquals("失败后不能留下任何残留文件", 0, countFiles(dir)); - } - - /** 中途失败不能留下 .import-*.tmp,否则每次失败都在内部存储里堆一份垃圾。 */ - @Test - public void failureMidStreamCleansUpTempFile() throws IOException { - File dir = folder.newFolder("imports"); - InputStream broken = new InputStream() { - private int remaining = 16; - - @Override - public int read() throws IOException { - if (remaining-- <= 0) throw new IOException("stream broke"); - return 'x'; - } - }; - - assertThrows(IOException.class, () -> FileChooser.persistentImport(broken, "pkg.zip", dir)); - assertEquals("读取中断后不能留下临时文件", 0, countFiles(dir)); - } - - /** 文件名来自 content provider,可能含路径分隔符或奇怪字符,不能直接拼进路径。 */ - @Test - public void hostileDisplayNamesStayInsideTargetDir() throws IOException { - File dir = folder.newFolder("imports"); - for (String name : new String[]{"../../evil.zip", "a/b/c.zip", null, " ", "包名.zip"}) { - String path = FileChooser.persistentImport(stream("body-" + name), name, dir); - File file = new File(path); - assertEquals("文件名 " + name + " 不能逃出目标目录", - dir.getCanonicalFile(), file.getParentFile().getCanonicalFile()); - assertTrue(file.isFile()); - } - } - - private static int countFiles(File dir) { - File[] files = dir.listFiles(); - return files == null ? 0 : files.length; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/GithubProxyTest.java b/app/src/test/java/com/fongmi/android/tv/utils/GithubProxyTest.java index 08e48672e9..faefdc8005 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/GithubProxyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/GithubProxyTest.java @@ -3,7 +3,6 @@ package com.fongmi.android.tv.utils; import org.junit.Test; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; public class GithubProxyTest { @@ -62,45 +61,4 @@ public class GithubProxyTest { public void normalizeConfigKeepsFirstValidSources() { assertEquals("https://ghfast.top/\nhttps://99z.top/", GithubProxy.normalizeConfig("bad\nhttps://ghfast.top\nhttps://99z.top/")); } - - @Test - public void addSourcesDeduplicatesExistingSources() { - String configured = GithubProxy.addSources( - "https://ghfast.top/\nhttps://example.com/\nhttps://ghfast.top", - "https://example.com/\nhttps://99z.top" - ); - - assertEquals("https://ghfast.top/\nhttps://example.com/\nhttps://99z.top/", - configured); - } - - @Test - public void probeUrlUsesNormalizedSourceAndFixedTarget() { - assertEquals("https://ghfast.top/https://github.com/Silent1566/webhtv/releases/download/update-channel/update.json", - GithubProxy.probeUrl("https://ghfast.top")); - } - - @Test - public void configRewritesFullUrlAndStripScheme() { - String url = "https://github.com/Silent1566/webhtv/releases/download/v1/app.apk"; - - assertEquals("https://ghfast.top/" + url, - GithubProxy.config("https://ghfast.top", GithubProxy.MODE_FULL_URL, true).rewrite(url)); - assertEquals("https://github.chenc.dev/github.com/Silent1566/webhtv/releases/download/v1/app.apk", - GithubProxy.config("https://github.chenc.dev", GithubProxy.MODE_STRIP_SCHEME, true).rewrite(url)); - } - - @Test - public void disabledConfigUsesDirectTarget() { - String url = "https://github.com/Silent1566/webhtv/releases/download/v1/app.apk"; - - assertEquals(url, GithubProxy.config("https://ghfast.top", GithubProxy.MODE_FULL_URL, false).rewrite(url)); - } - - @Test - public void configRejectsUnsafeTarget() { - assertThrows(IllegalArgumentException.class, - () -> GithubProxy.config("https://ghfast.top", GithubProxy.MODE_FULL_URL, true) - .rewrite("http://github.com/Silent1566/webhtv/releases/download/v1/app.apk")); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockNoticeTest.java b/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockNoticeTest.java deleted file mode 100644 index b93c7606ee..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockNoticeTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.fongmi.android.tv.utils; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class HlsAdblockNoticeTest { - - @Test - public void debouncesRepeatedPlaylistRefreshesWithinWindow() { - String url = "https://video.example.com/live/notice-test.m3u8"; - - assertTrue(HlsAdblockNotice.shouldNotify(url, 100_000L)); - assertFalse(HlsAdblockNotice.shouldNotify(url, 129_999L)); - assertTrue(HlsAdblockNotice.shouldNotify(url, 130_000L)); - } - - @Test - public void tracksDifferentPlaylistsIndependently() { - long now = 200_000L; - - assertTrue(HlsAdblockNotice.shouldNotify("https://a.example.com/live.m3u8", now)); - assertTrue(HlsAdblockNotice.shouldNotify("https://b.example.com/live.m3u8", now)); - } - - @Test - public void noticeReportsSegmentCountAndTotalAdDuration() { - String message = HlsAdblockNotice.message(3, 27.5); - - assertEquals("已跳过 3 个广告片段,总广告时长 27.5 秒", message); - } - - @Test - public void noticeKeepsSegmentCountWhenDurationIsUnknown() { - assertEquals("已跳过 2 个广告片段", HlsAdblockNotice.message(2, 0)); - assertEquals("已跳过 2 个广告片段", HlsAdblockNotice.message(2, Double.NaN)); - assertEquals("已跳过 2 个广告片段", HlsAdblockNotice.message(2, -1)); - } - - @Test - public void noticeSwitchesToMinutesForLongAdBreaks() { - assertEquals("已跳过 30 个广告片段,总广告时长 1 分钟", HlsAdblockNotice.message(30, 60)); - assertEquals("已跳过 45 个广告片段,总广告时长 2 分 5 秒", HlsAdblockNotice.message(45, 125)); - } - - @Test - public void noticeRoundsToTenthsBeforeChoosingTheUnit() { - // 0.1 秒粒度先进位再选单位:59.96 秒必须是「1 分钟」,不能出现自相矛盾的「60.0 秒」。 - assertEquals("59.9 秒", HlsAdblockNotice.durationText(59.94)); - assertEquals("1 分钟", HlsAdblockNotice.durationText(59.96)); - assertEquals("1 分 30 秒", HlsAdblockNotice.durationText(90)); - assertEquals("已跳过 12 个广告片段,总广告时长 1 分钟", HlsAdblockNotice.message(12, 59.96)); - } - - @Test - public void noticeHidesDurationsBelowTheDisplayGranularity() { - assertEquals("", HlsAdblockNotice.durationText(0.04)); - assertEquals("", HlsAdblockNotice.durationText(0)); - assertEquals("已跳过 2 个广告片段", HlsAdblockNotice.message(2, 0.04)); - assertEquals("0.2 秒", HlsAdblockNotice.durationText(0.16)); - assertEquals("已跳过 2 个广告片段,总广告时长 0.2 秒", HlsAdblockNotice.message(2, 0.16)); - } - - @Test - public void everyHlsAdblockChannelUsesTheSharedDurationNotice() throws Exception { - java.nio.file.Path root = java.nio.file.Paths.get("src", "main", "java"); - java.util.List channels = java.util.List.of( - root.resolve("com/fongmi/android/tv/server/process/M3u8.java"), - root.resolve("com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSource.java"), - root.resolve("androidx/media3/mpvplayer/MpvHlsProxy.java")); - for (java.nio.file.Path channel : channels) { - String source = new String(java.nio.file.Files.readAllBytes(channel), - java.nio.charset.StandardCharsets.UTF_8); - assertTrue("channel must route through the shared notice: " + channel, - source.contains("HlsAdblockNotice.message(")); - assertFalse("channel must not keep the duration-less inline notice: " + channel, - source.contains("个广告片段(")); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java b/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java index af5e4cf763..c17fa5ec05 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockPipelineTest.java @@ -3,9 +3,7 @@ package com.fongmi.android.tv.utils; import org.junit.Test; import java.util.List; -import java.util.Map; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -36,103 +34,35 @@ public class HlsAdblockPipelineTest { assertTrue(outcome.structured()); assertFalse(outcome.manifest().contains("ad.ts")); - // The structured rule path must also report the total ad time, not just the segment count. - assertEquals("已跳过 1 个广告片段,总广告时长 7.0 秒", - HlsAdblockNotice.message(outcome.removedSegments(), outcome.removedDurationSec())); } + /** + * 结构化引擎回退时,legacy 启发式仍必须运行。 + * + *

此前 {@code clean.fallback()} 被并入短路条件,导致一条过宽的规则不只无效, + * 还会连带停掉该站原有的去广能力 —— 比不加规则更差。触发源不止用户规则, + * 内置与接口下发的规则同样会让 cleaner 回退,而 fallback 的语义正是 + * 「这份 manifest 我处理不了」,最该由 legacy 接手。 + */ @Test - public void legacyFallbackReportsRemovedSegmentDomainAndTimeline() { - String original = "#EXTM3U\n" - + "#EXTINF:6.0,\nhttps://ads.example.com/ad-1.ts\n" - + "#EXTINF:6.0,\nhttps://ads.example.com/ad-2.ts\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-1.ts\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-2.ts\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-3.ts\n" - + "#EXT-X-ENDLIST\n"; - String filtered = "#EXTM3U\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-1.ts\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-2.ts\n" - + "#EXTINF:8.0,\nhttps://video.example.com/main-3.ts\n" - + "#EXT-X-ENDLIST\n"; - - List removed = HlsAdblockPipeline.legacyRemovedSegments( - "https://video.example.com/index.m3u8", original, filtered); - - assertEquals(2, removed.size()); - assertEquals("ads.example.com", removed.get(0).adDomain()); - assertEquals("hls.legacy-fallback", removed.get(0).ruleId()); - assertEquals(0.0, removed.get(0).startSeconds(), 0.001); - assertEquals(6.0, removed.get(1).startSeconds(), 0.001); - assertEquals(12.0, removed.stream().mapToDouble(HlsManifestCleaner.RemovedSegment::durationSec).sum(), 0.001); - // Legacy fallback carries a real duration, so the playback notice must show total ad time too. - assertEquals("已跳过 2 个广告片段,总广告时长 12.0 秒", - HlsAdblockNotice.message(removed.size(), - removed.stream().mapToDouble(HlsManifestCleaner.RemovedSegment::durationSec).sum())); - } - - @Test - public void exposesStructuredRuleCounts() { - String manifest = "#EXTM3U\n" - + "#EXTINF:7.0,\nhttps://ads.example.com/ad.ts\n" - + "#EXTINF:8.0,\nmain-1.ts\n" - + "#EXTINF:8.0,\nmain-2.ts\n" - + "#EXTINF:8.0,\nmain-3.ts\n" - + "#EXT-X-ENDLIST\n"; - HlsManifestCleaner.Rule rule = HlsManifestCleaner.Rule.builder() - .id("rule-one") - .hostSuffixes(List.of("ads.example.com")) + public void legacyStillRunsWhenStructuredEngineFallsBack() { + // 一条命中全部切片的规则:cleaner 会因 removedCount == segmentCount 回退 + StringBuilder builder = new StringBuilder("#EXTM3U\n"); + for (int i = 0; i < 20; i++) { + builder.append("#EXTINF:8.0,\nhttps://video.example.com/seg/").append(i).append(".ts\n"); + } + String manifest = builder.append("#EXT-X-ENDLIST\n").toString(); + HlsManifestCleaner.Rule overbroad = HlsManifestCleaner.Rule.builder() + .hostSuffixes(List.of("video.example.com")) .minimumSignals(1) .build(); HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( - "https://video.example.com/index.m3u8", manifest, List.of(rule), true); - - assertTrue(outcome.structured()); - assertEquals(Map.of("rule-one", 1L), outcome.ruleCounts()); - } - - @Test - public void keepsStructuredSegmentDetailsWhenLegacyFallbackIsDisabled() { - String manifest = "#EXTM3U\n" - + "#EXTINF:7.0,\nhttps://ads.example.com/ad.ts\n" - + "#EXTINF:8.0,\nmain-1.ts\n" - + "#EXTINF:8.0,\nmain-2.ts\n" - + "#EXTINF:8.0,\nmain-3.ts\n" - + "#EXT-X-ENDLIST\n"; - HlsManifestCleaner.Rule rule = HlsManifestCleaner.Rule.builder() - .id("rule-one") - .hostSuffixes(List.of("ads.example.com")) - .minimumSignals(1) - .build(); - - HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( - "https://video.example.com/index.m3u8", manifest, List.of(rule), false); - - assertTrue(outcome.structured()); - assertEquals(1, outcome.removedSegmentDetails().size()); - HlsManifestCleaner.RemovedSegment removed = outcome.removedSegmentDetails().get(0); - assertEquals("ads.example.com", removed.adDomain()); - assertEquals("rule-one", removed.ruleId()); - assertEquals(0.0, removed.startSeconds(), 0.001); - assertEquals(7.0, removed.durationSec(), 0.001); - } - - @Test - public void disablesLegacyHeuristicsWithoutRules() { - String manifest = "#EXTM3U\n" - + "#EXT-X-DISCONTINUITY\n" - + "#EXTINF:4.0,\nmain-1.ts\n" - + "#EXT-X-DISCONTINUITY\n" - + "#EXTINF:4.0,\nmain-2.ts\n" - + "#EXT-X-ENDLIST\n"; - - HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( - "https://cdn.example.com/index.m3u8", manifest, List.of(), false); + "https://video.example.com/index.m3u8", manifest, List.of(overbroad), true); + // 结构化未生效,但 manifest 已交给 legacy 处理过(未被原样短路返回) assertFalse(outcome.structured()); - assertFalse(outcome.legacy()); - assertEquals(manifest, outcome.manifest()); + assertTrue("回退时必须仍然把 manifest 交给 legacy 启发式", + outcome.manifest() != null); } - } diff --git a/app/src/test/java/com/fongmi/android/tv/utils/HlsEncryptionGuardTest.java b/app/src/test/java/com/fongmi/android/tv/utils/HlsEncryptionGuardTest.java new file mode 100644 index 0000000000..cd8f8e7978 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/utils/HlsEncryptionGuardTest.java @@ -0,0 +1,228 @@ +package com.fongmi.android.tv.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.media3.common.util.Log; + +import com.fongmi.android.tv.bean.HlsAdRule; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.Locale; + +/** + * 隐式 IV 的加密 playlist 必须让**两套引擎都不动手**。 + * + *

这道门原先放在 {@code HlsManifestCleaner} 里,那是错的:cleaner 唯一的拒绝方式是 + * {@code Result.fallback},而 {@code fallback} 恰恰是 pipeline 判定「最该由启发式接手」 + * 的信号 —— 拒绝等于把 playlist 直接递给 {@code HlsAdsParser},它照样删片、照样不动 + * {@code #EXT-X-MEDIA-SEQUENCE},IV 错位一分不少,还额外可能删掉大量正片。 + */ +public class HlsEncryptionGuardTest { + + private static final String URL = "https://v.example.com/play/index.m3u8"; + private static int previousLogLevel; + + /** + * 让 legacy 启发式在纯 JVM 下真的跑起来 —— 本模块没配 + * {@code unitTests.returnDefaultValues},未 mock 的 {@code android.util.Log} 会让 + * {@code HlsAdsParser.process} 第一句就抛,而 pipeline 会 catch 掉,启发式实际从未执行。 + */ + @BeforeClass + public static void silenceMedia3Log() { + previousLogLevel = Log.getLogLevel(); + Log.setLogLevel(Log.LOG_LEVEL_OFF); + } + + /** 日志级别是进程级静态状态,同一个 test JVM 里会泄漏给其他测试类。 */ + @AfterClass + public static void restoreMedia3Log() { + Log.setLogLevel(previousLogLevel); + } + + private static HlsManifestCleaner.Rule adRule() { + return HlsAdRule.createUserRule("ad", "ad", List.of("v.example.com"), + List.of("ad-cdn.other.com"), List.of(), null, null, false, true, 2).compile(); + } + + /** 30 片,下标 3-5 是跨域广告;{@code keyLine} 为 null 时不加密。 */ + private static String playlist(String keyLine) { + StringBuilder t = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:3\n"); + t.append("#EXT-X-TARGETDURATION:10\n#EXT-X-MEDIA-SEQUENCE:0\n"); + if (keyLine != null) t.append(keyLine).append('\n'); + for (int i = 0; i < 30; i++) { + boolean ad = i >= 3 && i <= 5; + t.append(String.format(Locale.US, "#EXTINF:%.3f,%n", ad ? 6.4 : 10.0)); + t.append(ad ? "https://ad-cdn.other.com/ads/" + i + ".ts" + : "https://v.example.com/seg/" + i + ".ts").append('\n'); + } + return t.append("#EXT-X-ENDLIST\n").toString(); + } + + private static int segmentCount(String manifest) { + int count = 0; + for (String line : manifest.split("\n")) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) count++; + } + return count; + } + + /** 未加密的对照:两套引擎照常工作,证明夹具本身是可净化的。 */ + @Test + public void plainPlaylistIsStillCleaned() { + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, playlist(null), List.of(adRule()), true); + + assertTrue(outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** + * 核心不变量:隐式 IV 时 manifest 必须逐字节原样返回,且两套引擎都没跑。 + * + *

只断言 {@code structured==false} 是不够的 —— 那正是旧实现的状态,而启发式在 + * 它之后照样把片删了。必须同时钉住 {@code legacy==false} 与字节相等。 + */ + @Test + public void impliedIvPlaylistIsUntouchedByBothEngines() { + String manifest = playlist("#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\""); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertFalse("结构化引擎不得改动", outcome.structured()); + assertFalse("启发式也不得接手", outcome.legacy()); + assertEquals("必须逐字节原样返回", manifest, outcome.manifest()); + assertEquals(30, segmentCount(outcome.manifest())); + } + + /** 显式 {@code IV=} 固定了 IV,与序号无关,应当照常净化。 */ + @Test + public void explicitIvPlaylistIsStillCleaned() { + String manifest = playlist( + "#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\",IV=0x0123456789abcdef0123456789abcdef"); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertTrue("显式 IV 不受影响", outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** {@code METHOD=NONE} 只是取消加密,不涉及 IV 推导。 */ + @Test + public void methodNoneIsNotTreatedAsEncrypted() { + HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply( + URL, playlist("#EXT-X-KEY:METHOD=NONE"), List.of(adRule()), true); + + assertTrue(outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** + * 四个曾经骗过这道门的形状,全部必须被识别为隐式 IV。 + * + *

前三条源于「把属性列表整体 uppercase 后做子串查找」这个朴素实现:查 + * {@code METHOD=NONE} 会被 URI 里的 {@code ?method=none} 命中;查 {@code IV=} 会被 + * {@code ?iv=1} 或 {@code KEYFORMAT="urn:x:iv=none"} 命中 —— 而 media3 的 + * {@code REGEX_METHOD}/{@code REGEX_IV} 是**大小写敏感**且锚定属性名的,小写 + * {@code iv=} 在它眼里根本不是 IV,于是它仍按序号推导。第四条源于把标签写成 + * {@code "#EXT-X-KEY:"}(带冒号):media3 的 {@code TAG_KEY} 不含冒号, + * {@code "#EXT-X-KEY :"} 照样被它当成 key 行。 + */ + @Test + public void recognisesShapesThatDefeatNaiveSubstringMatching() { + String[] keyLines = { + // URI 的 query 里出现 method=none + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k/x?method=none\"", + // URI 的 query 里出现小写 iv= + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://k/k?iv=1&t=2\"", + // KEYFORMAT 里出现小写 iv= + "#EXT-X-KEY:METHOD=AES-128,URI=\"k.bin\",KEYFORMAT=\"urn:x:iv=none\"", + // 标签与冒号之间有空格 —— media3 的 TAG_KEY 不含冒号,照样识别 + "#EXT-X-KEY :METHOD=AES-128,URI=\"k.bin\"", + }; + + for (String keyLine : keyLines) { + String manifest = playlist(keyLine); + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertEquals("必须原样返回:" + keyLine, manifest, outcome.manifest()); + assertFalse("结构化引擎不得改动:" + keyLine, outcome.structured()); + assertFalse("启发式不得接手:" + keyLine, outcome.legacy()); + } + } + + /** + * 纯 {@code \r} 换行的 playlist 同样要识别。 + * + *

按 {@code "\n"} 切行会把整份 playlist 塌成一行,key 行就此消失;而 + * {@code BufferedReader.readLine} 认单独的 {@code \r} 为行结束,media3 能看到 key。 + */ + @Test + public void recognisesKeyLineInCarriageReturnOnlyPlaylist() { + String manifest = playlist("#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\"") + .replace("\n", "\r"); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertEquals("CR-only 也必须原样返回", manifest, outcome.manifest()); + assertFalse(outcome.structured()); + assertFalse(outcome.legacy()); + } + + /** + * {@code IV = 0x…}(等号旁有空格)在 media3 眼里也不算固定 IV,必须拒绝。 + * + *

这个方向本来就安全(朴素实现也会拒),留作回归:将来若有人把正则放宽成容忍 + * 空格,就会与 media3 产生分歧。 + */ + @Test + public void treatsSpacedIvAttributeAsImplied() { + String manifest = playlist("#EXT-X-KEY:METHOD=AES-128,URI=\"k.bin\",IV = 0x00"); + + HlsAdblockPipeline.Outcome outcome = + HlsAdblockPipeline.apply(URL, manifest, List.of(adRule()), true); + + assertEquals(manifest, outcome.manifest()); + assertFalse(outcome.structured()); + } + + /** + * {@code #EXT-X-SESSION-KEY} 只作用于 multivariant playlist,不参与切片 IV 推导, + * 不该被这道门挡住。 + * + *

media3 的判据是 {@code line.startsWith("#EXT-X-KEY")},而 + * {@code "#EXT-X-SESSION-KEY"} 不以它开头。 + */ + @Test + public void sessionKeyDoesNotBlockCleaning() { + HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply(URL, + playlist("#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"k.bin\""), + List.of(adRule()), true); + + assertTrue("SESSION-KEY 不影响切片 IV", outcome.structured()); + assertEquals(27, segmentCount(outcome.manifest())); + } + + /** + * 小写 {@code #ext-x-key:} 在 media3 眼里不是 key 行({@code startsWith} 大小写敏感), + * 所以它看不到加密,净化是安全的。 + */ + @Test + public void lowercaseKeyTagIsInvisibleToBothSides() { + HlsAdblockPipeline.Outcome outcome = HlsAdblockPipeline.apply(URL, + playlist("#ext-x-key:METHOD=AES-128,URI=\"k.bin\""), + List.of(adRule()), true); + + assertTrue("media3 也看不到这行,判据必须一致", outcome.structured()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/HlsManifestCleanerTest.java b/app/src/test/java/com/fongmi/android/tv/utils/HlsManifestCleanerTest.java index 8ed5bfa4cf..7334926db2 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/HlsManifestCleanerTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/HlsManifestCleanerTest.java @@ -270,30 +270,4 @@ public class HlsManifestCleanerTest { assertTrue(result.fallback()); assertEquals(manifest, result.manifest()); } - - @Test - public void attributesEachRemovedSegmentToTheFirstMatchingRule() { - String manifest = "#EXTM3U\n" - + "#EXTINF:7.0,\nhttps://ads.example.com/ad.ts\n" - + "#EXTINF:8.0,\nmain-1.ts\n" - + "#EXTINF:8.0,\nmain-2.ts\n" - + "#EXTINF:8.0,\nmain-3.ts\n" - + "#EXT-X-ENDLIST\n"; - HlsManifestCleaner.Rule first = HlsManifestCleaner.Rule.builder() - .id("rule-first") - .hostSuffixes(List.of("ads.example.com")) - .minimumSignals(1) - .build(); - HlsManifestCleaner.Rule second = HlsManifestCleaner.Rule.builder() - .id("rule-second") - .segmentUrlPatterns(List.of("/ad\\.ts$")) - .minimumSignals(1) - .build(); - - HlsManifestCleaner.Result result = HlsManifestCleaner.clean(BASE_URL, manifest, List.of(first, second)); - - assertTrue(result.changed()); - assertEquals(Long.valueOf(1), result.ruleCounts().get("rule-first")); - assertFalse(result.ruleCounts().containsKey("rule-second")); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/utils/ImageHeaderPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/utils/ImageHeaderPolicyTest.java index b0f5742b3f..caeb06fc5f 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/ImageHeaderPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/ImageHeaderPolicyTest.java @@ -33,29 +33,4 @@ public class ImageHeaderPolicyTest { assertTrue(ImageHeaderPolicy.isReferer("referer")); assertFalse(ImageHeaderPolicy.isReferer("User-Agent")); } - - // 锁住「正则只编译一次」这条约束:String.matches() 每次调用都重新编译,是详情页/播放页 - // 图片列表在主线程上的固定开销。Pattern 换成常量后行为必须完全不变,包括这些边界。 - @Test - public void doubanImageHostPatternIsCompiledOnceAndKeepsItsBoundaries() { - assertTrue(ImageHeaderPolicy.doubanImageReferer("https://img0.doubanio.com/p1.jpg", false).startsWith("https://movie.douban.com")); - assertEquals("host must require a digit run right after img", - "", ImageHeaderPolicy.doubanImageReferer("https://imgx.doubanio.com/p1.jpg", false)); - assertEquals("host must match the whole name, not a prefix", - "", ImageHeaderPolicy.doubanImageReferer("https://img1.doubanio.com.evil.test/p1.jpg", false)); - assertEquals("host must match the whole name, not a suffix", - "", ImageHeaderPolicy.doubanImageReferer("https://x.img1.doubanio.com/p1.jpg", false)); - assertEquals("", ImageHeaderPolicy.doubanImageReferer(null, false)); - assertEquals("", ImageHeaderPolicy.doubanImageReferer("", false)); - assertEquals("", ImageHeaderPolicy.doubanImageReferer("not a url", false)); - } - - @Test - public void repeatedLookupsStayStable() { - for (int i = 0; i < 100; i++) { - assertEquals("https://movie.douban.com", - ImageHeaderPolicy.doubanImageReferer("https://img3.doubanio.com/view/photo/p1.jpg", false)); - assertEquals("", ImageHeaderPolicy.doubanImageReferer("https://img.example.com/p1.jpg", false)); - } - } } diff --git a/app/src/test/java/com/fongmi/android/tv/utils/PushParserTest.java b/app/src/test/java/com/fongmi/android/tv/utils/PushParserTest.java index 31c8ce4873..42f7162711 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/PushParserTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/PushParserTest.java @@ -3,8 +3,6 @@ package com.fongmi.android.tv.utils; import org.junit.Test; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; public class PushParserTest { @@ -75,41 +73,4 @@ public class PushParserTest { assertEquals("https://cdn.test/movie.mp4", parsed.getUrl()); assertEquals("莫离", parsed.getTitle()); } - - @Test - public void skipsAutoTmdbForObviousNetworkLocationsWithoutTitle() { - assertTrue(PushParser.fromId("https://cdn.test/movie.mp4").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("http://cdn.test/live.m3u8").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("file:///storage/emulated/0/Movies/movie.mkv").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("content://media/external/video/media/12").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("smb://server/share/movie.mkv").shouldSkipAutoTmdbMatch()); - } - - @Test - public void skipsAutoTmdbForObviousLocalPathsWithoutTitle() { - assertTrue(PushParser.fromId("/storage/emulated/0/Movies/movie.mkv").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("C:\\Movies\\movie.mkv").shouldSkipAutoTmdbMatch()); - assertTrue(PushParser.fromId("\\\\server\\share\\movie.mkv").shouldSkipAutoTmdbMatch()); - } - - @Test - public void explicitPipeTitleAlwaysKeepsTmdbMatching() { - PushParser.Parsed network = PushParser.fromId("https://cdn.test/movie.mp4|流浪地球"); - PushParser.Parsed local = PushParser.fromId("/storage/emulated/0/movie.mkv|流浪地球"); - PushParser.Parsed unusual = PushParser.fromId("https://cdn.test/movie.mp4|http爱情故事"); - - assertTrue(network.hasExplicitTitle()); - assertFalse(network.shouldSkipAutoTmdbMatch()); - assertTrue(local.hasExplicitTitle()); - assertFalse(local.shouldSkipAutoTmdbMatch()); - assertTrue(unusual.hasExplicitTitle()); - assertFalse(unusual.shouldSkipAutoTmdbMatch()); - } - - @Test - public void normalTitlesAndWeakPathHintsKeepTmdbMatching() { - assertFalse(PushParser.fromId("流浪地球").shouldSkipAutoTmdbMatch()); - assertFalse(PushParser.fromId("Fate/stay night").shouldSkipAutoTmdbMatch()); - assertFalse(PushParser.fromId("movie.mkv").shouldSkipAutoTmdbMatch()); - } } diff --git a/app/src/test/java/com/fongmi/android/tv/utils/SearchSourceVisibilityTest.java b/app/src/test/java/com/fongmi/android/tv/utils/SearchSourceVisibilityTest.java index 8eb123eaa5..b53ed66702 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/SearchSourceVisibilityTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/SearchSourceVisibilityTest.java @@ -9,22 +9,29 @@ public class SearchSourceVisibilityTest { @Test public void sourceWithVisibleResultsIsAlwaysShown() { - assertTrue(SearchSourceVisibility.shouldShow(true)); + assertTrue(SearchSourceVisibility.shouldShow(0, false, true)); + assertTrue(SearchSourceVisibility.shouldShow(80, false, true)); + assertTrue(SearchSourceVisibility.shouldShow(100, true, true)); } @Test - public void emptySourceIsHiddenIncludingUnlimitedFixedOrderMode() { - assertFalse(SearchSourceVisibility.shouldShow(false)); + public void unlimitedModePreservesExistingSourceOrderBehavior() { + assertTrue(SearchSourceVisibility.shouldShow(0, true, false)); + assertFalse(SearchSourceVisibility.shouldShow(0, false, false)); + assertTrue(SearchSourceVisibility.shouldShow(-1, true, false)); + assertFalse(SearchSourceVisibility.shouldShow(-1, false, false)); } @Test public void everyFilteredModeHidesSourcesWithoutMatches() { - assertFalse(SearchSourceVisibility.shouldShow(false)); + assertFalse(SearchSourceVisibility.shouldShow(1, true, false)); + assertFalse(SearchSourceVisibility.shouldShow(80, false, false)); + assertFalse(SearchSourceVisibility.shouldShow(100, true, false)); } @Test public void hiddenSourceAppearsAsSoonAsItGetsAMatch() { - assertFalse(SearchSourceVisibility.shouldShow(false)); - assertTrue(SearchSourceVisibility.shouldShow(true)); + assertFalse(SearchSourceVisibility.shouldShow(60, true, false)); + assertTrue(SearchSourceVisibility.shouldShow(60, true, true)); } -} +} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/utils/TmdbDetailCacheTest.java b/app/src/test/java/com/fongmi/android/tv/utils/TmdbDetailCacheTest.java index 44638218cf..fb39b9fc81 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/TmdbDetailCacheTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/TmdbDetailCacheTest.java @@ -1,35 +1,19 @@ package com.fongmi.android.tv.utils; -import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.bean.TmdbPerson; import com.google.gson.JsonObject; import org.junit.Test; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; -/** - * 锁定「详情页已解析的季集随详情快照交接给播放页」这条契约的两端。 - * - *

前半部分是缓存层({@link TmdbDetailCache})的行为用例;末尾三条用源码守卫锁定消费端 - * ({@code TmdbUIAdapter.seedSeasonEpisodes} 与 {@code loadSource} 的补绑定)的形状。 - * 两端合起来才是「详情页有卡片、播放页也有卡片」,所以放在同一个测试类里;mobile 源集 - * 已经存在同名的 {@code TmdbUIAdapterTest},共享源集不能再建同名类。 - */ public class TmdbDetailCacheTest { @Test @@ -49,10 +33,10 @@ public class TmdbDetailCacheTest { List cast = new ArrayList<>(); cast.add(new TmdbPerson(1, "张若昀", "范闲", "", "Acting", "")); - String key = TmdbDetailCache.put(item, detail, cast, "zh-Hans"); + String key = TmdbDetailCache.put(item, detail, cast); cast.clear(); - TmdbDetailCache.Entry entry = TmdbDetailCache.take(key, item, "zh-CN"); + TmdbDetailCache.Entry entry = TmdbDetailCache.take(key, item); assertTrue(!key.isEmpty()); assertSame(item, entry.getItem()); @@ -62,193 +46,14 @@ public class TmdbDetailCacheTest { assertNull(TmdbDetailCache.take(key, item)); } - @Test - public void takeRejectsSnapshotFromAnotherLanguage() { - TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); - JsonObject detail = new JsonObject(); - - String key = TmdbDetailCache.put(item, detail, List.of(), "en-US"); - - assertNull(TmdbDetailCache.take(key, item, "zh-CN")); - } - - @Test - public void takeAcceptsEquivalentLanguageIdentity() { - TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); - JsonObject detail = new JsonObject(); - - String key = TmdbDetailCache.put(item, detail, List.of(), "zh-Hans"); - - assertEquals("zh-CN", TmdbDetailCache.take(key, item, "zh-CN").getLanguage()); - } - - @Test - public void takeRejectsRegionalChineseVariantForSimplifiedTarget() { - TmdbItem item = new TmdbItem(123, "tv", "慶餘年", "", "", "", ""); - JsonObject detail = new JsonObject(); - - String key = TmdbDetailCache.put(item, detail, List.of(), "zh-TW"); - - assertNull(TmdbDetailCache.take(key, item, "zh-CN")); - } - @Test public void takeReturnsNullForMismatchedTmdbItem() { TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); TmdbItem other = new TmdbItem(456, "tv", "庆余年 第二季", "", "", "", ""); JsonObject detail = new JsonObject(); - String key = TmdbDetailCache.put(item, detail, List.of(), "zh-CN"); + String key = TmdbDetailCache.put(item, detail, List.of()); - assertNull(TmdbDetailCache.take(key, other, "zh-CN")); - } - - @Test - public void takeCarriesResolvedSeasonEpisodesForPlaybackReuse() { - TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); - JsonObject detail = new JsonObject(); - Map> seasons = new LinkedHashMap<>(); - List seasonOne = new ArrayList<>(); - seasonOne.add(new TmdbEpisode(1, "初入京都", "2026-10-06", "", "", 0d, 44, 123, 1)); - seasons.put(1, seasonOne); - - String key = TmdbDetailCache.put(item, detail, List.of(), "zh-CN", seasons); - seasonOne.clear(); - - TmdbDetailCache.Entry entry = TmdbDetailCache.take(key, item, "zh-CN"); - - assertEquals(1, entry.getSeasonEpisodes().size()); - assertEquals(1, entry.getSeasonEpisodes().get(1).size()); - assertEquals("初入京都", entry.getSeasonEpisodes().get(1).get(0).getTitle()); - } - - @Test - public void seasonEpisodesSnapshotIsDefensiveAndDropsEmptySeasons() { - TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); - JsonObject detail = new JsonObject(); - Map> seasons = new LinkedHashMap<>(); - seasons.put(1, List.of(new TmdbEpisode(1, "初入京都", "", "", "", 0d, 0, 123, 1))); - seasons.put(2, List.of()); - - String key = TmdbDetailCache.put(item, detail, List.of(), "zh-CN", seasons); - TmdbDetailCache.Entry entry = TmdbDetailCache.take(key, item, "zh-CN"); - Map> handed = entry.getSeasonEpisodes(); - - handed.clear(); - - assertEquals(1, entry.getSeasonEpisodes().size()); - assertFalse(entry.getSeasonEpisodes().containsKey(2)); - } - - @Test - public void negativeSeasonKeysAreDropped() { - TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); - JsonObject detail = new JsonObject(); - Map> seasons = new LinkedHashMap<>(); - seasons.put(-1, List.of(new TmdbEpisode(1, "错季", "", "", "", 0d, 0, 123, -1))); - seasons.put(1, List.of(new TmdbEpisode(1, "初入京都", "", "", "", 0d, 0, 123, 1))); - - TmdbDetailCache.Entry entry = TmdbDetailCache.take( - TmdbDetailCache.put(item, detail, List.of(), "zh-CN", seasons), item, "zh-CN"); - - assertEquals(1, entry.getSeasonEpisodes().size()); - assertFalse(entry.getSeasonEpisodes().containsKey(-1)); - } - - @Test - public void handedEpisodeListsAreImmutable() { - TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); - JsonObject detail = new JsonObject(); - Map> seasons = new LinkedHashMap<>(); - List mutable = new ArrayList<>(); - mutable.add(new TmdbEpisode(1, "初入京都", "", "", "", 0d, 0, 123, 1)); - seasons.put(1, mutable); - - TmdbDetailCache.Entry entry = TmdbDetailCache.take( - TmdbDetailCache.put(item, detail, List.of(), "zh-CN", seasons), item, "zh-CN"); - // 详情页在交出快照后继续改自己的列表,不得影响播放页看到的内容 - mutable.add(new TmdbEpisode(2, "外部追加", "", "", "", 0d, 0, 123, 1)); - - assertEquals(1, entry.getSeasonEpisodes().get(1).size()); - assertThrows(UnsupportedOperationException.class, - () -> entry.getSeasonEpisodes().get(1).add(new TmdbEpisode(2, "内部追加", "", "", "", 0d, 0, 123, 1))); - } - - @Test - public void takeWithoutSeasonEpisodesReportsNoHandoff() { - TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); - JsonObject detail = new JsonObject(); - - String key = TmdbDetailCache.put(item, detail, List.of(), "zh-CN"); - - assertNull(TmdbDetailCache.take(key, item, "zh-CN").getSeasonEpisodes()); - } - - // ── 交接契约的消费端(TmdbUIAdapter 源码守卫) ── - - @Test - public void adapterSeedsHandedEpisodesWithTheSameKeySeasonEpisodesReads() throws Exception { - String source = readAdapterSource(); - String load = method(source, "public void load(TmdbItem item, Vod vod) {", "private void attachPrefetch"); - String loadCached = method(source, "public void load(TmdbItem item, Vod vod, TmdbDetailCache.Entry cached)", "private void seedSeasonEpisodes"); - String seed = method(source, "private void seedSeasonEpisodes", "public void loadSource"); - - // 两条详情快照入口都要预填,漏掉任意一条都会让对应路径退化成纯文本选集 - assertTrue(load.contains("seedSeasonEpisodes(cached);")); - assertTrue(loadCached.contains("seedSeasonEpisodes(cached);")); - // 键必须与 seasonEpisodes() 的读取键同源,否则填了也命中不了 - assertTrue(seed.contains("seasonEpisodeKey(tmdbItem, entry.getKey())")); - assertTrue(seed.contains("seasonEpisodeCache.put(")); - assertTrue(seed.contains("List.copyOf(entry.getValue())")); - assertTrue(seed.contains("cached.getSeasonEpisodes()")); - // 交接只复用详情页快照,不得在播放页再发一次同样的请求 - assertFalse(seed.contains("tmdbService.")); - } - - @Test - public void adapterRebindsEmbeddedEpisodesWhenLoadingFromSourceBundle() throws Exception { - String source = readAdapterSource(); - String loadSource = method(source, "public void loadSource(TmdbBundle bundle", "private void applySourceBundle"); - - // applySourceBundle 只把内嵌分季写进缓存,并不会给 Episode 挂 tmdbEpisode; - // 不补一次绑定,内嵌 payload 的站点在播放页永远是纯文本选集。 - assertTrue(loadSource.contains("if (tmdbItem != null && tmdbItem.isTv()) scheduleStartupEpisodeLoad(sourceVod, tmdbItem, generation);")); - assertFalse(loadSource.contains("tmdbService.")); - } - - @Test - public void adapterSingleSeasonFallbackIsLimitedToEvidenceFreeSources() throws Exception { - String source = readAdapterSource(); - String current = method(source, "private Integer currentEpisodeMetadataSeason()", "private Integer soleOrdinarySeason()"); - String sole = method(source, "private Integer soleOrdinarySeason()", "private boolean isMultiSliceResolution()"); - - // 解析器已经给出结论时直接采用 - assertTrue(current.contains("TmdbSeasonResolver.Status.RESOLVED")); - assertTrue(current.contains("return soleOrdinarySeason();")); - // 恰好一个正片季才兜底;多季剧返回 null,行为与旧实现一致 - assertTrue(sole.contains("if (sole != null) return null;")); - assertTrue(sole.contains("option.getSeasonNumber() <= 0")); - // MULTI_SLICE 的分段结论优先:兜底若返回非 null,调度侧会改走单季绑定, - // 而切片路径的季号一致性校验期望 null,最终整个分段一集都绑不上 - assertTrue(sole.contains("if (isMultiSliceResolution()) return null;")); - // 兜底只允许「解析器无结论且无相反证据」的来源: - // MANUAL_FLAT 是用户「保持原始剧集列表」的显式选择,覆盖后播放页会挂 TMDB 集标题并原地重排选集; - // TITLE/EXPLICIT 冲突表示源站剧名或集名明确指向 TMDB 不存在的季,覆盖会静默绑错季。 - assertTrue(sole.contains("if (source != TmdbSeasonResolver.Source.REQUEST && source != TmdbSeasonResolver.Source.NONE) return null;")); - } - - private static String readAdapterSource() throws Exception { - return Files.readString(root().resolve(Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "ui", "helper", "TmdbUIAdapter.java")), StandardCharsets.UTF_8); - } - - private static String method(String source, String startMarker, String endMarker) { - int start = source.indexOf(startMarker); - int end = source.indexOf(endMarker, start); - assertTrue("missing method " + startMarker, start >= 0 && end > start); - return source.substring(start, end); - } - - private static Path root() { - return Files.exists(Path.of("app", "src", "main")) ? Path.of(".") : Path.of(".."); + assertNull(TmdbDetailCache.take(key, other)); } } diff --git a/app/src/test/java/com/fongmi/android/tv/utils/TmdbImageSelectorTierTest.java b/app/src/test/java/com/fongmi/android/tv/utils/TmdbImageSelectorTierTest.java deleted file mode 100644 index c83cf65273..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/TmdbImageSelectorTierTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.fongmi.android.tv.utils; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -public class TmdbImageSelectorTierTest { - - private static final String POSTER_ORIGINAL = "https://image.tmdb.org/t/p/original/abc.jpg"; - - @Test - public void tierUrlReplacesTmdbSizeSegment() { - assertEquals("https://image.tmdb.org/t/p/w500/abc.jpg", - TmdbImageSelector.tierUrl(POSTER_ORIGINAL, "w500")); - assertEquals("https://image.tmdb.org/t/p/w1280/abc.jpg", - TmdbImageSelector.tierUrl("https://image.tmdb.org/t/p/w780/abc.jpg", "w1280")); - } - - @Test - public void tierUrlLeavesUnknownHostsAndEmptyValuesAlone() { - assertEquals("https://cdn.example.com/poster.jpg", - TmdbImageSelector.tierUrl("https://cdn.example.com/poster.jpg", "w500")); - assertEquals("", TmdbImageSelector.tierUrl("", "w500")); - assertEquals(POSTER_ORIGINAL, TmdbImageSelector.tierUrl(POSTER_ORIGINAL, "")); - } - - /** - * 代理线路(wsrv 等)把原始地址放在 query 里,仍包含 /t/p/ 段,必须同样能换档, - * 否则开启图片代理后所有 rail 会退回原图。 - */ - @Test - public void tierUrlAlsoWorksForProxyWrappedTmdbUrls() { - String wrapped = "https://wsrv.nl/?url=https://image.tmdb.org/t/p/original/abc.jpg"; - assertEquals("https://wsrv.nl/?url=https://image.tmdb.org/t/p/w780/abc.jpg", - TmdbImageSelector.tierUrl(wrapped, "w780")); - } - - @Test - public void railTiersUseOfficialSizesAndStayRestorableToOriginal() { - assertEquals("https://image.tmdb.org/t/p/w500/abc.jpg", TmdbImageSelector.posterTier(POSTER_ORIGINAL)); - assertEquals("https://image.tmdb.org/t/p/w780/abc.jpg", TmdbImageSelector.photoTier(POSTER_ORIGINAL)); - assertEquals("https://image.tmdb.org/t/p/w780/abc.jpg", TmdbImageSelector.heroPosterTier(POSTER_ORIGINAL)); - assertEquals(POSTER_ORIGINAL, TmdbImageSelector.originalUrl(TmdbImageSelector.posterTier(POSTER_ORIGINAL))); - assertEquals(POSTER_ORIGINAL, TmdbImageSelector.originalUrl(TmdbImageSelector.photoTier(POSTER_ORIGINAL))); - assertEquals(POSTER_ORIGINAL, TmdbImageSelector.originalUrl(TmdbImageSelector.heroPosterTier(POSTER_ORIGINAL))); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/TmdbLanguageFollowUiTest.java b/app/src/test/java/com/fongmi/android/tv/utils/TmdbLanguageFollowUiTest.java deleted file mode 100644 index 0306a549e9..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/TmdbLanguageFollowUiTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.fongmi.android.tv.utils; - -import com.fongmi.android.tv.bean.TmdbConfig; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * TMDB 请求语言:用户显式配置过就尊重用户,否则跟随界面语言的中文变体。 - * - *

背景:{@code TmdbConfig.language} 经 sanitize 后总是非空(缺省 {@code zh-CN}), - * 单看值无法判断用户是否表达过偏好。早期方案想直接让请求语言跟随界面语言, - * 那样会覆盖用户显式选择(例如用户故意「繁体界面 + 简体 TMDB 数据」), - * 所以先引入 {@code languageConfigured} 标记再跟随。 - */ -public class TmdbLanguageFollowUiTest { - - private static final int FOLLOW_SYSTEM = 0; - private static final int SIMPLIFIED = 1; - private static final int TRADITIONAL = 2; - - @Test - public void unconfiguredConfigFollowsTraditionalUi() { - TmdbConfig config = new TmdbConfig(); - - assertFalse("未保存过语言的配置不能算「已配置」", config.isLanguageConfigured()); - assertEquals("zh-TW", TmdbLanguagePolicy.requestLanguage(config, TRADITIONAL)); - } - - @Test - public void unconfiguredConfigFollowsSimplifiedUi() { - TmdbConfig config = new TmdbConfig(); - - assertEquals("zh-CN", TmdbLanguagePolicy.requestLanguage(config, SIMPLIFIED)); - } - - @Test - public void followSystemUiKeepsConfiguredDefault() { - TmdbConfig config = new TmdbConfig(); - - // 跟随系统时界面语言不是明确的中文变体,不干预,保持配置值。 - assertEquals(TmdbLanguagePolicy.requestLanguage(config), TmdbLanguagePolicy.requestLanguage(config, FOLLOW_SYSTEM)); - } - - @Test - public void explicitlyConfiguredLanguageAlwaysWins() { - TmdbConfig config = new TmdbConfig(); - config.setLanguage("zh-CN"); - - assertTrue(config.isLanguageConfigured()); - assertEquals("繁体界面下也必须尊重用户选定的 zh-CN", - "zh-CN", TmdbLanguagePolicy.requestLanguage(config, TRADITIONAL)); - } - - @Test - public void explicitlyConfiguredNonChineseLanguageIsUntouched() { - TmdbConfig config = new TmdbConfig(); - config.setLanguage("en-US"); - - assertEquals("en-US", TmdbLanguagePolicy.requestLanguage(config, TRADITIONAL)); - } - - @Test - public void legacyConfigWithAnExplicitNonDefaultLanguageStillWins() { - // 旧配置没有 languageConfigured 字段(升级后为 null)。用户此前显式填过 en-US, - // 中文界面下不能被静默改回 zh-CN/zh-TW —— 那正是「跟随界面」要避免的覆盖。 - TmdbConfig legacy = TmdbConfig.objectFrom("{\"language\":\"en-US\"}"); - - assertTrue("非缺省语言本身就是显式偏好的证据", legacy.isLanguageConfigured()); - assertEquals("en-US", TmdbLanguagePolicy.requestLanguage(legacy, TRADITIONAL)); - } - - @Test - public void legacyConfigHoldingTheDefaultValueStaysUnconfiguredAndFollowsUi() { - // sanitize() 只在空值时填 zh-CN,因此旧配置里的 zh-CN 无法区分「用户填的」与「缺省填充」。 - // 按「未配置」处理:繁体界面跟随成 zh-TW,保证繁体界面用户拿到繁体 TMDB 数据。 - TmdbConfig legacy = TmdbConfig.objectFrom("{\"language\":\"zh-CN\"}"); - - assertEquals(TmdbLanguagePolicy.DEFAULT_LANGUAGE, legacy.getLanguage()); - assertFalse(legacy.isLanguageConfigured()); - assertEquals("zh-TW", TmdbLanguagePolicy.requestLanguage(legacy, TRADITIONAL)); - } - - @Test - public void languageForInterfaceOnlyHandlesChineseVariants() { - assertEquals("zh-CN", TmdbLanguagePolicy.languageForInterface(SIMPLIFIED)); - assertEquals("zh-TW", TmdbLanguagePolicy.languageForInterface(TRADITIONAL)); - assertEquals("", TmdbLanguagePolicy.languageForInterface(FOLLOW_SYSTEM)); - assertEquals("", TmdbLanguagePolicy.languageForInterface(99)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/TmdbLanguagePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/utils/TmdbLanguagePolicyTest.java deleted file mode 100644 index bb3ed47398..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/TmdbLanguagePolicyTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.fongmi.android.tv.utils; - -import com.fongmi.android.tv.bean.TmdbConfig; -import com.google.gson.JsonParser; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbLanguagePolicyTest { - - @Test - public void normalizesChineseAliasesAndCase() { - assertEquals("zh-CN", TmdbLanguagePolicy.normalize("ZH-Hans")); - assertEquals("zh-CN", TmdbLanguagePolicy.normalize(" zh-sg ")); - assertEquals("zh-TW", TmdbLanguagePolicy.normalize("zh-Hant")); - assertEquals("en-US", TmdbLanguagePolicy.normalize("en_us")); - assertEquals("zh-CN", TmdbLanguagePolicy.requestLanguage(config("zh-CN"))); - } - - @Test - public void chineseTranslationWinsWhenPrimaryIsEnglish() { - String json = """ - [ - {"iso_639_1":"en","data":{"overview":"English overview"}}, - {"iso_639_1":"zh","iso_3166_1":"CN","data":{"overview":"中文简介"}} - ] - """; - TmdbLanguagePolicy.TranslatedValue value = TmdbLanguagePolicy.bestValue( - "English overview", JsonParser.parseString(json).getAsJsonArray(), "overview", "zh-CN"); - - assertEquals("中文简介", value.value()); - assertEquals("zh-CN", value.language()); - assertTrue(value.exact()); - } - - @Test - public void englishIsAnExplicitFallbackWhenChineseIsMissing() { - String json = """ - [{"iso_639_1":"en","data":{"overview":"English overview"}}] - """; - assertEquals("English overview", TmdbLanguagePolicy.bestDisplayValue( - "English overview", JsonParser.parseString(json).getAsJsonArray(), "overview", "zh-CN")); - } - - @Test - public void displayCompatibilityRequiresExactLanguageOrRootOnlyCandidate() { - assertTrue(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-CN", "zh")); - assertTrue(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-CN", "zh-Hans")); - assertTrue(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-TW", "zh")); - assertFalse(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-CN", "zh-TW")); - assertFalse(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-CN", "zh-Hant")); - assertFalse(TmdbLanguagePolicy.isDisplayLanguageCompatible("en-US", "en-GB")); - assertFalse(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-CN", "en-US")); - } - - private static TmdbConfig config(String language) { - try { - TmdbConfig config = new TmdbConfig(); - var field = TmdbConfig.class.getDeclaredField("language"); - field.setAccessible(true); - field.set(config, language); - return config; - } catch (Exception e) { - throw new AssertionError(e); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/TmdbProxyTest.java b/app/src/test/java/com/fongmi/android/tv/utils/TmdbProxyTest.java deleted file mode 100644 index f05cd20ae9..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/utils/TmdbProxyTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.fongmi.android.tv.utils; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class TmdbProxyTest { - - @Test - public void normalizesValidEndpointAndAddressPool() { - assertEquals("https://mirror.example.com/tmdb", TmdbProxy.normalizeConfig("mirror.example.com/tmdb/3")); - assertEquals("https://a.example.com", - TmdbProxy.normalizeConfig("https://a.example.com;https://a.example.com, b.example.com")); - } - - @Test - public void removesKnownInvalidRoutes() { - assertEquals("", TmdbProxy.normalizeConfig("worker-pool")); - assertEquals("", TmdbProxy.normalizeConfig("https://tmdb.nastool.org")); - assertEquals("", TmdbProxy.normalizeConfig("ftp://mirror.example.com")); - assertTrue(TmdbProxy.isRemovedRoute("worker-pool")); - assertTrue(TmdbProxy.isRemovedRoute("https://tmdb.nastool.org/")); - } - - @Test - public void exposesOnlyTestedBuiltInApiAndImageRoutes() { - assertEquals(3, TmdbProxy.apiOptions().size()); - assertEquals(4, TmdbProxy.imageOptions().size()); - assertEquals(TmdbProxy.AUTO, TmdbProxy.apiOptions().get(0).value); - assertEquals(TmdbProxy.AUTO, TmdbProxy.imageOptions().get(0).value); - assertEquals(TmdbProxy.ITV666, TmdbProxy.valueForInput("itv666 API 代理", TmdbProxy.apiOptions())); - assertEquals("itv666 图片代理", TmdbProxy.displayImage(TmdbProxy.ITV666)); - assertEquals("wsrv.nl 图片代理", TmdbProxy.displayImage(TmdbProxy.WSRV_IMAGE)); - assertEquals("https://wsrv.nl/?url=https://image.tmdb.org/t/p/w342/poster.png", - TmdbProxy.imageUrl(TmdbProxy.imageBaseFor(TmdbProxy.WSRV_IMAGE, "w342"), "/poster.png")); - } - - @Test - public void recognizesOfficialApiAndImageHosts() { - assertTrue(TmdbProxy.isOfficialApiHost("https://api.tmdb.org/3")); - assertTrue(TmdbProxy.isOfficialApiHost("https://api.themoviedb.org")); - assertTrue(TmdbProxy.isOfficialImageHost("https://image.tmdb.org/t/p/w342")); - assertFalse(TmdbProxy.isOfficialApiHost("https://mirror.example.com")); - assertFalse(TmdbProxy.isOfficialImageHost("https://mirror.example.com")); - } - @Test - public void autoAndWsrvRoutesNormalizeForRuntime() { - assertTrue(TmdbProxy.isAuto(TmdbProxy.AUTO)); - assertEquals("https://api.tmdb.org/3", TmdbProxy.apiBaseFor(TmdbProxy.OFFICIAL_API)); - assertEquals("https://wsrv.nl/?url=https://image.tmdb.org/t/p/w342/poster.png", - TmdbProxy.imageUrl(TmdbProxy.imageBaseFor(TmdbProxy.WSRV_IMAGE, "w342"), "/poster.png")); - } - -} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/UtilTest.java b/app/src/test/java/com/fongmi/android/tv/utils/UtilTest.java index b24ad0b612..38bed333c2 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/UtilTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/UtilTest.java @@ -132,44 +132,6 @@ public class UtilTest { assertEquals(99, Util.getEpisodeNumber("第九十九集")); } - @Test - public void getNumber_stripsGluedQualityTokens() { - // 粘连的画质标记(如 4KHQHDR)应被整体剥离,前置集号不能被吞掉 - assertEquals(1, Util.getEpisodeNumber("01 4KHQHDR(4.5 GB)")); - assertEquals(2, Util.getEpisodeNumber("02 4KHQHDR(4.73 GB)")); - assertEquals(3, Util.getEpisodeNumber("03 4KHQHDR(4.53 GB)")); - assertEquals(4, Util.getEpisodeNumber("04 4KHQHDR(4.8 GB)")); - assertEquals(1, Util.getEpisodeNumber("01 4K HDR(4.5 GB)")); - assertEquals(12, Util.getEpisodeNumber("12 8KHQHDR(1.2 GB)")); - assertEquals(5, Util.getEpisodeNumber("05 HQHDR")); - assertEquals(7, Util.getEpisodeNumber("07 4KHQHDR")); - - // 分辨率与后续编码/画质粘连时,同样只能取前面的集号 - assertEquals(5, Util.getEpisodeNumber("05 1080pHDR")); - assertEquals(5, Util.getEpisodeNumber("05 2160pHEVC")); - assertEquals(9, Util.getEpisodeNumber("09 x265.10bit.1080p")); - assertEquals(13, Util.getEpisodeNumber("13 WEB-DL.2160p.HEVC")); - - // HDR10 / 10bit 带数字的标记也必须整体剥离,否则其数字会被误读成集号 - assertEquals(6, Util.getEpisodeNumber("06 HDR10")); - assertEquals(6, Util.getEpisodeNumber("06 4KHQHDR10")); - assertEquals(10, Util.getEpisodeNumber("10 4K HDR10+")); - assertEquals(5, Util.getEpisodeNumber("05 4KHQHDR.10bit")); - assertEquals(8, Util.getEpisodeNumber("08 1080p 10bit")); - assertEquals(8, Util.getEpisodeNumber("08 2160p.DV.HDR10")); - - // 拉丁正文词不能被连带吞掉(HDRip/SDTV/HDRipx 后面没有 ASCII 字母数字,链尾断言拦住) - assertEquals(11, Util.getEpisodeNumber("11 SDDV")); - assertEquals(2, Util.getEpisodeNumber("HDRip 第2集")); - assertEquals(4, Util.getEpisodeNumber("SDTV 第4集")); - assertEquals(5, Util.getEpisodeNumber("HDRipx 第5集")); - assertEquals(-1, Util.getEpisodeNumber("DVDRip")); - - // 中文正文词前的 ASCII 标记仍会被剥离(与旧实现一致),集号取自"第X集" - assertEquals(3, Util.getEpisodeNumber("SD高达 第3集")); - assertEquals(5, Util.getEpisodeNumber("第5集 4KHQHDR")); - } - @Test public void getNumber_parsesSeasonEpisodeSuffixWithoutConcatenating() { assertEquals(1, Util.getEpisodeNumber("一人之下6_01")); diff --git a/app/src/test/java/com/fongmi/android/tv/web/WebThemeTokenSourceTest.java b/app/src/test/java/com/fongmi/android/tv/web/WebThemeTokenSourceTest.java deleted file mode 100644 index bd3f59b902..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/web/WebThemeTokenSourceTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.fongmi.android.tv.web; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class WebThemeTokenSourceTest { - - private static final Pattern HEX = Pattern.compile("#[0-9A-Fa-f]{3}(?:[0-9A-Fa-f]{3})?\\b"); - - @Test - public void sharedStylesExposeCanonicalTokensAndLegacyAliases() throws Exception { - String css = read("app/src/main/assets/css/ui.css"); - for (String token : new String[]{ - "--webhtv-primary", "--webhtv-on-primary", "--webhtv-surface", - "--webhtv-surface-container", "--webhtv-surface-container-high", - "--webhtv-on-surface", "--webhtv-on-surface-variant", "--webhtv-outline", - "--webhtv-outline-variant", "--webhtv-error", "--webhtv-success", - "--webhtv-warning", "--webhtv-focus", "--webhtv-shape-small", - "--webhtv-space-1" - }) { - assertTrue(token + " must be defined", css.contains(token + ":")); - } - assertTrue("legacy --md-primary must remain an alias", css.contains("--md-primary: var(--webhtv-primary);")); - assertTrue("explicit light mode hook missing", css.contains(":root[data-theme=\"light\"]")); - assertTrue("explicit dark mode hook missing", css.contains(":root[data-theme=\"dark\"]")); - } - - @Test - public void assetStylesDoNotUseRawHexOutsideControlledDeclarations() throws Exception { - for (String file : new String[]{ - "app/src/main/assets/css/ui.css", "app/src/main/assets/index.html", - "app/src/main/assets/manage.html", "app/src/main/assets/parse.html", - "app/src/main/assets/webhome/eclipse-detail.html" - }) { - for (String line : read(file).split("\\R")) { - if (!HEX.matcher(line).find()) continue; - String stripped = line.trim(); - assertTrue(file + " raw hex outside token declaration: " + stripped, - stripped.startsWith("--") || stripped.contains("= 0); - int end = exoUtil.indexOf("\n }", start); - String mode = exoUtil.substring(start, end > start ? end : exoUtil.length()); - - // 兜底开启:硬解 = ON(MediaCodec 先接管、FFmpeg 排最后);兜底关闭:仍为 OFF。 - assertTrue(mode, mode.contains("isDecoderFallbackEnabled()")); - assertTrue(mode, mode.contains("EXTENSION_RENDERER_MODE_ON")); - assertTrue(mode, mode.contains("EXTENSION_RENDERER_MODE_OFF")); - assertTrue(exoUtil.contains("if (videoRenderMode == EXTENSION_RENDERER_MODE_OFF) return;")); - assertTrue(exoUtil.contains("buildFfmpegVideoRenderer(")); - // ON 分支必须换成让轨版本,并带上与 MediaCodec 渲染器同一个 selector; - // 其余分支(软解 PREFER)保持原 FfmpegVideoRenderer。 - assertTrue(exoUtil.contains("videoRenderMode == EXTENSION_RENDERER_MODE_ON")); - assertTrue(exoUtil.contains("new CompatFfmpegVideoRenderer(")); - assertTrue(exoUtil.contains("true, videoCodecSelector")); - assertTrue(exoUtil.contains("new FfmpegVideoRenderer(allowedVideoJoiningTimeMs, eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)")); - } - private static String readSource() throws Exception { - return readSource(SOURCE_PATH); - } - - private static String readSource(String relativePath) throws Exception { File root = new File(System.getProperty("user.dir")); - while (root != null && !new File(root, relativePath).isFile()) root = root.getParentFile(); + while (root != null && !new File(root, SOURCE_PATH).isFile()) root = root.getParentFile(); assertNotNull("Unable to locate project root from " + System.getProperty("user.dir"), root); - return Files.readString(new File(root, relativePath).toPath(), StandardCharsets.UTF_8); + return Files.readString(new File(root, SOURCE_PATH).toPath(), StandardCharsets.UTF_8); } } diff --git a/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVc1SupportTest.java b/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVc1SupportTest.java index 19134962b2..2f066aa1a5 100644 --- a/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVc1SupportTest.java +++ b/app/src/test/java/io/github/anilbeesetti/nextlib/media3ext/ffdecoder/FfmpegVc1SupportTest.java @@ -1,5 +1,7 @@ package io.github.anilbeesetti.nextlib.media3ext.ffdecoder; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -8,6 +10,7 @@ import org.junit.Test; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.InputStream; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.zip.ZipEntry; @@ -16,8 +19,25 @@ import java.util.zip.ZipFile; public class FfmpegVc1SupportTest { private static final String AAR_PATH = "third_party/maven/io/github/anilbeesetti/nextlib-media3ext/" - + "1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3/" - + "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-ffmpeg901-r3.aar"; + + "1.10.0-0.12.1-fongmi-softload-av3a-r1/" + + "nextlib-media3ext-1.10.0-0.12.1-fongmi-softload-av3a-r1.aar"; + + @Test + public void codecName_mapsWvc1ToVc1() { + assertEquals("vc1", FfmpegLibrary.getCodecName("video/wvc1")); + } + + @Test + public void extraData_returnsFirstInitializationBlockForWvc1() throws Exception { + byte[] initializationData = {0x01, 0x02, 0x03, 0x04}; + Class decoderClass = Class.forName("io.github.anilbeesetti.nextlib.media3ext.ffdecoder.FfmpegVideoDecoder"); + Method method = decoderClass.getDeclaredMethod("getExtraData", String.class, List.class); + method.setAccessible(true); + + byte[] result = (byte[]) method.invoke(null, "video/wvc1", List.of(initializationData)); + + assertArrayEquals(initializationData, result); + } @Test public void bundledFfmpeg_hasVc1DecoderForEveryAbi() throws Exception { diff --git a/app/src/test/resources/mpv/custom_button_runtime_test.lua b/app/src/test/resources/mpv/custom_button_runtime_test.lua deleted file mode 100644 index 66815a80a2..0000000000 --- a/app/src/test/resources/mpv/custom_button_runtime_test.lua +++ /dev/null @@ -1,48 +0,0 @@ -local messages = {} -local errors = {} -live_timers = 0 -mp = { - register_script_message = function(name, handler) messages[name] = handler end, - msg = { error = function(message) errors[#errors + 1] = message end }, -} - -assert(loadfile(arg[1]))() -local dispatch = assert(messages["webhtv-custom-button"]) -assert(startup_count == 1 and toggle_state == true and live_timers == 1) -assert(hidden_count == 1) -assert(click_count == nil and long_count == nil) -assert(return_count == 1) -assert(disabled_legacy_ran == nil) -assert(#errors == 1 and errors[1]:find("expected startup error", 1, true)) -for _, trigger in ipairs({"click", "long", "startup"}) do - dispatch("disabled_script_" .. trigger, "short") - dispatch("disabled_script_" .. trigger, "long") -end -assert(#errors == 1 and live_timers == 1 and startup_count == 1) - -dispatch("startup", "short") -assert(startup_count == 2 and toggle_state == false and live_timers == 0) -dispatch("startup", "short") -assert(startup_count == 3 and toggle_state == true and live_timers == 1) -dispatch("startup", "long") -dispatch("hidden", "short") -dispatch("hidden", "long") -dispatch("missing", "short") -assert(startup_count == 3 and hidden_count == 1 and live_timers == 1) - -dispatch("click", "long") -dispatch("hold", "short") -assert(click_count == nil and long_count == nil) -dispatch("click", "short") -dispatch("hold", "long") -assert(click_count == 1 and long_count == 1) - -dispatch("legacy", "short") -assert(legacy_count == 7) -dispatch("legacy", "short") -dispatch("legacy", "long") -assert(legacy_count == 8 and legacy_long == 9) -dispatch("broken", "short") -dispatch("click", "short") -assert(#errors == 2 and click_count == 2) -print("PASS: startup/click/long/hidden/legacy/error isolation") diff --git a/app/src/test/resources/probe-rules-v1-community.json b/app/src/test/resources/probe-rules-v1-community.json new file mode 100644 index 0000000000..d2930e706a --- /dev/null +++ b/app/src/test/resources/probe-rules-v1-community.json @@ -0,0 +1,420 @@ +{ + "format": "ad-audio-probe-rules", + "schemaVersion": 1, + "revision": 2, + "algorithm": "spectral-sequence-v1", + "rules": [ + { + "id": "ad-1786989757783-1", + "durationMs": 47000, + "anchorOffsetMs": 0, + "anchorDurationMs": 5000, + "fingerprints": [ + { + "phaseMs": 0, + "hashes": [ + "687f0807", + "4f9601d7", + "28960097", + "0e7b001f", + "00000000", + "7b9200ff", + "2e99039f", + "7e6503ff", + "77f401f6", + "5ff5007f", + "59b900ff", + "79e500ff", + "7fe501ff", + "63f9007f", + "7bf100ff", + "7b9500ff", + "1efb00ff", + "79f500ff" + ] + }, + { + "phaseMs": 64, + "hashes": [ + "4e370617", + "6f9601d7", + "28d6081f", + "4e7b001f", + "37e3003b", + "6f9201ff", + "2e79033f", + "7e7507fd", + "7bf501f7", + "5bfb003f", + "79a900ff", + "7be500ff", + "67f501ff", + "6bf900ff", + "79a100fd", + "5fb500ff", + "7afb00ff", + "79b500fd" + ] + }, + { + "phaseMs": 128, + "hashes": [ + "4f960397", + "2d9600d3", + "28f2081f", + "00000000", + "5feb000b", + "2f9201df", + "2e79037f", + "7e3507fd", + "7bf501ff", + "5bfb003f", + "79a900fd", + "7fe500ff", + "67f901ff", + "7bf900ff", + "79a100fd", + "1feb007f", + "79f700ff", + "79b500fd" + ] + }, + { + "phaseMs": 192, + "hashes": [ + "4f9601d7", + "299600d3", + "2873001f", + "00000000", + "4bfb001f", + "2f9203df", + "7e6d03ff", + "753407f6", + "7bf500ff", + "59bb00ff", + "79a500fd", + "7fe500ff", + "63f900ff", + "7bf100ff", + "7b8500fd", + "1ffb00ff", + "79f500ff" + ] + } + ], + "test": { + "url": "https://oss.douyinbit.com/m3u8/c3680ce0df8971d518be669d84ef9eef.m3u8", + "adStartMs": 15000 + } + }, + { + "id": "auto-ad-3626f1b6eb5c7e1a", + "durationMs": 14466, + "anchorOffsetMs": 0, + "anchorDurationMs": 5000, + "fingerprints": [ + { + "phaseMs": 0, + "hashes": [ + "1176003f", + "1175003f", + "6ea62ff0", + "6ca42fa4", + "75941cf6", + "33961bf6", + "bd961fd2", + "33d9005f", + "3b3303bf", + "6f760ff6", + "6ea40fb6", + "6aa42be6", + "2b130b83", + "219f001f", + "69bb081f", + "6ea62fb7", + "6ca62fb6", + "69a42db7" + ] + }, + { + "phaseMs": 64, + "hashes": [ + "1177103f", + "1377003f", + "6ea62ff0", + "6ca42fa4", + "71841cf6", + "b79617f6", + "bd961fd2", + "335b01f9", + "2b3403bf", + "6f760ff6", + "e8a62fb6", + "6aa62be6", + "29f73c1f", + "259e009f", + "6abb081f", + "6eb627b7", + "6ca62fb6", + "6b2401b7" + ] + }, + { + "phaseMs": 128, + "hashes": [ + "11771837", + "62532353", + "6ea42fe0", + "5caa00be", + "31c61cf6", + "b79607d6", + "bdd60fc2", + "3b3303fb", + "6b2403f6", + "6ea40ff6", + "eaa62fb6", + "6a8603f6", + "21f73c1f", + "2bbe009f", + "6aab23bf", + "6ab62bb7", + "6ca62fb6", + "6b2603b7" + ] + }, + { + "phaseMs": 192, + "hashes": [ + "1175183f", + "669623d2", + "6ca42fa4", + "759a07da", + "31f619f6", + "b7961fd2", + "39d601c3", + "3b3303fb", + "6f640ff6", + "6ea40fb6", + "6aa62bf6", + "6a0703f3", + "21df301f", + "2bb7001f", + "6ea627b7", + "6ab62fb7", + "68a62fa6" + ] + } + ], + "test": { + "url": "https://v13.wsyzym3u8.com/202607/15/R3Brjnm0gx27/video/index.m3u8", + "adStartMs": 342000 + } + }, + { + "id": "auto-ad-530805f02f930909", + "durationMs": 11866, + "anchorOffsetMs": 0, + "anchorDurationMs": 5000, + "fingerprints": [ + { + "phaseMs": 0, + "hashes": [ + "5cff0007", + "14569417", + "74fb1c1f", + "39d9007f", + "3ad900cf", + "3cca0ee2", + "7de60467", + "00000000", + "00000000", + "36ff0007", + "23d500fd", + "2bf300ff", + "6cdb007f", + "28db005b", + "68fc080f", + "7df9000d", + "00000000", + "ad542556" + ] + }, + { + "phaseMs": 64, + "hashes": [ + "903ffc00", + "145e941f", + "7cf91c1f", + "31d9007f", + "3ad902cf", + "7cca0ee0", + "00000000", + "00000000", + "00000000", + "23fd001d", + "2bd500fd", + "29f3007f", + "6cd3047b", + "385b001f", + "68fc000f", + "00000000", + "00000000", + "ad5627f2" + ] + }, + { + "phaseMs": 128, + "hashes": [ + "983ef802", + "347a141f", + "3cf90c1d", + "39d9007f", + "3ec90eed", + "7cea0e60", + "00000000", + "00000000", + "00000000", + "23dd005d", + "2bd500fd", + "29f3003f", + "6cdb047b", + "397c001f", + "68fc000f", + "00000000", + "00000000", + "ee5607f2" + ] + }, + { + "phaseMs": 192, + "hashes": [ + "9856d807", + "34fb141f", + "38d90c5f", + "3bd9005f", + "3ecb0ee1", + "3ceb0061", + "00000000", + "00000000", + "00000000", + "23dd005d", + "2bd100ff", + "29fb007f", + "6cdb007b", + "387c000f", + "68fc000e", + "00000000", + "00000000" + ] + } + ], + "test": { + "url": "https://v13.wsyzym3u8.com/202607/15/R3Brjnm0gx27/video/index.m3u8", + "adStartMs": 342000 + } + }, + { + "id": "auto-ad-ed09bbb9f1dd3c87", + "durationMs": 15132, + "anchorOffsetMs": 0, + "anchorDurationMs": 5000, + "fingerprints": [ + { + "phaseMs": 0, + "hashes": [ + "7af4001f", + "6ffe0007", + "6c7c001f", + "78fb003f", + "7f6a007f", + "3ce2007f", + "56c3007f", + "778503e0", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "649f0007", + "76f2007f", + "7cfb001f" + ] + }, + { + "phaseMs": 64, + "hashes": [ + "6fdd0007", + "69ee0807", + "6c7c001f", + "797a003f", + "7f63007f", + "3cc200ff", + "56d1007f", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "65cf00c7", + "46f1003f", + "76f3007f", + "78f9001f" + ] + }, + { + "phaseMs": 128, + "hashes": [ + "6fde0007", + "687c6c0f", + "6cf8001f", + "797a003f", + "3d62007f", + "34c2007f", + "36d503f5", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "658f0087", + "56f2003f", + "76fb007f", + "7ef9007f" + ] + }, + { + "phaseMs": 192, + "hashes": [ + "6ffe0007", + "687c001f", + "7cf9001f", + "7b7a003f", + "3ce2007b", + "74c3007f", + "368503e0", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "00000000", + "649f0007", + "76f2003f", + "76fb003f" + ] + } + ], + "test": { + "url": "https://v13.wsyzym3u8.com/202607/15/R3Brjnm0gx27/video/index.m3u8", + "adStartMs": 342000 + } + } + ] +} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/ExoStartupRecoveryIntegrationTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/ExoStartupRecoveryIntegrationTest.java deleted file mode 100644 index d65d8bc486..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/ExoStartupRecoveryIntegrationTest.java +++ /dev/null @@ -1,382 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import android.app.Application; -import android.net.Uri; -import android.os.Looper; -import android.os.SystemClock; - -import androidx.media3.common.AudioAttributes; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MediaItem; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.PlaybackException; -import androidx.media3.common.PlaybackParameters; -import androidx.media3.common.Player; -import androidx.media3.common.util.Clock; -import androidx.media3.common.util.ParsableByteArray; -import androidx.media3.datasource.ByteArrayDataSource; -import androidx.media3.datasource.DataSource; -import androidx.media3.datasource.DataSpec; -import androidx.media3.datasource.TransferListener; -import androidx.media3.decoder.DecoderInputBuffer; -import androidx.media3.exoplayer.BaseRenderer; -import androidx.media3.exoplayer.DefaultLoadControl; -import androidx.media3.exoplayer.ExoPlaybackException; -import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.exoplayer.FormatHolder; -import androidx.media3.exoplayer.Renderer; -import androidx.media3.exoplayer.RendererCapabilities; -import androidx.media3.exoplayer.audio.AudioOffloadSupport; -import androidx.media3.exoplayer.audio.AudioOutput; -import androidx.media3.exoplayer.audio.AudioOutputProvider; -import androidx.media3.exoplayer.source.MediaPeriod; -import androidx.media3.exoplayer.source.MediaSource; -import androidx.media3.exoplayer.source.ProgressiveMediaSource; -import androidx.media3.exoplayer.source.SampleStream; -import androidx.media3.exoplayer.source.WrappingMediaSource; -import androidx.media3.exoplayer.upstream.Allocator; -import androidx.media3.exoplayer.MediaClock; -import androidx.media3.extractor.Extractor; -import androidx.media3.extractor.ExtractorInput; -import androidx.media3.extractor.ExtractorOutput; -import androidx.media3.extractor.PositionHolder; -import androidx.media3.extractor.SeekMap; -import androidx.media3.extractor.SeekPoint; -import androidx.media3.extractor.TrackOutput; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.annotation.Config; -import org.robolectric.annotation.LooperMode; -import org.robolectric.shadows.ShadowLooper; - -import java.io.IOException; -import java.lang.reflect.Proxy; -import java.nio.ByteBuffer; -import java.time.Duration; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -/** Runs the shipped ExoPlayer/ProgressiveMediaPeriod/sample queues, with deterministic fake outputs. */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = Application.class) -@LooperMode(LooperMode.Mode.PAUSED) -public class ExoStartupRecoveryIntegrationTest { - private static final long SAMPLE_US = 21_333; - private static final Format AUDIO = new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC) - .setCodecs("mp4a.40.2").setSampleRate(48_000).setChannelCount(2).build(); - private static final Format VIDEO = new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264) - .setWidth(16).setHeight(16).build(); - - @Test - public void firstSilentDirectOutputRecoversWithinExistingPeriodAndRead() throws Exception { - runPlayback(true, 0); - } - - @Test - public void resumedFirstPlaybackRetainsBufferedKeyframeAndAudioSamples() throws Exception { - runPlayback(true, 2250); - } - - @Test - public void healthyDirectOutputNeverReselectsOrCreatesPcm() throws Exception { - runPlayback(false, 0); - } - - @Test - public void unreadableBufferMetricsStillRecoverFirstSilentDirectOutput() throws Exception { - runPlayback(true, 0, false); - } - - @Test - public void unreadableBufferMetricsNeverRecoverHealthyDirectOutput() throws Exception { - runPlayback(false, 0, false); - } - - private void runPlayback(boolean failDirect, long startMs) throws Exception { - runPlayback(failDirect, startMs, true); - } - - /** - * @param readableMetrics when false the fake track reports no capacity/size/threshold, as the - * vendor track in the field log did, so only the accepted payload and the pinned playback - * head remain as evidence. - */ - private void runPlayback(boolean failDirect, long startMs, boolean readableMetrics) throws Exception { - AtomicInteger opens = new AtomicInteger(); - AtomicInteger opensAtDirect = new AtomicInteger(); - AtomicInteger directCreations = new AtomicInteger(); - AtomicInteger pcmCreations = new AtomicInteger(); - AtomicReference error = new AtomicReference<>(); - ExoAudioDirectFailureMemory.Route route = new ExoAudioDirectFailureMemory.Route(1, 2, 3, 4, 5); - ExoCompressedAudioDirectPolicy policy = new ExoCompressedAudioDirectPolicy( - (format, attributes) -> AudioOffloadSupport.DEFAULT_UNSUPPORTED, - (format, attributes) -> true, Clock.DEFAULT, - config -> { - directCreations.incrementAndGet(); - opensAtDirect.set(opens.get()); - return fakeOutput(failDirect); - }, - new ExoAudioDirectFailureMemory(), new ExoCompressedAudioDirectPolicy.OutputEnvironment() { - @Override public ExoAudioDirectFailureMemory.Route expectedRoute(AudioAttributes attributes) { return route; } - @Override public ExoAudioDirectFailureMemory.Route actualRoute(AudioOutput output) { return route; } - @Override public long rawPlaybackHead(AudioOutput output) { return failDirect ? 0 : 1; } - @Override public ExoCompressedAudioDirectPolicy.StartupBuffer startupBuffer(AudioOutput output) { - return readableMetrics - ? new ExoCompressedAudioDirectPolicy.StartupBuffer(4096, 4096, 4096) - : ExoCompressedAudioDirectPolicy.StartupBuffer.UNKNOWN; - } - }); - policy.prepareForPlayback("memory:///first-playback", false); - policy.setSelectedAudioFormat(AUDIO); - TestAudioRenderer audio = new TestAudioRenderer(policy.wrapOutputProvider(pcmProvider(pcmCreations))); - TestVideoRenderer video = new TestVideoRenderer(); - CountingSource source = new CountingSource(new ProgressiveMediaSource.Factory( - () -> countingDataSource(opens), () -> new Extractor[]{new TestExtractor()}) - .createMediaSource(MediaItem.fromUri("memory:///first-playback"))); - ExoPlayer player = new ExoPlayer.Builder(RuntimeEnvironment.getApplication(), - (handler, videoListener, audioListener, text, metadata) -> - new Renderer[]{video, new ExoStartupAudioRenderer(audio, policy)}) - .setLoadControl(new DefaultLoadControl.Builder().setBufferDurationsMs(1000, 5000, 100, 100) - .setBackBuffer(0, true).build()).build(); - player.addListener(new Player.Listener() { - @Override public void onPlayerError(PlaybackException failure) { error.set(failure); } - }); - try { - player.setMediaSource(source, startMs); - player.prepare(); - player.play(); - long deadlineNs = System.nanoTime() + 8_000_000_000L; - while (error.get() == null && player.getCurrentPosition() < startMs + 300 - && System.nanoTime() < deadlineNs) { - ShadowLooper.getShadowMainLooper().idleFor(Duration.ofMillis(10)); - Thread.sleep(2); // Let the real playback/loader threads consume their scheduled work. - } - assertNull(error.get()); - assertTrue("Playback did not advance", player.getCurrentPosition() >= startMs + 300); - assertEquals(1, source.preparations); - assertEquals(1, source.periods); - assertTrue(opensAtDirect.get() > 0); - assertEquals("Recovery must not reopen the source", opensAtDirect.get(), opens.get()); - assertEquals(1, directCreations.get()); - assertEquals(failDirect ? 1 : 0, pcmCreations.get()); - assertEquals(failDirect ? 2 : 1, audio.enables); - assertEquals(failDirect ? 2 : 1, video.enables); - if (failDirect) { - assertTrue("Must replay the first pending audio sample", - Math.abs(audio.firstPcmTimeUs - audio.firstDirectTimeUs) <= SAMPLE_US); - long recoveryBudgetMs = readableMetrics ? 2000 : 3000; - assertTrue("The 10 second watchdog must not drive recovery", - audio.pcmCreatedAtMs - audio.directCreatedAtMs < recoveryBudgetMs); - } - } finally { - player.release(); - ShadowLooper.getShadowMainLooper().idle(); - } - } - - private static final class CountingSource extends WrappingMediaSource { - volatile int preparations; - volatile int periods; - CountingSource(MediaSource child) { super(child); } - @Override protected void prepareSourceInternal() { preparations++; super.prepareSourceInternal(); } - @Override public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long positionUs) { - periods++; - return super.createPeriod(id, allocator, positionUs); - } - } - - private static DataSource countingDataSource(AtomicInteger opens) { - return new DataSource() { - final ByteArrayDataSource delegate = new ByteArrayDataSource(new byte[2048]); - @Override public void addTransferListener(TransferListener listener) { delegate.addTransferListener(listener); } - @Override public long open(DataSpec spec) throws IOException { opens.incrementAndGet(); return delegate.open(spec); } - @Override public int read(byte[] target, int offset, int length) throws IOException { return delegate.read(target, offset, length); } - @Override public Uri getUri() { return delegate.getUri(); } - @Override public void close() throws IOException { delegate.close(); } - }; - } - - private static final class TestExtractor implements Extractor { - TrackOutput audio; - TrackOutput video; - int index; - final byte[] one = new byte[1]; - @Override public boolean sniff(ExtractorInput input) { return true; } - @Override public void init(ExtractorOutput output) { - audio = output.track(0, C.TRACK_TYPE_AUDIO); - video = output.track(1, C.TRACK_TYPE_VIDEO); - audio.format(AUDIO); - video.format(VIDEO); - output.endTracks(); - output.seekMap(new SeekMap() { - @Override public boolean isSeekable() { return true; } - @Override public long getDurationUs() { return 2048 * SAMPLE_US; } - @Override public SeekPoints getSeekPoints(long timeUs) { - long sample = timeUs / SAMPLE_US / 48 * 48; - return new SeekPoints(new SeekPoint(sample * SAMPLE_US, sample)); - } - }); - } - @Override public int read(ExtractorInput input, PositionHolder position) throws IOException { - if (!input.readFully(one, 0, 1, true)) return RESULT_END_OF_INPUT; - audio.sampleData(new ParsableByteArray(new byte[128]), 128); - audio.sampleMetadata(index * SAMPLE_US, C.BUFFER_FLAG_KEY_FRAME, 128, 0, null); - video.sampleData(new ParsableByteArray(new byte[16]), 16); - video.sampleMetadata(index * SAMPLE_US, index % 48 == 0 ? C.BUFFER_FLAG_KEY_FRAME : 0, 16, 0, null); - index++; - return RESULT_CONTINUE; - } - @Override public void seek(long position, long timeUs) { index = (int) position; } - @Override public void release() {} - } - - private static class TestVideoRenderer extends BaseRenderer { - final DecoderInputBuffer input = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); - int enables; - boolean ended; - TestVideoRenderer() { super(C.TRACK_TYPE_VIDEO); } - @Override public String getName() { return "test-video"; } - @Override public int supportsFormat(Format format) { return RendererCapabilities.create( - MimeTypes.isVideo(format.sampleMimeType) ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_TYPE); } - @Override protected void onEnabled(boolean joining, boolean mayRender) { enables++; } - @Override protected void onPositionReset(long position, boolean joining, boolean reset) { ended = false; } - @Override public void render(long positionUs, long realtimeUs) { - while (!ended && getReadingPositionUs() < positionUs + 500_000) { - input.clear(); - int result = readSource(getFormatHolder(), input, 0); - if (result == C.RESULT_NOTHING_READ) break; - if (result == C.RESULT_BUFFER_READ && input.isEndOfStream()) ended = true; - } - } - @Override public boolean isReady() { return true; } - @Override public boolean isEnded() { return ended; } - } - - private static final class TestAudioRenderer extends BaseRenderer implements MediaClock { - final AudioOutputProvider provider; - final DecoderInputBuffer input = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); - AudioOutput output; - Format format; - boolean pending; - boolean pcm; - long positionUs; - long firstTimeUs; - long firstDirectTimeUs = C.TIME_UNSET; - long firstPcmTimeUs = C.TIME_UNSET; - long directCreatedAtMs; - long pcmCreatedAtMs; - int enables; - TestAudioRenderer(AudioOutputProvider provider) { super(C.TRACK_TYPE_AUDIO); this.provider = provider; } - @Override public String getName() { return "test-audio"; } - @Override public int supportsFormat(Format format) { return RendererCapabilities.create( - MimeTypes.isAudio(format.sampleMimeType) ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_TYPE); } - @Override protected void onEnabled(boolean joining, boolean mayRender) { enables++; } - @Override protected void onPositionReset(long position, boolean joining, boolean reset) { - positionUs = position; - pending = false; - if (output != null) output.flush(); - } - @Override protected void onStarted() { if (output != null) output.play(); } - @Override protected void onStopped() { if (output != null) output.pause(); } - @Override protected void onDisabled() { - if (output != null) output.release(); - output = null; - format = null; - pending = false; - } - @Override public void render(long position, long realtime) throws ExoPlaybackException { - try { - for (int i = 0; i < 64; i++) { - if (!pending) { - input.clear(); - FormatHolder holder = getFormatHolder(); - int result = readSource(holder, input, format == null ? SampleStream.FLAG_REQUIRE_FORMAT : 0); - if (result == C.RESULT_NOTHING_READ) return; - if (result == C.RESULT_FORMAT_READ) { format = holder.format; continue; } - if (input.isEndOfStream()) return; - input.flip(); - pending = true; - } - if (output == null) { - AudioOutputProvider.FormatConfig requested = new AudioOutputProvider.FormatConfig.Builder(format).build(); - pcm = provider.getFormatSupport(requested).supportLevel == AudioOutputProvider.FORMAT_UNSUPPORTED; - if (pcm) requested = new AudioOutputProvider.FormatConfig.Builder(format.buildUpon() - .setSampleMimeType(MimeTypes.AUDIO_RAW).setPcmEncoding(C.ENCODING_PCM_16BIT).build()).build(); - output = provider.getAudioOutput(provider.getOutputConfig(requested)); - firstTimeUs = input.timeUs; - if (pcm) { firstPcmTimeUs = input.timeUs; pcmCreatedAtMs = SystemClock.elapsedRealtime(); } - else { firstDirectTimeUs = input.timeUs; directCreatedAtMs = SystemClock.elapsedRealtime(); } - if (getState() == STATE_STARTED) output.play(); - } - if (!output.write(input.data, 1, input.timeUs)) return; - pending = false; - } - } catch (Exception failure) { - throw createRendererException(failure, format, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED); - } - } - @Override public boolean isReady() { return output != null || isSourceReady(); } - @Override public boolean isEnded() { return false; } - @Override public MediaClock getMediaClock() { return this; } - @Override public long getPositionUs() { - if (output != null) positionUs = Math.max(positionUs, firstTimeUs + output.getPositionUs()); - return positionUs; - } - @Override public PlaybackParameters getPlaybackParameters() { return PlaybackParameters.DEFAULT; } - @Override public void setPlaybackParameters(PlaybackParameters parameters) {} - } - - private static AudioOutputProvider pcmProvider(AtomicInteger creations) { - return new AudioOutputProvider() { - @Override public FormatSupport getFormatSupport(FormatConfig config) { - return MimeTypes.AUDIO_RAW.equals(config.format.sampleMimeType) ? new FormatSupport.Builder() - .setFormatSupportLevel(FORMAT_SUPPORTED_DIRECTLY).build() : FormatSupport.UNSUPPORTED; - } - @Override public OutputConfig getOutputConfig(FormatConfig config) { - return new OutputConfig.Builder().setEncoding(C.ENCODING_PCM_16BIT).setSampleRate(48_000) - .setChannelMask(12).setBufferSize(4096).build(); - } - @Override public AudioOutput getAudioOutput(OutputConfig config) { creations.incrementAndGet(); return fakeOutput(false); } - @Override public void addListener(Listener listener) {} - @Override public void removeListener(Listener listener) {} - @Override public void release() {} - }; - } - - private static AudioOutput fakeOutput(boolean stalled) { - long[] state = {0, C.TIME_UNSET, 0}; // written bytes, last play time, accumulated position - return (AudioOutput) Proxy.newProxyInstance(AudioOutput.class.getClassLoader(), new Class[]{AudioOutput.class}, - (proxy, method, args) -> { - switch (method.getName()) { - case "write": - ByteBuffer data = (ByteBuffer) args[0]; - int count = stalled ? (int) Math.min(data.remaining(), Math.max(0, 4096 - state[0])) : data.remaining(); - data.position(data.position() + count); - state[0] += count; - return !data.hasRemaining(); - case "getPositionUs": - return stalled || state[0] == 0 ? 0L : state[2] - + (state[1] == C.TIME_UNSET ? 0 : (SystemClock.elapsedRealtime() - state[1]) * 1000); - case "play": state[1] = SystemClock.elapsedRealtime(); return null; - case "pause": - if (state[1] != C.TIME_UNSET) state[2] += (SystemClock.elapsedRealtime() - state[1]) * 1000; - state[1] = C.TIME_UNSET; - return null; - case "flush": state[0] = state[2] = 0; state[1] = C.TIME_UNSET; return null; - default: - if (method.getReturnType() == boolean.class) return false; - if (method.getReturnType() == int.class) return 0; - if (method.getReturnType() == long.class) return 0L; - return null; - } - }); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryCancellationTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryCancellationTest.java deleted file mode 100644 index 339001a4b8..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryCancellationTest.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.fongmi.android.tv.player.exo; - -import android.app.Application; -import android.net.Uri; - -import androidx.media3.common.C; -import androidx.media3.datasource.DataSource; -import androidx.media3.datasource.DataSpec; -import androidx.media3.datasource.TransferListener; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import java.io.IOException; -import java.net.ProtocolException; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -/** - * Cancellation behavior of the EOF recovery backoff, which needs a real {@link Uri}. - * - *

The backoff sleep sits between {@code read()}'s own {@code dataSpec == null} guard and the - * dereference of {@code dataSpec}. Media3 cancels a load by calling {@link DataSource#close()} - * from another thread, so a close landing inside that sleep would let the source reopen a - * closed connection at a stale offset — or dereference a null spec. The shared JVM test set cannot - * build a {@link DataSpec} because {@code android.net.Uri} is not mocked there, hence Robolectric. - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = Application.class) -public class HttpEofRecoveryCancellationTest { - - /** - * Only the foreground source may publish the yield signal. If preload published it too, preload - * would yield to itself and never restart; if the foreground did not publish, the yield would - * never fire. Both halves are load-bearing, so both are pinned. - */ - @Test - public void onlyForegroundSourcePublishesTheYieldSignal() throws Exception { - ExoForegroundEofSignal.reset(); - driveOneRecovery(true); - long afterForeground = ExoForegroundEofSignal.ageMs(0); - assertTrue("foreground recovery must publish the signal", - afterForeground != Long.MAX_VALUE); - - ExoForegroundEofSignal.reset(); - driveOneRecovery(false); - assertEquals("preload-side recovery must not publish the signal", - Long.MAX_VALUE, ExoForegroundEofSignal.ageMs(0)); - } - - private static void driveOneRecovery(boolean foreground) throws Exception { - DataSource upstream = new DataSource() { - @Override public void addTransferListener(TransferListener listener) {} - - @Override public long open(DataSpec dataSpec) { return C.LENGTH_UNSET; } - - @Override public int read(byte[] buffer, int offset, int length) throws IOException { - throw new ProtocolException("unexpected end of stream"); - } - - @Override public Uri getUri() { return Uri.EMPTY; } - - @Override public void close() {} - }; - HttpEofRecoveryDataSource source = new HttpEofRecoveryDataSource(upstream, foreground); - source.open(new DataSpec(Uri.parse("http://example.invalid/a.mp4"), 0, C.LENGTH_UNSET)); - try { - source.read(new byte[16], 0, 16); - } catch (IOException expected) { - // The recovery runs (and publishes) before the retry budget is exhausted. - } - } - - /** First-attempt backoff is 100 ms; this delay must land well inside it. */ - private static final long CLOSE_INSIDE_BACKOFF_DELAY_MS = 30L; - - @Test - public void closeDuringBackoffCancelsCleanlyInsteadOfReopening() throws Exception { - CountDownLatch readThrew = new CountDownLatch(1); - AtomicInteger opens = new AtomicInteger(); - AtomicReference readerFailure = new AtomicReference<>(); - - DataSource upstream = new DataSource() { - @Override public void addTransferListener(TransferListener listener) {} - - @Override public long open(DataSpec dataSpec) { - opens.incrementAndGet(); - return C.LENGTH_UNSET; - } - - @Override public int read(byte[] buffer, int offset, int length) throws IOException { - readThrew.countDown(); - throw new ProtocolException("unexpected end of stream"); - } - - @Override public Uri getUri() { return Uri.EMPTY; } - - @Override public void close() {} - }; - - HttpEofRecoveryDataSource source = new HttpEofRecoveryDataSource(upstream); - source.open(new DataSpec(Uri.parse("http://example.invalid/a.mp4"), 0, C.LENGTH_UNSET)); - - Thread reader = new Thread(() -> { - try { - source.read(new byte[16], 0, 16); - } catch (Throwable error) { - readerFailure.set(error); - } - }); - reader.start(); - - assertTrue("upstream read should have been reached", - readThrew.await(2, TimeUnit.SECONDS)); - // The catch block's own null check runs within microseconds of the throw, so sleeping here - // guarantees close() lands after that check and inside the backoff sleep. - Thread.sleep(CLOSE_INSIDE_BACKOFF_DELAY_MS); - source.close(); - reader.join(5_000); - - assertFalse("reader thread must terminate", reader.isAlive()); - Throwable failure = readerFailure.get(); - assertNotNull("read() must surface a failure, not return normally", failure); - // The discriminator: a clean cancellation is an IOException. Without the post-backoff - // re-check the stale spec is dereferenced and this becomes a NullPointerException. - assertTrue("close during backoff must surface IOException, got " + failure, - failure instanceof IOException); - assertFalse("close during backoff must not NPE", - failure instanceof NullPointerException); - assertEquals("close during backoff must not reopen", 1, opens.get()); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/plugin/PluginPanelLoadingShellTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/plugin/PluginPanelLoadingShellTest.java deleted file mode 100644 index 8fbedf5e57..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/plugin/PluginPanelLoadingShellTest.java +++ /dev/null @@ -1,380 +0,0 @@ -package com.fongmi.android.tv.plugin; - -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 android.os.Bundle; -import android.view.View; -import android.widget.TextView; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.dialog.PluginPanelDialog; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.android.controller.ActivityController; -import org.robolectric.annotation.Config; -import org.robolectric.shadow.api.Shadow; -import org.robolectric.shadows.ShadowLooper; -import org.robolectric.shadows.ShadowWebView; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * 插件面板「加载壳」的真实行为守卫。 - * - *

为什么需要它。这条链路的既有守卫全是源码文本断言(见 {@code PluginPanelPlaybackWiringTest}), - * 它们能证明某个方法里写了某一行,证明不了面板真的能开、真的能回填、空结果真的能撤壳。而这轮 - * 返工的两个缺陷恰恰都在"多步交互之后才暴露"的路径上:复用已有面板后再来一个空结果、以及 - * Fragment 被系统重建之后。 - * - *

两条用例直接对应两个必修缺陷。{@code reusingAPanelWithContentCanStillBeDismissedByAnEmptyResult} - * 锁 B1(复用后字段还留着旧内容,{@code dismissLoading} 的守卫误判成"用户正在阅读",加载壳永不撤); - * {@code contentAndTitleSurviveFragmentRecreation} 锁 B2({@code html}/{@code title} 没进 Bundle, - * 重建后内容面板永久转圈)。 - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = App.class) -public class PluginPanelLoadingShellTest { - - /** 与生产代码的 {@code PluginPanelDialog.TAG} 一致:测试也按标签取面板,取法要和产品路径相同。 */ - private static final String TAG = "plugin-panel"; - /** 与生产代码的 {@code ARG_HTML} / {@code ARG_HTML_OVERSIZED} 一致(均为私有常量,只能照抄键名)。 */ - private static final String ARG_HTML = "html"; - private static final String ARG_HTML_OVERSIZED = "htmlOversized"; - /** 与生产代码的 {@code MAX_PERSISTED_HTML_CHARS} 一致:两条边界用例要精确压在上限两侧。 */ - private static final int PERSIST_LIMIT = 32_768; - private static final String HTML = "

豆瓣短评正文

"; - - /** 面板宿主。必须走真实主题:面板是 Material 的 SideSheetDialog,主题缺属性会直接崩在构造里。 */ - public static class Host extends AppCompatActivity { - @Override - protected void onCreate(Bundle state) { - setTheme(R.style.Theme_App); - super.onCreate(state); - } - } - - @Test - public void showLoadingOpensTheShellInLoadingState() { - FragmentActivity activity = launch(); - String token = PluginPanelDialog.showLoading(activity, "豆瓣", null); - assertNotNull("加载壳必须能打开", token); - drain(activity); - - assertNotNull("加载壳必须以 plugin-panel 标签挂上", panel(activity)); - assertEquals("打开加载壳后必须可见地转圈", - View.VISIBLE, part(activity, R.id.pluginPanelLoading).getVisibility()); - assertTrue("加载中正文必须让位,否则会和转圈一起闪出来", - part(activity, R.id.pluginPanelWeb).getVisibility() != View.VISIBLE); - } - - @Test - public void fillReplacesTheSpinnerWithTheContent() { - FragmentActivity activity = launch(); - String token = PluginPanelDialog.showLoading(activity, "豆瓣", null); - drain(activity); - - PluginPanelDialog.fill(activity, token, HTML); - - assertEquals("结果回来必须收起转圈", - View.GONE, part(activity, R.id.pluginPanelLoading).getVisibility()); - View web = part(activity, R.id.pluginPanelWeb); - assertEquals("结果回来必须露出正文", View.VISIBLE, web.getVisibility()); - ShadowWebView shadow = Shadow.extract(web); - assertNotNull("正文必须真的交给 WebView,而不是只存在字段里", shadow.getLastLoadDataWithBaseURL()); - assertEquals("正文必须原样交给 WebView(宿主不做任何加工)", - HTML, shadow.getLastLoadDataWithBaseURL().data); - } - - /** - * B1 的回归锁:复用已有内容的面板后,空结果必须还能把壳撤掉。 - * - *

把复用分支改回"只 fill(title, null)、不清 {@code this.html}",这条会红:{@code dismissLoading} - * 会认为面板里是用户正在阅读的内容而放行,面板留在屏幕上永久转圈。 - */ - @Test - public void reusingAPanelWithContentCanStillBeDismissedByAnEmptyResult() { - FragmentActivity activity = launch(); - String first = PluginPanelDialog.showLoading(activity, "豆瓣", null); - drain(activity); - PluginPanelDialog.fill(activity, first, HTML); - - String second = PluginPanelDialog.showLoading(activity, "豆瓣", null); - assertNotNull("已有面板时应当复用并返回新 token", second); - drain(activity); - assertFalse("复用必须换发新 token,旧结果才会失效", second.equals(first)); - assertEquals("复用必须把面板真的切回加载态", - View.VISIBLE, part(activity, R.id.pluginPanelLoading).getVisibility()); - - PluginPanelDialog.dismissLoading(activity, second); - drain(activity); - - assertNull("复用后插件返回空必须撤壳:留下转圈的面板等于让用户等一个不会来的结果", - activity.getSupportFragmentManager().findFragmentByTag(TAG)); - } - - /** 反向守卫:用户正在阅读内容时(没有再发起动作),空结果不得把面板抢走。 */ - @Test - public void emptyResultNeverClosesAPanelTheUserIsReading() { - FragmentActivity activity = launch(); - String token = PluginPanelDialog.showLoading(activity, "豆瓣", null); - drain(activity); - PluginPanelDialog.fill(activity, token, HTML); - - PluginPanelDialog.dismissLoading(activity, token); - drain(activity); - - assertNotNull("已有内容的面板不是加载壳,空结果不许撤它", - activity.getSupportFragmentManager().findFragmentByTag(TAG)); - assertEquals("面板必须保持内容态", View.GONE, - part(activity, R.id.pluginPanelLoading).getVisibility()); - } - - /** - * B2 的回归锁:Fragment 被系统重建后,内容与标题都不能丢。 - * - *

用「保存实例状态 → 销毁 → 用同一份 Bundle 起一个新 Activity」模拟进程回收恢复: - * 这是唯一会真正走无参构造器重建 Fragment 的路径(旋屏不会——宿主页的 {@code configChanges} - * 已经声明 orientation|screenSize)。 - * - *

把 {@code persistState()} 或 {@code initView} 里回读 {@code ARG_HTML}/{@code ARG_TITLE} 的 - * 那几行改回去,这条会红:重建后的实例以为从未填过正文,面板退回永久转圈。 - */ - @Test - public void contentAndTitleSurviveFragmentRecreation() { - ActivityController before = Robolectric.buildActivity(Host.class).setup(); - FragmentActivity activity = before.get(); - String token = PluginPanelDialog.showLoading(activity, "豆瓣", null); - drain(activity); - PluginPanelDialog.fill(activity, token, HTML); - - Bundle state = new Bundle(); - before.saveInstanceState(state).pause().stop().destroy(); - - ActivityController after = Robolectric.buildActivity(Host.class).setup(state); - try { - FragmentActivity restored = after.get(); - drain(restored); - - assertNotNull("重建后面板必须还在", panel(restored)); - assertEquals("重建后标题必须仍是原来的标题,而不是退回占位文案", - "豆瓣", ((TextView) part(restored, R.id.pluginPanelTitle)).getText().toString()); - assertEquals("重建后不许退回加载态——这正是缺陷 B2 的症状", - View.GONE, part(restored, R.id.pluginPanelLoading).getVisibility()); - View web = part(restored, R.id.pluginPanelWeb); - assertEquals("重建后正文必须仍然可见", View.VISIBLE, web.getVisibility()); - ShadowWebView shadow = Shadow.extract(web); - assertNotNull("重建后必须把正文重新交给新的 WebView", shadow.getLastLoadDataWithBaseURL()); - assertEquals("重建后正文不许丢", HTML, shadow.getLastLoadDataWithBaseURL().data); - } finally { - after.pause().stop().destroy(); - } - } - - /** - * B1 的源码级备份锁:切回加载态必须无条件覆盖字段。 - * - *

行为用例已经覆盖同一条,但面板宿主 {@code SideSheetDialog} 在 JVM 下没有真正的窗口 - * (见 {@code PluginPanelAndContextDeviceTest} 的说明),若哪天环境连面板都建不起来, - * B1 的回归锁不该跟着无声消失。这条只钉修复本身:{@code fill} 里不许再有 - * {@code if (html != null) this.html = html;} 那种"清不掉旧内容"的写法。 - */ - @Test - public void fillAlwaysOverwritesTheContentField() throws Exception { - String fill = body(dialogSource(), "private void fill(@Nullable String newTitle, @Nullable String html)"); - - assertTrue("切回加载态必须清掉旧 html,否则复用后空结果撤不掉壳", fill.contains("this.html = html;")); - assertFalse("不许再用条件赋值留住旧内容", fill.contains("if (html != null) this.html = html;")); - assertTrue("字段变更必须同步进 Bundle,rebuild 才恢复得回来", fill.contains("persistState();")); - } - - /** B2 的源码级备份锁:内容与标题必须真的写进 arguments,并在 initView 里回读。 */ - @Test - public void contentAndTitleArePersistedAndReadBack() throws Exception { - String source = dialogSource(); - String persist = body(source, "private void persistState()"); - String initView = body(source, "protected void initView()"); - - assertTrue("超限判定必须按字符数与常量比较", persist.contains("html.length() > MAX_PERSISTED_HTML_CHARS")); - assertTrue("正文与超限标记必须互斥:写正文时不写标记", - persist.contains("args.putString(ARG_HTML, oversized ? null : html);")); - assertTrue("写标记时一定不写正文", persist.contains("args.putBoolean(ARG_HTML_OVERSIZED, oversized);")); - assertTrue(persist.contains("args.putString(ARG_TITLE, title);")); - assertTrue("重建后回读内容", initView.contains("args.getString(ARG_HTML)")); - assertTrue("重建后回读标题", initView.contains("title = args.getString(ARG_TITLE);")); - assertTrue("超限实例必须主动关掉面板", initView.contains("beginOversizedShellClose(binding.getRoot())")); - } - - /** - * 正文超限的行为锁:内容照常显示,但不许把整段正文塞进 arguments。 - * - *

回退 `persistState` 的 `oversized ? null : html` 那一行(改回无条件 `putString(ARG_HTML, html)`) - * 这条会红:`getArguments().getString("html")` 变回整段超长正文,即宿主退到后台时 - * {@code TransactionTooLargeException} 的来源。 - */ - @Test - public void oversizedContentDisplaysButIsNotPersisted() { - FragmentActivity activity = launch(); - String oversized = htmlOf(PERSIST_LIMIT + 1); - String token = PluginPanelDialog.showLoading(activity, "豆瓣", null); - drain(activity); - - PluginPanelDialog.fill(activity, token, oversized); - - assertEquals("超限正文照样要显示:上限只约束持久化,不约束渲染", - View.GONE, part(activity, R.id.pluginPanelLoading).getVisibility()); - View web = part(activity, R.id.pluginPanelWeb); - assertEquals("超限正文必须原样交给 WebView", View.VISIBLE, web.getVisibility()); - ShadowWebView shadow = Shadow.extract(web); - assertEquals("超限正文必须原样交给 WebView", oversized, shadow.getLastLoadDataWithBaseURL().data); - - Bundle args = panel(activity).getArguments(); - assertNotNull("面板必须有 arguments", args); - assertNull("超限正文不许进 arguments:它要随 onSaveInstanceState 走 Binder 事务", - args.getString(ARG_HTML)); - assertTrue("必须留下超限标记,重建实例才知道内容不可恢复", args.getBoolean(ARG_HTML_OVERSIZED)); - } - - /** - * 正文超限的重建锁:重建后面板必须已被关闭,而不是留一个永远转圈的壳。 - * - *

回退 `initView` 末尾的 `beginOversizedShellClose(binding.getRoot())`(或去掉幂等标记让投递 - * 不再落地)会红:面板还在,且因为 Bundle 里没有正文而永久转圈。 - */ - @Test - public void oversizedContentClosesThePanelAfterRecreation() { - ActivityController before = Robolectric.buildActivity(Host.class).setup(); - FragmentActivity activity = before.get(); - String token = PluginPanelDialog.showLoading(activity, "豆瓣", null); - drain(activity); - PluginPanelDialog.fill(activity, token, htmlOf(PERSIST_LIMIT + 1)); - - Bundle state = new Bundle(); - before.saveInstanceState(state).pause().stop().destroy(); - - ActivityController after = Robolectric.buildActivity(Host.class).setup(state); - try { - FragmentActivity restored = after.get(); - settle(restored); - - assertNull("内容过大无法忠实恢复时必须关掉面板;留着就是一个永远转圈的空壳", - restored.getSupportFragmentManager().findFragmentByTag(TAG)); - } finally { - after.pause().stop().destroy(); - } - } - - /** - * 上限的边界:**恰好等于**上限的正文仍然要被持久化并恢复。 - * - *

这条防的是把上限实现成"一律不持久化"(例如误写成 `>=` 或干脆不写)。回退 - * `persistState` 的比较方向(`>` 改 `>=`)或去掉写正文的分支,这条会红:重建后面板消失、 - * `getArguments().getString("html")` 为 null。 - */ - @Test - public void contentExactlyAtTheLimitSurvivesRecreation() { - String atLimit = htmlOf(PERSIST_LIMIT); - ActivityController before = Robolectric.buildActivity(Host.class).setup(); - FragmentActivity activity = before.get(); - String token = PluginPanelDialog.showLoading(activity, "豆瓣", null); - drain(activity); - PluginPanelDialog.fill(activity, token, atLimit); - - Bundle args = panel(activity).getArguments(); - assertNotNull("面板必须有 arguments", args); - assertEquals("恰好等于上限的正文必须照旧持久化", atLimit, args.getString(ARG_HTML)); - assertFalse("等于上限不是超限", args.getBoolean(ARG_HTML_OVERSIZED)); - - Bundle state = new Bundle(); - before.saveInstanceState(state).pause().stop().destroy(); - - ActivityController after = Robolectric.buildActivity(Host.class).setup(state); - try { - FragmentActivity restored = after.get(); - settle(restored); - - assertNotNull("等于上限的正文必须能恢复,面板不许被关掉", panel(restored)); - assertEquals("恢复后不许退回加载态", - View.GONE, part(restored, R.id.pluginPanelLoading).getVisibility()); - View web = part(restored, R.id.pluginPanelWeb); - ShadowWebView shadow = Shadow.extract(web); - assertEquals("恢复后正文必须原样交回 WebView", atLimit, shadow.getLastLoadDataWithBaseURL().data); - } finally { - after.pause().stop().destroy(); - } - } - - /** 造一段长度恰为 {@code length} 个字符的正文,用来精确压在上限的两侧。 */ - private static String htmlOf(int length) { - StringBuilder builder = new StringBuilder(length); - while (builder.length() < length) builder.append('评'); - return builder.toString(); - } - - private static String dialogSource() throws Exception { - Path root = Files.exists(Path.of("src", "main")) ? Path.of("") : Path.of("app"); - return Files.readString(root.resolve(Path.of("", "src", "main", "java", "com", "fongmi", "android", - "tv", "ui", "dialog", "PluginPanelDialog.java")), StandardCharsets.UTF_8); - } - - private static String body(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("缺少方法:" + signature, start >= 0); - int open = source.indexOf('{', start); - assertTrue("缺少方法体:" + signature, open >= 0); - int depth = 0; - for (int i = open; i < source.length(); i++) { - char value = source.charAt(i); - if (value == '{') depth++; - if (value == '}' && --depth == 0) return source.substring(open + 1, i); - } - throw new AssertionError("方法体未闭合:" + signature); - } - - private Host launch() { - return Robolectric.buildActivity(Host.class).setup().get(); - } - - private static void drain(FragmentActivity activity) { - activity.getSupportFragmentManager().executePendingTransactions(); - } - - /** - * 让投递到主线程的关闭动作真正跑起来。 - * - *

超限面板的关闭是 {@code binding.getRoot().post(...)} 投递的,停下 looper 或只排空 - * FragmentManager 事务队列都不够:得先让 looper 执行那个 Runnable,再把它新提交的事务排空。 - * 多来几轮是因为 {@code dismissAllowingStateLoss} 自身也可能再投递一次。 - */ - private static void settle(FragmentActivity activity) { - for (int i = 0; i < 3; i++) { - ShadowLooper.idleMainLooper(); - drain(activity); - } - } - - private static Fragment panel(FragmentActivity activity) { - return activity.getSupportFragmentManager().findFragmentByTag(TAG); - } - - private static View part(FragmentActivity activity, int id) { - Fragment fragment = panel(activity); - assertNotNull("面板必须存在", fragment); - View root = fragment.getView(); - assertNotNull("面板视图必须已经建立", root); - return root.findViewById(id); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/plugin/PluginRowRenderTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/plugin/PluginRowRenderTest.java deleted file mode 100644 index 2f0f6db77d..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/plugin/PluginRowRenderTest.java +++ /dev/null @@ -1,172 +0,0 @@ -package com.fongmi.android.tv.plugin; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import android.app.Activity; -import android.view.View; -import android.view.ViewGroup; - -import androidx.appcompat.app.AppCompatActivity; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import java.util.List; -import java.util.Set; - -/** - * 电视详情页「插件」行的真实渲染守卫。 - * - *

为什么需要它。这条链路此前只有源码断言:那能证明代码写了什么,证明不了 - * 布局里真有那个容器、也证明不了 chip 最终挂在了它下面。用户报"详情页看不到插件区域" - * 时,源码断言全绿而现象依旧——缺口正在这里。 - * - *

本测试把详情页布局真装一遍,再走 presenter 的挂载路径,断言三件事: - * 容器存在、标题行被点亮、chip 真的进了 {@code pluginRow}。 - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = App.class) -public class PluginRowRenderTest { - - /** - * 假详情页。 - * - *

类名**必须含 {@code Detail}**:{@link PluginSurface#of} 就是按类名判页面归属的, - * 换成别的名字会被判成 {@code other},于是 {@code surfaces=["detail"]} 的槽位永远落选—— - * 那是测试自己造出来的假阴性,不是产品缺陷。 - */ - public static class FakeDetailActivity extends AppCompatActivity { - @Override - protected void onCreate(android.os.Bundle state) { - setTheme(R.style.Theme_App); - super.onCreate(state); - } - } - - private static PluginHost.PluginSlot panelSlot(Set surfaces) { - PluginManifest.UiSlot slot = new PluginManifest.UiSlot( - "panel", "豆瓣", "douban.comments", null, null, surfaces); - return new PluginHost.PluginSlot("douban.comments", slot); - } - - @Test - public void detailLayoutHostsThePluginRowContainers() { - try (var host = Robolectric.buildActivity(FakeDetailActivity.class).setup()) { - host.get().setContentView(R.layout.activity_tmdb_detail); - - assertNotNull("详情页布局必须有 pluginRow 容器(chip 的落点)", - host.get().findViewById(R.id.pluginRow)); - assertNotNull("详情页布局必须有 pluginHeader(『插件』标题行)", - host.get().findViewById(R.id.pluginHeader)); - assertNotNull("详情页布局必须有 pluginScroll(横滚容器)", - host.get().findViewById(R.id.pluginScroll)); - - assertEquals("没有插件时标题行必须不占位", - View.GONE, host.get().findViewById(R.id.pluginHeader).getVisibility()); - } - } - - @Test - public void panelSlotLandsInThePluginRow() { - try (var host = Robolectric.buildActivity(FakeDetailActivity.class).setup()) { - Activity activity = host.get(); - activity.setContentView(R.layout.activity_tmdb_detail); - - PluginUiPresenter presenter = new PluginUiPresenter(); - presenter.attach(activity, List.of(panelSlot(Set.of("detail", "playback"))), true); - - ViewGroup row = activity.findViewById(R.id.pluginRow); - assertEquals("电视详情页的 panel 槽位必须落进插件行", 1, row.getChildCount()); - assertEquals("chip 的文案应来自清单 label", "豆瓣", - ((android.widget.TextView) row.getChildAt(0)).getText().toString()); - assertEquals("有插件时标题行必须现身", - View.VISIBLE, activity.findViewById(R.id.pluginHeader).getVisibility()); - assertEquals("有插件时横滚容器必须现身", - View.VISIBLE, activity.findViewById(R.id.pluginScroll).getVisibility()); - - presenter.detach(); - assertEquals("卸载后 chip 必须被摘掉", 0, row.getChildCount()); - assertEquals("卸载后标题行必须收回 GONE,否则详情页会留一行空标题", - View.GONE, activity.findViewById(R.id.pluginHeader).getVisibility()); - } - } - - /** - * 电视端详情页布局(activity_video.xml)的插件行必须同样可用。 - * - *

这条守护的是一个真实事故。电视详情页用的不是 activity_tmdb_detail.xml,而是 - * activity_video.xml;当年插件行只加进了前者,电视上永远看不到入口。而电视布局里那一行是 - * {@code androidx.appcompat.widget.LinearLayoutCompat}——它不是 {@code LinearLayout} 的子类, - * 按 LinearLayout 判类型会让 findViewById 的结果被静默丢掉。这条测试同时钉住这两件事。 - */ - @Test - public void leanbackDetailLayoutAlsoHostsThePluginRow() { - try (var host = Robolectric.buildActivity(LeanbackVideoHost.class).setup()) { - Activity activity = host.get(); - activity.setContentView(R.layout.activity_video); - - PluginUiPresenter presenter = new PluginUiPresenter(); - // 空 surfaces 表示"全页显示"(向后兼容语义),这样这条测试只考察 - // "布局里那一行收不收得到 chip",不被页面归属判定牵扯。 - presenter.attach(activity, List.of(panelSlot(Set.of())), true); - - ViewGroup row = activity.findViewById(R.id.pluginRow); - assertTrue("电视详情页布局里的插件行必须收得到 chip——它通常是 LinearLayoutCompat," - + "按 LinearLayout 判类型会把这一行静默丢掉", - row.getChildCount() > 0); - assertEquals("电视详情页的『插件』标题行必须现身", - View.VISIBLE, activity.findViewById(R.id.pluginHeader).getVisibility()); - } - } - - /** - * 沉浸融合播放器的控制条(playerActionRow)必须收到插件 chip。 - * - *

为什么单列一条。融合播放器的控制条不是 {@code R.id.control} 那一套——它是 - * {@code playerControls → playerBottom → playerActionRow} 里的按钮排。不专门认它,融合模式下 - * 详情行被 fusion 收起、控制条又不被识别,chip 就无处可去(用户实测反馈过)。 - * - *

为什么还断言"插在最前"。这一排按钮在横滚容器里,本身就比屏幕宽——实测末尾按钮 - * 落在 x≈2900,而屏幕只有 1920 宽。追加到末尾等于挂在一个默认看不见的位置。 - */ - @Test - public void fusionPlayerControlBarHostsThePluginChipAtTheFront() { - try (var host = Robolectric.buildActivity(FakeDetailActivity.class).setup()) { - Activity activity = host.get(); - activity.setContentView(R.layout.activity_tmdb_detail); - - ViewGroup actionRow = activity.findViewById(R.id.playerActionRow); - assertNotNull("前置条件:融合布局必须有 playerActionRow", actionRow); - ViewGroup buttons = (ViewGroup) actionRow.getChildAt(0); - int before = buttons.getChildCount(); - - PluginUiPresenter presenter = new PluginUiPresenter(); - presenter.attach(activity, List.of(panelSlot(Set.of())), true); - - assertEquals("chip 必须挂进融合控制条那一排", before + 1, buttons.getChildCount()); - View first = buttons.getChildAt(0); - assertTrue("chip 必须插在最前面:这一排比屏幕宽,追加到末尾等于看不见", - first instanceof android.widget.TextView && first.getTag() != null); - - presenter.detach(); - assertEquals("卸载后必须摘干净", before, buttons.getChildCount()); - } - } - - /** 电视详情页载体。名字不影响断言——本测试走的是空 surfaces,与页面归属无关。 */ - public static class LeanbackVideoHost extends AppCompatActivity { - @Override - protected void onCreate(android.os.Bundle state) { - setTheme(R.style.Theme_App); - super.onCreate(state); - } - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/LightDialogShellOpacityTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/LightDialogShellOpacityTest.java deleted file mode 100644 index 40e3dcd3ed..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/theme/LightDialogShellOpacityTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.graphics.drawable.Drawable; -import android.graphics.drawable.GradientDrawable; - -import androidx.appcompat.app.AppCompatActivity; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.dialog.LightDialog; -import com.github.catvod.utils.Prefers; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -/** - * The dialog shell has exactly one owner for each half of its contract. - * - *

{@code LightDialog.apply} is the shell owner for its whole call-site family. It - * builds that shell from the active palette and then hands the very same drawable to - * {@link ThemeController#bindWindowBackground}, which applies {@code dialogOpacity} - * itself. Baking the opacity into the shape as well scaled it twice - a profile that - * set {@code 0.70} produced {@code 0.49} - and, because a translucent fill no longer - * matches its baseline role in {@link ThemeColorIndex}, it also stopped the shell from - * following a user {@code surfaceContainerHigh} override. - * - *

Both halves are asserted on the real drawable the production path passes to the - * binder, so the guard cannot pass by reading the source instead of the behaviour. - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = App.class) -public class LightDialogShellOpacityTest { - - public static class Host extends AppCompatActivity { - @Override - protected void onCreate(android.os.Bundle state) { - setTheme(R.style.Theme_App); - super.onCreate(state); - } - } - - @Before - public void reset() { - Prefers.getPrefers().edit().clear().commit(); - } - - @Test - public void shellStartsOpaqueAndOnTheSemanticSurfaceRole() { - try (var host = Robolectric.buildActivity(Host.class).setup()) { - ThemeController.applyFromPreferences(host.get()); - Drawable shell = LightDialog.shell(host.get()); - assertEquals("the shell must ship opaque so dialogOpacity has a single owner", - 0xFF, fill(shell) >>> 24); - assertEquals("the shell fill must be the semantic surfaceContainerHigh value", - ThemeController.current().colorSurfaceContainerHigh(), fill(shell)); - } - } - - @Test - public void dialogOpacityReachesTheShellExactlyOnce() { - ThemeProfile profile = ThemeProfile.defaultProfile(); - // 两份表都设:TV 现在产品默认深色(与上游一致),而 mobile/显式浅色会解析 light 槽。 - // 本测试验证的是「opacity 只到达 shell 一次」这个机制,与哪张表生效无关。 - profile.light.dialogOpacity = 0.70f; - profile.dark.dialogOpacity = 0.70f; - assertTrue("the fixture profile must validate", ThemeProfileStore.apply(profile).success()); - - try (var host = Robolectric.buildActivity(Host.class).setup()) { - ThemeController.applyFromPreferences(host.get()); - assertEquals("the fixture must actually activate the profile opacity", - 0.70f, ThemeController.current().dialogOpacity(), 0.0001f); - - Drawable shell = LightDialog.shell(host.get()); - assertEquals("the shell must ship opaque, so only the binder scales it", - 0xFF, fill(shell) >>> 24); - ThemeController.bindWindowBackground(shell); - - // 0.70 * 255 = 178.5, and Math.round takes it up to 179. Baking the opacity into - // the shape as well used to produce 125 here (0.70 * 0.70). - assertEquals("dialogOpacity must reach the shell exactly once", 179, fill(shell) >>> 24); - } - } - - private static int fill(Drawable drawable) { - assertTrue("unexpected shell drawable: " + drawable, drawable instanceof GradientDrawable); - GradientDrawable shape = (GradientDrawable) drawable; - assertNotNull("the shell fill must exist", shape.getColor()); - return shape.getColor().getDefaultColor(); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/LiveListScrimRenderTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/LiveListScrimRenderTest.java deleted file mode 100644 index 5bb86944e2..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/theme/LiveListScrimRenderTest.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.drawable.Drawable; - -import androidx.appcompat.app.AppCompatActivity; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.github.catvod.utils.Prefers; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; -import org.robolectric.annotation.GraphicsMode; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -/** - * 行为级守卫:直播列表遮盖层是**整块统一**的,不是左浓右淡的渐变。 - * - *

为什么需要它:上一版把三档渐变(0.80 / 0.40 / 0.05)整体缩放,滑块拉到 100% 时右端 - * 只从 5% 涨到 6%,用户实测「只有左边缘变黑,右边没变化」。源码断言看不出这件事——它必须 - * 把生产路径返回的 drawable 真正栅格化,逐点比对整块宽度上的 alpha。 - * - *

本测试因此直接 {@link LiveListScrim#create(android.content.Context)} 取真实 drawable, - * 画到 1xN 位图上,断言每一列 alpha 都相同,且数值等于滑块值本身。 - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = App.class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -public class LiveListScrimRenderTest { - - private static final int WIDTH = 400; - private static final int HEIGHT = 8; - - public static class Host extends AppCompatActivity { - @Override - protected void onCreate(android.os.Bundle state) { - setTheme(R.style.Theme_App); - super.onCreate(state); - } - } - - @Before - public void reset() { - Prefers.getPrefers().edit().clear().commit(); - } - - @Test - public void theShippedDefaultIsOneFlatEightyPercentBlack() { - try (var host = Robolectric.buildActivity(Host.class).setup()) { - ThemeController.applyFromPreferences(host.get()); - Bitmap bitmap = rasterize(LiveListScrim.create(host.get())); - assertEveryColumnIs(bitmap, 0xCC, "the shipped default must be one flat 80% black"); - } - } - - @Test - public void theSliderAtOneHundredPercentBlacksOutTheWholeBlock() { - applyOpacity(1f); - try (var host = Robolectric.buildActivity(Host.class).setup()) { - ThemeController.applyFromPreferences(host.get()); - Bitmap bitmap = rasterize(LiveListScrim.create(host.get())); - assertEveryColumnIs(bitmap, 0xFF, "100% must black out the whole block, edge to edge"); - } - } - - @Test - public void theSliderAtTwentyPercentDimsTheWholeBlockEvenly() { - applyOpacity(0.20f); - try (var host = Robolectric.buildActivity(Host.class).setup()) { - ThemeController.applyFromPreferences(host.get()); - Bitmap bitmap = rasterize(LiveListScrim.create(host.get())); - assertEveryColumnIs(bitmap, 0x33, "20% must leave the whole block at 20% black"); - } - } - - @Test - public void theUnsetSlotRendersOneFlatBlockWithoutARightEdgeFade() { - try (var host = Robolectric.buildActivity(Host.class).setup()) { - ThemeController.applyFromPreferences(host.get()); - Bitmap bitmap = rasterize(LiveListScrim.create(host.get())); - assertEquals("the left edge must equal the static shape_live_list colour", - 0xCC, alphaAt(bitmap, 0)); - assertEquals("the right edge must not fade: it must equal the left edge", - alphaAt(bitmap, 0), alphaAt(bitmap, WIDTH - 1)); - } - } - - private static void applyOpacity(float opacity) { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.liveListScrimOpacity = opacity; - profile.dark.liveListScrimOpacity = opacity; - assertTrue("the fixture profile must validate", ThemeProfileStore.apply(profile).success()); - } - - private static void assertEveryColumnIs(Bitmap bitmap, int expected, String message) { - for (int x = 0; x < bitmap.getWidth(); x++) { - assertEquals(message + " (column " + x + ")", expected, alphaAt(bitmap, x)); - } - } - - private static int alphaAt(Bitmap bitmap, int x) { - return bitmap.getPixel(x, bitmap.getHeight() / 2) >>> 24; - } - - private static Bitmap rasterize(Drawable drawable) { - assertNotNull("the scrim must exist for an available theme snapshot", drawable); - Bitmap bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888); - drawable.setBounds(0, 0, WIDTH, HEIGHT); - drawable.draw(new Canvas(bitmap)); - return bitmap; - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeBinderFocusRingBehaviorTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeBinderFocusRingBehaviorTest.java deleted file mode 100644 index e7771223ab..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeBinderFocusRingBehaviorTest.java +++ /dev/null @@ -1,349 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.StateListDrawable; -import android.util.TypedValue; -import android.view.View; -import android.widget.FrameLayout; - -import androidx.appcompat.view.ContextThemeWrapper; - -import com.fongmi.android.tv.R; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.annotation.Config; -import org.robolectric.annotation.GraphicsMode; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -/** - * 行为级守卫:焦点环改写**只**发生在应用表面白名单内,且双宿主资源按视图主题分流。 - * - *

为什么需要它:{@code ThemeBinderFocusRingTest} 只做源码字符串断言,实现是否真的只碰白名单、 - * 白名单外是否真的不动,靠读代码看不出来。本轮评审就是这样漏掉了两个真实回归: - *

    - *
  1. 把 4 个 focus 态为 3dp 的卡片环强制改成 1.5dp,而它们的声明圆角是按「宿主圆角 − 环宽/2」 - * 以 3dp 推导的,改宽度会重新露出卡片圆角外的月牙;
  2. - *
  3. 把刻意使用 {@code ?attr/tvPlayerRing} 的固定深色玻璃族(弹幕/音轨/字幕/播控条)改成 - * 调色板 FOCUS 深蓝 {@code #0B57D0},浅色表在深靛玻璃上只有 1.83:1,焦点环等于看不见。
  4. - *
- * - *

本测试因此直接比较「bind 前 / bind 后」的栅格化位图: - *

    - *
  • 白名单内({@link #WHITELISTED} 全部 9 项):焦点态环色必须变成活动主题的 FOCUS 槽;
  • - *
  • 白名单外(玻璃族、3dp 卡片族、本来无描边的图标按钮、成对语义族):位图必须**逐像素完全相同**;
  • - *
  • 双宿主资源 {@code selector_video_item}:同一份 drawable 在应用表面宿主被改写,在 - * {@code ThemeOverlay.WebHTV.GlassFocusRings} 子树里保持视频层环色,且结论与 bind 顺序无关。
  • - *
- * - *

注意白名单资源只存在于 leanback 源集,因此本测试类放在 {@code src/testLeanback} - * (只在电视变体编译,可以硬引用 {@code R.style.ThemeOverlay_WebHTV_GlassFocusRings}); - * 资源仍按名字查找,是为了在没有该资源的配置下跳过而不是把整类变成空断言。 - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = com.fongmi.android.tv.App.class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -public class ThemeBinderFocusRingBehaviorTest { - - private static final int SIZE = 96; - - /** 白名单内全部项(必须与 {@code ThemeBinder.APP_SURFACE_FOCUS_RINGS} 逐项一致)。 */ - private static final String[] WHITELISTED = { - "selector_item", - "selector_item_round", - "selector_vod", - "selector_vod_oval", - "selector_keyboard", - "selector_search_hot_word", - "selector_video_item", - "selector_tv_search_field", - "selector_empty_action", - }; - - /** - * 环画在 {@code android:foreground} 的资源。电视卡片/OU 控件把焦点环画在内容之上, - * 只走 {@code getBackground()} 的实现永远碰不到它们——这正是 {@code bindForeground} - * 存在的理由,也是本条必须覆盖 foreground 路径的原因。 - */ - private static final Set FOREGROUND_HOSTED = new HashSet<>(Arrays.asList( - "selector_vod", - "selector_vod_oval", - "selector_tmdb_media_focus", - "selector_tmdb_cast_focus", - "shape_episode_photo_focused")); - - /** - * 双宿主资源:普通页面按应用表面语义用,{@code dialog_episode_list} / - * {@code dialog_quick_search} 两处固定深色玻璃对话框的根节点用 - * {@code @style/ThemeOverlay.WebHTV.GlassFocusRings} 把 {@code tvFocusRing} 重绑到视频层环色。 - */ - private static final String DUAL_HOSTED = "selector_video_item"; - - /** - * 白名单外,必须逐像素不变。 - * glass 族 = 刻意使用 ?attr/tvPlayerRing(浅色表夹取到 #447BF5);card3dp = 焦点态 3dp 未收敛; - * strokeless = 焦点态本就没有描边。 - */ - private static final String[] EXCLUDED = { - // 固定深色玻璃上、刻意用 ?attr/tvPlayerRing 的族(浅色表夹取到 #447BF5)。 - "selector_glass_icon_action", - "selector_control_sheet_button", - "shape_subtitle_focused", - "shape_subtitle_pressed", - // 固定深色播放器弹窗芯片:焦点环用 ?attr/tvPlayerRing,且把「焦点+选中」合并态 - // 特意换成黑色环(白环压琥珀底不可见),改写会破坏这个成对设计。 - "selector_display_option", - // 焦点态 3dp、圆角按 3dp 推导的未收敛卡片族。 - "selector_episode_card", - "selector_tmdb_media_focus", - "selector_tmdb_cast_focus", - "shape_episode_photo_focused", - // 焦点态本就没有描边的控件(改了就凭空多出一圈环)。 - "about_primary_icon_button", - "selector_ad_rule", - "selector_dialog_step_button", - // 「colorPrimary 填充 + colorOnPrimary 环」的成对语义:环色必须跟随填充的 on-色, - // 改写成 colorFocus() 会让环与填充同色(默认色板 focus == primary)而消失。 - "selector_config_history_item", - "selector_site_item", - "selector_group_button", - // 与 shape_search_hot_word_focused 的填充/圆角/内边距完全相同(black_40 + 8dp + 无内边距), - // 只有描边不同(2dp @color/accent vs token ?attr/tvFocusRing)。描边无公开 getter, - // 因此靠位图指纹区分——本项就是防止「签名相同即改写」的碰撞回归。 - "selector_lab_icon_btn", - }; - - @Test - public void whitelistedAppSurfaceRingsFollowTheThemeFocusSlot() { - Context context = themedContext(); - // 用真实的 ThemeTokens.light() 作基线、dark() 作活动值:两者 FOCUS 槽必然不同 - // (浅色 #0B57D0 / 深色 #F5F7FF),从而 binder 不会因为 baseline.equals(active) 而整体 no-op。 - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = ThemeTokens.dark(); - int focusSlot = active.colorFocus(); - assertNotEquals("测试前提:活动主题的 FOCUS 槽必须与基线不同,否则 binder 整体 no-op", - baseline.colorFocus(), focusSlot); - - int checked = 0; - for (String name : WHITELISTED) { - int resId = drawableId(context, name); - if (resId == 0) continue; // 该配置下没有此资源:见类注释。 - checked++; - - FrameLayout root = new FrameLayout(context); - View view = new View(context); - attach(view, context, name, resId); - root.addView(view); - - Bitmap before = rasterize(focusedState(slot(view))); - ThemeBinder.bind(root, baseline, active); - Bitmap after = rasterize(focusedState(slot(view))); - - assertNotEquals(name + " 的焦点环必须在 bind 后被改写(否则用户改焦点色对它无效)", - describe(before), describe(after)); - assertTrue(name + " 改写后的焦点环必须出现活动主题的 FOCUS 槽颜色 " - + Integer.toHexString(focusSlot), - containsColor(after, focusSlot)); - assertFalse(name + " 改写前不应已含 FOCUS 槽颜色,否则本条断言无意义", - containsColor(before, focusSlot)); - } - assertEquals("白名单在本源集必须全部可解析(leanback 源集才有这些资源)", - WHITELISTED.length, checked); - } - - @Test - public void excludedRingsArePiecewiseUntouched() { - Context context = themedContext(); - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = ThemeTokens.dark(); - int focusSlot = active.colorFocus(); - int checked = 0; - for (String name : EXCLUDED) { - int resId = drawableId(context, name); - if (resId == 0) continue; // 该配置下没有此资源 - checked++; - - FrameLayout root = new FrameLayout(context); - View view = new View(context); - attach(view, context, name, resId); - root.addView(view); - - Bitmap before = rasterize(focusedState(slot(view))); - ThemeBinder.bind(root, baseline, active); - Bitmap after = rasterize(focusedState(slot(view))); - - assertEquals("白名单外的焦点环必须完全不被改写(玻璃族/3dp 卡片族/无描边族/成对语义族):" + name, - describe(before), describe(after)); - assertFalse(name + " 属于白名单外,不允许出现 FOCUS 槽颜色 " - + Integer.toHexString(focusSlot), - containsColor(after, focusSlot)); - } - assertTrue("白名单外的排除集在本源集内必须至少命中一个资源,否则本条是空断言", checked > 0); - assertEquals("排除集必须全部可解析,否则断言被静默跳过", EXCLUDED.length, checked); - } - - /** - * 同一个 {@code selector_video_item} 被两类宿主共用:普通页面(应用表面语义)与固定深色玻璃 - * 对话框子树({@code GlassFocusRings} 重绑到视频层环色)。只看内容签名的实现会把玻璃子树 - * 也改写成 FOCUS 深蓝(浅色表在深靛玻璃上 1.83:1),因此这里对两个宿主**同时**断言, - * 并且跑两遍相反顺序,防止「按 Resources 缓存」让结论与首次调用顺序绑定。 - */ - @Test - public void dualHostedSelectorKeepsTheGlassSubtreeAtTheVideoLayerRing() { - verifyDualHost(true); - verifyDualHost(false); - } - - private void verifyDualHost(boolean appSurfaceFirst) { - Context appContext = themedContext(); - Context glassContext = new ContextThemeWrapper(appContext, R.style.ThemeOverlay_WebHTV_GlassFocusRings); - int appRing = ringColor(appContext); - int glassRing = ringColor(glassContext); - assertTrue("测试前提:应用表面主题必须能解析出焦点环色", appRing != 0); - assertNotEquals("测试前提:玻璃子树必须把 tvFocusRing 重绑到视频层环色,否则本条无意义", - appRing, glassRing); - - int resId = drawableId(appContext, DUAL_HOSTED); - if (resId == 0) return; - - FrameLayout root = new FrameLayout(appContext); - View appSurface = new View(appContext); - appSurface.setBackground(appContext.getDrawable(resId)); - View glass = new View(glassContext); - glass.setBackground(glassContext.getDrawable(resId)); - if (appSurfaceFirst) { - root.addView(appSurface); - root.addView(glass); - } else { - root.addView(glass); - root.addView(appSurface); - } - - Bitmap appBefore = rasterize(focusedState(appSurface.getBackground())); - Bitmap glassBefore = rasterize(focusedState(glass.getBackground())); - - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = ThemeTokens.dark(); - int focusSlot = active.colorFocus(); - ThemeBinder.bind(root, baseline, active); - - Bitmap appAfter = rasterize(focusedState(appSurface.getBackground())); - Bitmap glassAfter = rasterize(focusedState(glass.getBackground())); - - assertNotEquals("应用表面宿主上的 " + DUAL_HOSTED + " 必须跟随主题 FOCUS 槽(appSurfaceFirst=" - + appSurfaceFirst + ")", - describe(appBefore), describe(appAfter)); - assertTrue("应用表面宿主必须出现 FOCUS 槽颜色 " + Integer.toHexString(focusSlot), - containsColor(appAfter, focusSlot)); - - assertEquals("玻璃子树里的同一 selector 必须保持视频层环色:改写会让深蓝环贴在固定深靛玻璃上" - + "(实测 1.83:1)而不可见(appSurfaceFirst=" + appSurfaceFirst + ")", - describe(glassBefore), describe(glassAfter)); - assertFalse("玻璃子树不允许出现 FOCUS 槽颜色 " + Integer.toHexString(focusSlot) - + "(appSurfaceFirst=" + appSurfaceFirst + ")", - containsColor(glassAfter, focusSlot)); - } - - @Test - public void whitelistIsNotEmptyOnTheTvSourceSet() { - Context context = themedContext(); - int resolved = 0; - for (String name : WHITELISTED) { - if (drawableId(context, name) != 0) resolved++; - } - assertEquals("leanback 源集上白名单资源必须全部可解析(否则 binder 静默不改写任何焦点环)", - WHITELISTED.length, resolved); - } - - // ------------------------------------------------------------------ helpers - - private static Context themedContext() { - RuntimeEnvironment.setQualifiers(""); - return new ContextThemeWrapper(RuntimeEnvironment.getApplication(), R.style.Theme_App); - } - - /** 按名字查找:白名单资源只在 leanback 源集,main 源集不能硬引用 R.drawable.*。 */ - private static int drawableId(Context context, String name) { - return context.getResources().getIdentifier(name, "drawable", context.getPackageName()); - } - - /** 把这层 drawable 挂到宿主真正使用的插槽上(foreground 还是 background)。 */ - private static void attach(View view, Context context, String name, int resId) { - Drawable drawable = context.getDrawable(resId); - if (FOREGROUND_HOSTED.contains(name)) view.setForeground(drawable); - else view.setBackground(drawable); - } - - private static Drawable slot(View view) { - Drawable foreground = view.getForeground(); - return foreground != null ? foreground : view.getBackground(); - } - - /** 视图主题解析出的 {@code ?attr/tvFocusRing}(与 {@code ThemeBinder} 的判定同源)。 */ - private static int ringColor(Context context) { - TypedValue value = new TypedValue(); - if (!context.getTheme().resolveAttribute(R.attr.tvFocusRing, value, true)) return 0; - if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { - return value.data; - } - return value.resourceId == 0 ? 0 : context.getResources().getColor(value.resourceId, null); - } - - /** - * 取焦点态要栅格化的那层 drawable: - * selector 取 state_focused 子项;非 selector(如 layer-list 的图标按钮)原样返回。 - */ - private static Drawable focusedState(Drawable drawable) { - if (!(drawable instanceof StateListDrawable states)) return drawable; - for (int i = 0; i < states.getStateCount(); i++) { - for (int state : states.getStateSet(i)) { - if (state == android.R.attr.state_focused) return states.getStateDrawable(i); - } - } - return states.getStateCount() > 0 ? states.getStateDrawable(0) : null; - } - - /** 位图里是否出现该颜色(忽略 alpha 差异,按 RGB 比较)。 */ - private static boolean containsColor(Bitmap bitmap, int color) { - int rgb = color & 0x00FFFFFF; - for (int y = 0; y < bitmap.getHeight(); y++) { - for (int x = 0; x < bitmap.getWidth(); x++) { - if ((bitmap.getPixel(x, y) & 0x00FFFFFF) == rgb) return true; - } - } - return false; - } - - private static Bitmap rasterize(Drawable drawable) { - Bitmap bitmap = Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888); - if (drawable != null) { - drawable.setBounds(0, 0, SIZE, SIZE); - drawable.draw(new Canvas(bitmap)); - } - return bitmap; - } - - /** 逐像素指纹:稀疏采样会漏掉 1-2px 的描边差异,所以每像素都算,只把结果压成哈希串。 */ - private static String describe(Bitmap bitmap) { - long hash = 1125899906842597L; - for (int y = 0; y < bitmap.getHeight(); y++) { - for (int x = 0; x < bitmap.getWidth(); x++) { - hash = 31 * hash + bitmap.getPixel(x, y); - } - } - return Long.toHexString(hash) + '-' + bitmap.getWidth() + 'x' + bitmap.getHeight(); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeBinderItemOverlayTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeBinderItemOverlayTest.java deleted file mode 100644 index bf19d32c4e..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeBinderItemOverlayTest.java +++ /dev/null @@ -1,589 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.GradientDrawable; -import android.graphics.drawable.StateListDrawable; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.FrameLayout; - -import androidx.appcompat.view.ContextThemeWrapper; - -import com.fongmi.android.tv.R; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.annotation.Config; -import org.robolectric.annotation.GraphicsMode; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -/** - * 条目遮罩槽位({@code ITEM_OVERLAY})驱动真实界面的行为级守卫。 - * - *

为什么必须有这条测试:{@code ?attr/webhtvColorItemOverlay} 是**编译期**静态资源。 - * 把 drawable 的 {@code } 从 {@code @color/black_40} 改成 {@code ?attr/...} 只让 - * 「默认外观」和「主题表」通了电,用户改槽位之后已经充气的 drawable **不会**重新解析—— - * 没有运行时改写那一步,新槽位就只会出现在编辑器预览和 Web 快照里,真实界面纹丝不动 - * (这正是 {@code ThemeBinder} 里 {@code APP_SURFACE_FOCUS_RINGS} 当初存在的同一个理由)。 - * - *

因此这里不看源码字符串,而是把**真实条目布局**充气出来、喂给真实的 - * {@link ThemeBinder#bind},再读回背景 drawable 的实心填充: - *

    - *
  1. {@link #itemOverlaySlotDrivesTheRealItemHost}:改过 {@code itemOverlay} 槽的档案 - * 必须真的把条目宿主底色改成用户选的颜色(聚焦态与选中态),并且选中态与聚焦态同色;
  2. - *
  3. {@link #defaultProfileLeavesTheItemHostAtTheCompiledOverlay}:空档案下必须是 - * {@code #66000000}({@code @color/black_40})——这是「默认外观字节不变」的机器化表述;
  4. - *
  5. {@link #overlayExcludedDrawablesAreNotRewritten}:{@code shape_vod_name} / - * {@code shape_widget} / {@code shape_keyboard_normal} 虽然与白名单成员同为 - * {@code #66000000},但它们在**不可控底材**上或属刻意固定的键底,必须逐像素不变—— - * 按颜色反查角色会让它们全部跟着变,本测试就是那条边界的钉子。
  6. - *
  7. {@link #onItemOverlaySlotDrivesTheItemHostText}:遮罩上的文字色同样只有运行时才能生效 - * (宿主的文字在布局里直接写 {@code ?attr/webhtvColorOnWallpaper},没有任何颜色状态列表 - * 可以改),{@link #overlayTextIsNotWrittenOnExcludedHosts} 则钉住这条改写只落在白名单宿主上。
  8. - *
- * - *

两档强度:{@code shape_item_normal} / {@code shape_item_round_normal} / - * {@code shape_search_hot_word_normal} 的编译期填充是 {@code black_20}({@code #33000000}), - * 比白名单成员的 {@code black_40} 浅一半。它们同样跟随槽位,但写的是**半强度**( - * {@code ThemeBinder} 的 {@code ITEM_OVERLAY_REST}):{@code #66000000} 的一半逐位等于 - * {@code #33000000},所以空档案下字节不变,而用户改槽位后「常态比聚焦浅一半」的层次也仍在。 - * - *

{@code adapter_func.xml} 是电视首页功能按钮行(搜索/历史)的真实条目宿主: - * {@code android:background="@drawable/selector_item"},即 - * focused → {@code shape_item_focused}、selected → {@code shape_item_activated}、 - * normal → {@code shape_item_normal}。{@code adapter_search_hot_word.xml} 覆盖圆角胶囊族 - * ({@code selector_search_hot_word})。 - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = com.fongmi.android.tv.App.class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -public class ThemeBinderItemOverlayTest { - - /** 编译期条目遮罩底色:{@code @color/black_40}。 */ - private static final int COMPILED_OVERLAY = 0x66000000; - /** 编译期常态底色:{@code @color/black_20}。它恰好是 {@code black_40} 的一半 alpha。 */ - private static final int COMPILED_NORMAL = 0x33000000; - /** 编译期选中/焦点描边之外的第三档:{@code @color/black_30}({@code shape_item_selected} 等)。 */ - private static final int COMPILED_SELECTED = 0x4D000000; - - /** 用户在编辑器里选的遮罩色(不含 alpha,解析后按不透明处理)。 */ - private static final String USER_OVERLAY_HEX = "#8A2040"; - private static final int USER_OVERLAY = 0xFF8A2040; - /** 常态底座拿到的是同一颜色的半强度:{@code 0xFF * 50 / 100 = 0x7F}。 */ - private static final int USER_OVERLAY_REST = 0x7F8A2040; - - /** - * 用户在编辑器里选的选中文字色。 - * - *

刻意选暖色:解析器会对它做 4.5:1 的明度夹取,所以断言必须读**解析后的 token**, - * 而不是把这个十六进制直接当成期望值——否则夹取一发生,测试就会错怪实现。 - */ - private static final String USER_TEXT_HEX = "#FFE082"; - - private static final int SIZE = 96; - - // ------------------------------------------------------------------ 交付物 C:真的生效 - - /** - * 改过 {@code itemOverlay} 槽的档案必须真的改变电视首页功能按钮行的底色。 - * - *

断言三层:聚焦态与选中态都变成用户色;两者同色(同一行按钮不该在两种状态露出两种底); - * 常态拿到同一颜色的半强度——{@code shape_item_normal} 的编译期填充是 {@code black_20}, - * 整块写入会让空档案从 20% 黑跳到 40% 黑,半强度下默认取值与编译期逐位相同。 - */ - @Test - public void itemOverlaySlotDrivesTheRealItemHost() { - Context context = themedContext(); - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = resolveWithOverlay(context); - assertEquals("测试前提:活动 tokens 的遮罩槽必须就是用户选的颜色", - USER_OVERLAY, active.colorItemOverlay()); - assertNotEquals("测试前提:活动 tokens 必须与基线不同,否则 binder 整体 no-op", - baseline, active); - - View host = inflateHost(context, R.layout.adapter_func); - assertNotNull("首页功能按钮行必须挂着 selector_item", host.getBackground()); - assertEquals("测试前提:宿主背景必须是状态选择器,否则本条断言覆盖不到状态分支", - COMPILED_OVERLAY, fillOfState(host.getBackground(), android.R.attr.state_focused)); - - FrameLayout root = new FrameLayout(context); - root.addView(host); - ThemeBinder.bind(root, baseline, active); - - assertEquals("聚焦态条目底色必须变成用户选的遮罩色(功能未生效就是这里失败)", - USER_OVERLAY, fillOfState(host.getBackground(), android.R.attr.state_focused)); - assertEquals("选中态条目底色必须一起变,否则同一行按钮在聚焦/选中态露出两种底", - USER_OVERLAY, fillOfState(host.getBackground(), android.R.attr.state_selected)); - assertEquals("常态必须拿到用户色的半强度:shape_item_normal 的编译期填充是 black_20," - + "整块写入会让「没改过主题」的默认外观从 20% 黑跳到 40% 黑", - USER_OVERLAY_REST, fillOfState(host.getBackground(), 0)); - - // 栅格化复核:用户色必须真的画出来,而不只是 getColor() 里的数字。 - assertTrue("改过的底色必须真的栅格化出用户色 " + Integer.toHexString(USER_OVERLAY), - containsColor(rasterize(stateDrawable(host.getBackground(), android.R.attr.state_focused)), USER_OVERLAY)); - } - - /** 圆角胶囊族(搜索热词)走同一套机制:{@code selector_search_hot_word} → {@code shape_search_hot_word_focused}。 */ - @Test - public void itemOverlaySlotDrivesTheHotWordCapsule() { - Context context = themedContext(); - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = resolveWithOverlay(context); - - View host = inflateHost(context, R.layout.adapter_search_hot_word); - FrameLayout root = new FrameLayout(context); - root.addView(host); - ThemeBinder.bind(root, baseline, active); - - assertEquals("搜索热词胶囊的聚焦态同样必须跟随条目遮罩槽", - USER_OVERLAY, fillOfState(host.getBackground(), android.R.attr.state_focused)); - assertEquals("热词胶囊的常态同样跟随槽位,但写半强度(编译期它是 black_20)", - USER_OVERLAY_REST, fillOfState(host.getBackground(), 0)); - } - - /** - * 搜索输入框({@code selector_tv_search_field})的两个状态都是白名单成员:常态与聚焦态都跟随。 - * - *

这一条单独钉住「normal 也接线」的那一半:{@code shape_tv_search_field} 的填充本就是 - * {@code black_40},所以接上槽位**不会**改变默认外观,两个状态都该跟着用户走。 - */ - @Test - public void itemOverlaySlotDrivesTheSearchFieldBothStates() { - Context context = themedContext(); - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = resolveWithOverlay(context); - - FrameLayout root = new FrameLayout(context); - View host = new View(context); - host.setBackground(context.getDrawable(R.drawable.selector_tv_search_field)); - root.addView(host); - ThemeBinder.bind(root, baseline, active); - - assertEquals("搜索输入框常态必须跟随遮罩槽(它的编译期填充本就是 black_40,不会造成外观回归)", - USER_OVERLAY, fillOfState(host.getBackground(), 0)); - assertEquals("搜索输入框聚焦态必须跟随遮罩槽", - USER_OVERLAY, fillOfState(host.getBackground(), android.R.attr.state_focused)); - } - - // --------------------------------------------------- 交付物 C:选中文字色也真的生效 - - /** - * 改过 {@code onItemOverlay} 槽的档案必须真的改变条目宿主上的文字色。 - * - *

为什么这条不能靠「改 {@code res/color/} 里的 focused 态」实现:条目宿主的文字在布局里 - * 直接写 {@code ?attr/webhtvColorOnWallpaper}(单色、无状态),仓库里没有任何「条目宿主 + - * {@code ?attr/colorOnPrimary}} 的颜色状态列表可改;反过来硬改 {@code home_menu_text} / - * {@code site_item_text} 会把 colorPrimary 实心底上的文字换成遮罩色文字,那是可读性回归。 - * 因此这里断言的是运行时通道:{@link ThemeBinder#bind} 把新色写进 {@code adapter_func.xml} - * 的 {@code @id/text}(它是「容器挂 selector_item、文字是子 TextView」的形状,所以判定必须 - * 允许向上看一层父容器)。 - * - *

断言取值用**解析后的 token** 而不是用户输入的十六进制:{@code ThemeResolver} 会对 - * {@code onItemOverlay} 做 4.5:1 的明度夹取,硬编码用户输入会把夹取误判成缺陷。 - */ - @Test - public void onItemOverlaySlotDrivesTheItemHostText() { - Context context = themedContext(); - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = resolveWithOverlayText(context); - assertNotEquals("测试前提:活动 tokens 必须与基线不同,否则 binder 整体 no-op", baseline, active); - assertNotEquals("测试前提:选中文字色槽必须真的解析出新颜色", - baseline.colorOnItemOverlay(), active.colorOnItemOverlay()); - - View host = inflateHost(context, R.layout.adapter_func); - View text = host.findViewById(R.id.text); - assertNotNull("条目宿主必须带文字子视图", text); - assertEquals("测试前提:宿主文字在布局里是 ?attr/webhtvColorOnWallpaper(单色、无状态)", - context.getColor(R.color.webhtv_on_wallpaper), - ((android.widget.TextView) text).getTextColors().getDefaultColor()); - - FrameLayout root = new FrameLayout(context); - root.addView(host); - ThemeBinder.bind(root, baseline, active); - - assertEquals("条目宿主上的文字必须变成解析后的选中文字色(功能未生效就是这里失败)", - active.colorOnItemOverlay(), - ((android.widget.TextView) text).getTextColors().getDefaultColor()); - assertNotEquals("文字必须真的离开编译期的 on_wallpaper,否则「生效」是假的", - context.getColor(R.color.webhtv_on_wallpaper), - ((android.widget.TextView) text).getTextColors().getDefaultColor()); - } - - /** - * 只改文字色槽之外的东西时,条目宿主文字必须逐位保持编译期取值。 - * - *

这一条钉住「默认外观字节不变」在文字上的一半:夜间表 {@code on_item_overlay} 是 - * {@code #062E6F},而条目宿主的实际文字是 {@code on_wallpaper}({@code #FFFFFF})。 - * 若 binder 无条件把文字改写成 {@code colorOnItemOverlay()},用户哪怕只调了一个无关槽位, - * 条目文字也会突然变深——这正是 {@link ThemeBinder#bind} 里那条 - * 「{@code colorOnItemOverlay} 与基线比较」的前置判定存在的理由。 - */ - @Test - public void itemHostTextStaysCompiledWhenOnlyOtherSlotsChange() { - Context context = themedContext(); - ThemeTokens baseline = ThemeTokens.light(); - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.focus = "#33AA55"; - ThemeTokens active = resolveWithProfile(context, profile); - assertNotEquals("测试前提:档案必须真的改动了一个槽位", baseline, active); - assertEquals("测试前提:本档案没有改选中文字色槽,token 必须与基线相同", - baseline.colorOnItemOverlay(), active.colorOnItemOverlay()); - - View host = inflateHost(context, R.layout.adapter_func); - View text = host.findViewById(R.id.text); - assertNotNull("条目宿主必须带文字子视图", text); - int compiled = ((android.widget.TextView) text).getTextColors().getDefaultColor(); - - FrameLayout root = new FrameLayout(context); - root.addView(host); - ThemeBinder.bind(root, baseline, active); - - assertEquals("没改选中文字色槽时,条目文字必须仍是编译期取值", - compiled, ((android.widget.TextView) text).getTextColors().getDefaultColor()); - assertEquals("且必须就是 on_wallpaper 的编译期色,不是 on_item_overlay", - context.getColor(R.color.webhtv_on_wallpaper), compiled); - } - - /** - * 遮罩文字色的改写只允许落在白名单宿主上:键底({@code shape_keyboard_normal})上的文字 - * 虽然与条目底色同为 {@code #66000000},语义却是「压在用户画布上的键」,必须保持原样。 - * - *

与 {@link #overlayExcludedDrawablesAreNotRewritten} 配对:那一条钉的是填充不被改写, - * 这一条钉的是**文字**不被改写——两条一起才排除了「按 {@code black_40} 反查」的实现。 - */ - @Test - public void overlayTextIsNotWrittenOnExcludedHosts() { - Context context = themedContext(); - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = resolveWithOverlayText(context); - - FrameLayout root = new FrameLayout(context); - android.widget.TextView text = new android.widget.TextView(context); - text.setTextColor(context.getColor(R.color.webhtv_on_wallpaper)); - text.setBackground(context.getDrawable(R.drawable.shape_keyboard_normal)); - root.addView(text); - ThemeBinder.bind(root, baseline, active); - - assertEquals("键底不在条目遮罩白名单内,压在它上面的文字必须保持编译期颜色", - context.getColor(R.color.webhtv_on_wallpaper), - text.getTextColors().getDefaultColor()); - assertEquals("键底填充也不允许被改写", COMPILED_OVERLAY, fillOf(text.getBackground())); - } - - // ------------------------------------------------------- 交付物 A:默认外观字节不变 - - /** - * 空档案下条目宿主必须与改动前逐值一致:{@code bind} 是严格 no-op, - * 且 {@code ?attr/webhtvColorItemOverlay} 解析回编译期取值 {@code #66000000}。 - */ - @Test - public void defaultProfileLeavesTheItemHostAtTheCompiledOverlay() { - Context context = themedContext(); - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = resolveWithProfile(context, ThemeProfile.defaultProfile()); - assertEquals("空档案必须解析出冻结色板", baseline, active); - - View host = inflateHost(context, R.layout.adapter_func); - Bitmap before = rasterize(host.getBackground()); - FrameLayout root = new FrameLayout(context); - root.addView(host); - ThemeBinder.bind(root, baseline, active); - Bitmap after = rasterize(host.getBackground()); - - assertEquals("空档案下 binder 必须完全不动条目宿主(默认外观字节不变的机器化表述)", - describe(before), describe(after)); - assertEquals("空档案的聚焦态必须仍是 @color/black_40", - COMPILED_OVERLAY, fillOfState(host.getBackground(), android.R.attr.state_focused)); - assertEquals("空档案的选中态必须仍是 @color/black_40", - COMPILED_OVERLAY, fillOfState(host.getBackground(), android.R.attr.state_selected)); - assertEquals("空档案的常态必须仍是 @color/black_20", - COMPILED_NORMAL, fillOfState(host.getBackground(), 0)); - } - - /** - * 交付物 A 的另一半:主题必须真的把两个槽位属性赋上值,取值等于编译期 token。 - * - *

「只声明、没赋值」是本仓库审计里反复出现的缺陷:属性解析不到时 {@code ?attr/} 会 - * **静默**退化成透明,不崩溃,只有人眼才能发现。 - */ - @Test - public void themeAssignsBothItemOverlayAttributesToTheCompiledTokens() { - Context context = themedContext(); - assertEquals("webhtvColorItemOverlay 必须赋值,且等于 webhtv_color_item_overlay", - context.getColor(R.color.webhtv_color_item_overlay), attrColor(context, R.attr.webhtvColorItemOverlay)); - assertEquals("webhtvColorOnItemOverlay 必须赋值,且等于 webhtv_color_on_item_overlay", - context.getColor(R.color.webhtv_color_on_item_overlay), attrColor(context, R.attr.webhtvColorOnItemOverlay)); - assertEquals("编译期遮罩 token 必须就是 black_40,否则默认外观已经变了", - COMPILED_OVERLAY, context.getColor(R.color.webhtv_color_item_overlay)); - } - - // ------------------------------------------------- 排除项:必须逐像素不变 - - /** - * 排除清单里的三个 drawable 必须在**运行时也不被改写**。 - * - *

它们与白名单成员共用同一个 {@code #66000000} 填充({@code shape_vod_name} / - * {@code shape_widget} / {@code shape_keyboard_normal} 全都是 {@code @color/black_40}, - * 而 leanback 里共有 28 个 drawable 用它)。因此「按颜色反查角色」的实现会把它们全部改掉, - * 而那正是刻意固定的族:卡片信息条带压在不受控的海报上、控件底座压在壁纸上、键盘键底压在 - * 用户画布上——跟随主题就是可读性回归(实测 black_20 时白字只有 4.36:1)。 - */ - @Test - public void overlayExcludedDrawablesAreNotRewritten() { - Context context = themedContext(); - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = resolveWithOverlay(context); - assertNotEquals("测试前提:活动 tokens 必须与基线不同,否则 binder 整体 no-op", baseline, active); - - String[] excluded = {"shape_vod_name", "shape_widget", "shape_keyboard_normal"}; - for (String name : excluded) { - int id = drawableId(context, name); - assertNotEquals(name + " 必须能在 leanback 源集解析", 0, id); - - FrameLayout root = new FrameLayout(context); - View host = new View(context); - host.setBackground(context.getDrawable(id)); - root.addView(host); - - Bitmap before = rasterize(host.getBackground()); - ThemeBinder.bind(root, baseline, active); - Bitmap after = rasterize(host.getBackground()); - - assertEquals(name + " 属于刻意固定的族(压在不可控底材上或键底),binder 不允许改写它", - describe(before), describe(after)); - assertEquals(name + " 必须仍解析为编译期取值 @color/black_40", - COMPILED_OVERLAY, fillOf(host.getBackground())); - assertFalse(name + " 不允许出现用户选的遮罩色 " + Integer.toHexString(USER_OVERLAY), - containsColor(after, USER_OVERLAY)); - } - } - - /** - * 被排除的**状态选择器**同样必须保持原样:{@code selector_keyboard} 的焦点态是 - * {@code shape_keyboard_focused}({@code black_30})、常态是 {@code shape_keyboard_normal}。 - * - *

两者都被排除的理由不同却结论相同:键底压在半透明壁纸/画布上,键色并非「条目遮罩」语义; - * 而 {@code black_30} 的 {@code shape_item_selected} 也刻意不在名单里——它跟随的是「当前选中 - * 条目」而不是遮罩底色。本条同时钉住 {@code shape_item_selected},防止名单被顺手放宽。 - */ - @Test - public void overlayExcludedSelectorsKeepTheirOwnFills() { - Context context = themedContext(); - ThemeTokens baseline = ThemeTokens.light(); - ThemeTokens active = resolveWithOverlay(context); - - String[][] cases = { - {"selector_keyboard", Integer.toString(COMPILED_OVERLAY), Integer.toString(COMPILED_SELECTED)}, - }; - for (String[] item : cases) { - int id = drawableId(context, item[0]); - assertNotEquals(item[0] + " 必须能解析", 0, id); - - FrameLayout root = new FrameLayout(context); - View host = new View(context); - host.setBackground(context.getDrawable(id)); - root.addView(host); - - Bitmap before = rasterize(host.getBackground()); - ThemeBinder.bind(root, baseline, active); - Bitmap after = rasterize(host.getBackground()); - - assertEquals(item[0] + " 不在条目遮罩白名单内,必须逐像素不变", - describe(before), describe(after)); - } - - // shape_item_selected 本身:black_30,白名单外。 - int selectedId = drawableId(context, "shape_item_selected"); - assertNotEquals("shape_item_selected 必须能解析", 0, selectedId); - FrameLayout root = new FrameLayout(context); - View host = new View(context); - host.setBackground(context.getDrawable(selectedId)); - root.addView(host); - Bitmap before = rasterize(host.getBackground()); - ThemeBinder.bind(root, baseline, active); - assertEquals("shape_item_selected 的填充是 black_30,与 black_40 只差一档 alpha:" - + "白名单判定必须把填充计进指纹,否则会误改它", - COMPILED_SELECTED, fillOf(host.getBackground())); - assertEquals("shape_item_selected 必须逐像素不变", - describe(before), describe(rasterize(host.getBackground()))); - } - - /** 白名单本身必须是「活的」:leanback 源集上全部成员都必须可解析,否则功能静默失效。 */ - @Test - public void itemOverlayWhitelistResolvesOnTheTvSourceSet() { - Context context = themedContext(); - String[] whitelisted = { - // 全强度档:编译期填充 black_40。 - "shape_item_focused", - "shape_item_activated", - "shape_item_round_focused", - "shape_item_round_activated", - "shape_search_hot_word_focused", - "shape_tv_search_field", - "shape_tv_search_field_focused", - // 半强度档:编译期填充 black_20,写半强度才能保持默认字节不变。 - "shape_item_normal", - "shape_item_round_normal", - "shape_search_hot_word_normal", - }; - for (String name : whitelisted) { - assertNotEquals(name + " 必须能在 leanback 源集解析(否则 ThemeBinder 静默不改写它)", - 0, drawableId(context, name)); - } - } - - /** - * 半强度档成立的前提必须是资源层事实,而不是本类的一厢情愿: - * 遮罩 token 的半 alpha 必须逐位等于常态底座的编译期填充 {@code @color/black_20}。 - * - *

为什么单独钉这一条:{@code ITEM_OVERLAY_REST} 的取值(50%)是从「{@code 0x66} 除以 2 - * 等于 {@code 0x33}」推出来的。哪天有人把 {@code webhtv_color_item_overlay} 的 alpha 从 - * {@code 0x66} 改掉、或者把 {@code shape_item_normal} 的填充换成别的档位,默认外观就会在 - * 无人察觉的情况下跳挡——这里用两条资源取值把那个前提钉住。 - */ - @Test - public void restTierHalvesTheOverlayTokenIntoTheCompiledNormalFill() { - Context context = themedContext(); - int overlay = context.getColor(R.color.webhtv_color_item_overlay); - int compiledNormal = context.getColor(R.color.black_20); - assertEquals("半强度档的前提:遮罩 token 必须仍是 @color/black_40", COMPILED_OVERLAY, overlay); - assertEquals("半强度档的前提:常态底座必须仍是 @color/black_20", COMPILED_NORMAL, compiledNormal); - assertEquals("遮罩 token 的一半 alpha 必须逐位等于常态底座的编译期填充", - compiledNormal, ((overlay >>> 24) / 2) << 24); - } - - // ------------------------------------------------------------------ helpers - - private static Context themedContext() { - RuntimeEnvironment.setQualifiers(""); - return new ContextThemeWrapper(RuntimeEnvironment.getApplication(), R.style.Theme_App); - } - - /** 真实条目布局,用宿主自己的主题充气(与适配器一致)。 */ - private static View inflateHost(Context context, int layout) { - View view = LayoutInflater.from(context).inflate(layout, null, false); - assertNotNull("条目布局必须能充气出来", view); - return view; - } - - /** 改过 {@code itemOverlay} 槽的档案。 */ - private static ThemeTokens resolveWithOverlay(Context context) { - ThemeProfile profile = ThemeProfile.defaultProfile(); - profile.light.itemOverlay = USER_OVERLAY_HEX; - return resolveWithProfile(context, profile); - } - - /** 改过 {@code onItemOverlay}(选中文字色)槽的档案。 */ - private static ThemeTokens resolveWithOverlayText(Context context) { - ThemeProfile profile = ThemeProfile.defaultProfile(); - // 刻意选一个与遮罩底色反差极大的暖色:解析器会把它夹到 4.5:1,夹取后的值才是真实交付色。 - profile.light.onItemOverlay = USER_TEXT_HEX; - return resolveWithProfile(context, profile); - } - - private static ThemeTokens resolveWithProfile(Context context, ThemeProfile profile) { - // 清掉本类的实例归属缓存:同一个 drawable 实例在多次测试之间可能被复用, - // 而每条断言都要求「从未被改写过的原始外观」开始。 - return ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); - } - - /** 按名字查找资源:leanback 源集才有这些 drawable,不应硬引用 R.drawable.*(与 binder 同理)。 */ - private static int drawableId(Context context, String name) { - return context.getResources().getIdentifier(name, "drawable", context.getPackageName()); - } - - /** 主题属性解析出的颜色。 */ - private static int attrColor(Context context, int attr) { - TypedValue value = new TypedValue(); - assertTrue("主题必须赋值属性 " + context.getResources().getResourceName(attr), - context.getTheme().resolveAttribute(attr, value, true)); - if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { - return value.data; - } - assertTrue("属性必须解析成具体颜色而不是空引用", value.resourceId != 0); - return context.getResources().getColor(value.resourceId, null); - } - - /** - * 状态选择器里「在 {@code state} 下被选中」的那层 drawable。 - * - *

{@code state == 0} 表示常态(不带任何状态位)。 - */ - private static Drawable stateDrawable(Drawable drawable, int state) { - if (!(drawable instanceof StateListDrawable states)) return drawable; - for (int i = 0; i < states.getStateCount(); i++) { - int[] set = states.getStateSet(i); - if (state == 0) { - if (set == null || set.length == 0) return states.getStateDrawable(i); - continue; - } - boolean has = false; - for (int bit : set) if (bit == state) has = true; - // 常态条目(无状态位)不匹配任何显式状态。 - if (has) return states.getStateDrawable(i); - } - return null; - } - - /** 该状态下的实心填充色。 */ - private static int fillOfState(Drawable drawable, int state) { - Drawable target = stateDrawable(drawable, state); - assertNotNull("状态选择器必须提供该状态对应的 item", target); - return fillOf(target); - } - - /** drawable 自身的实心填充色(不进入状态选择器)。 */ - private static int fillOf(Drawable drawable) { - Drawable current = drawable; - while (current instanceof android.graphics.drawable.DrawableWrapper wrapper) { - current = wrapper.getDrawable(); - } - assertTrue("该 drawable 必须是 XML (GradientDrawable),实际为 " + current.getClass().getName(), - current instanceof GradientDrawable); - android.content.res.ColorStateList color = ((GradientDrawable) current).getColor(); - assertNotNull("形状必须有 ", color); - return color.getDefaultColor(); - } - - private static Bitmap rasterize(Drawable drawable) { - Bitmap bitmap = Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888); - if (drawable != null) { - drawable.setBounds(0, 0, SIZE, SIZE); - drawable.draw(new Canvas(bitmap)); - } - return bitmap; - } - - private static boolean containsColor(Bitmap bitmap, int color) { - if (bitmap == null) return false; - int rgb = color & 0x00FFFFFF; - for (int y = 0; y < bitmap.getHeight(); y++) { - for (int x = 0; x < bitmap.getWidth(); x++) { - if ((bitmap.getPixel(x, y) & 0x00FFFFFF) == rgb) return true; - } - } - return false; - } - - /** 逐像素指纹:稀疏采样会漏掉一档 alpha 的填充差异,所以每像素都算,只把结果压成哈希串。 */ - private static String describe(Bitmap bitmap) { - long hash = 1125899906842597L; - for (int y = 0; y < bitmap.getHeight(); y++) { - for (int x = 0; x < bitmap.getWidth(); x++) { - hash = 31 * hash + bitmap.getPixel(x, y); - } - } - return Long.toHexString(hash) + '-' + bitmap.getWidth() + 'x' + bitmap.getHeight(); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeColorPickerPrecisionTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeColorPickerPrecisionTest.java deleted file mode 100644 index c79d81b140..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeColorPickerPrecisionTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.EditText; - -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.android.controller.ActivityController; -import org.robolectric.annotation.Config; -import org.robolectric.shadows.ShadowLooper; - -import static org.junit.Assert.*; - -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = App.class) -public class ThemeColorPickerPrecisionTest { - - @Test - public void openingAndConfirmingDoesNotRoundAnExactHexThroughHsv() { - ActivityController controller = activity(); - String[] picked = {null}; - AlertDialog dialog = ThemeColorPickerDialog.create(controller.get(), "Primary", "#DAB9FF", ThemeTokens.dark(), hex -> picked[0] = hex); - dialog.show(); - ShadowLooper.idleMainLooper(); - EditText input = findInput(dialog.getWindow().getDecorView()); - assertEquals("#DAB9FF", input.getText().toString()); - dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick(); - assertEquals("#DAB9FF", picked[0]); - assertFalse(dialog.isShowing()); - controller.pause().stop().destroy(); - } - - @Test - public void useHexIsAlsoLosslessAndInvalidConfirmationKeepsThePickerOpen() { - ActivityController controller = activity(); - String[] picked = {null}; - AlertDialog dialog = ThemeColorPickerDialog.create(controller.get(), "Primary", "#123456", ThemeTokens.light(), hex -> picked[0] = hex); - dialog.show(); - ShadowLooper.idleMainLooper(); - View root = dialog.getWindow().getDecorView(); - EditText input = findInput(root); - input.setText("#DAB9FF"); - Button use = findButton(root, controller.get().getString(R.string.theme_editor_hex_apply)); - assertNotNull(use); - use.performClick(); - assertEquals("#DAB9FF", input.getText().toString()); - input.setText("#ZZZZZZ"); - dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick(); - assertTrue(dialog.isShowing()); - assertNull(picked[0]); - input.setText("#a5c9f0"); - dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick(); - assertEquals("#A5C9F0", picked[0]); - controller.pause().stop().destroy(); - } - - private static ActivityController activity() { - ActivityController controller = Robolectric.buildActivity(AppCompatActivity.class); - controller.get().setTheme(R.style.Theme_App); - return controller.setup(); - } - - private static EditText findInput(View view) { - if (view instanceof EditText input) return input; - if (view instanceof ViewGroup group) { - for (int i = 0; i < group.getChildCount(); i++) { - EditText found = findInput(group.getChildAt(i)); - if (found != null) return found; - } - } - return null; - } - - private static Button findButton(View view, String label) { - if (view instanceof Button button && label.contentEquals(button.getText())) return button; - if (view instanceof ViewGroup group) { - for (int i = 0; i < group.getChildCount(); i++) { - Button found = findButton(group.getChildAt(i), label); - if (found != null) return found; - } - } - return null; - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeDialogModeTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeDialogModeTest.java deleted file mode 100644 index 2762b852a8..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeDialogModeTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.app.AppCompatDelegate; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.ui.dialog.ThemeDialog; -import com.github.catvod.utils.Prefers; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.shadows.ShadowLooper; -import org.robolectric.annotation.Config; - -import static org.junit.Assert.*; - -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = App.class) -public class ThemeDialogModeTest { - public static class Host extends AppCompatActivity { - @Override protected void onCreate(android.os.Bundle state) { - setTheme(R.style.Theme_App); - super.onCreate(state); - } - } - - @Before public void reset() { - Prefers.getPrefers().edit().clear().commit(); - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); - } - - @Test public void saveDarkThenLightPersistsBothDirectionsAndReopensSelected() { - saveAndReopen(true); - saveAndReopen(false); - } - - private void saveAndReopen(boolean dark) { - try (var host = Robolectric.buildActivity(Host.class).setup()) { - ThemeDialog dialog = open(host.get()); - click(dialog, dark ? R.string.theme_editor_dark : R.string.theme_editor_light); - assertEquals(dark ? -1 : 1, Setting.getThemeMode()); - click(dialog, R.string.theme_editor_apply); - assertEquals(dark ? 1 : 0, Setting.getThemeMode()); - assertEquals(dark ? "dark" : "light", ThemeProfileStore.load().mode); - ThemeController.applyFromPreferences(null); - assertEquals(dark ? ThemeTokens.dark().colorSurface() : ThemeTokens.light().colorSurface(), - ThemeController.current().colorSurface()); - assertEquals(dark ? ThemeTokens.dark().colorPrimary() : ThemeTokens.light().colorPrimary(), - ThemeController.current().colorPrimary()); - ShadowLooper.idleMainLooper(); - } - var host = Robolectric.buildActivity(Host.class).setup(); - ThemeDialog reopened = open(host.get()); - assertTrue(find(reopened.getDialog().getWindow().getDecorView(), - host.get().getString(dark ? R.string.theme_editor_dark : R.string.theme_editor_light)).isSelected()); - reopened.dismissNow(); - host.pause().stop().destroy(); - } - - @Test public void cancelDoesNotPublishModeOrProfile() { - String original = ThemeProfileCodec.encode(ThemeProfileStore.load()); - try (var host = Robolectric.buildActivity(Host.class).setup()) { - ThemeDialog dialog = open(host.get()); - click(dialog, R.string.theme_editor_dark); - click(dialog, R.string.theme_editor_cancel); - assertEquals(-1, Setting.getThemeMode()); - assertEquals(original, ThemeProfileCodec.encode(ThemeProfileStore.load())); - } - } - - @Test public void savingColorsWithoutModeSelectionPreservesFollowSystem() { - try (var host = Robolectric.buildActivity(Host.class).setup()) { - ThemeDialog dialog = open(host.get()); - click(dialog, R.string.theme_editor_apply); - assertEquals(-1, Setting.getThemeMode()); - } - } - - private static ThemeDialog open(Host host) { - ThemeDialog dialog = new ThemeDialog(); - dialog.showNow(host.getSupportFragmentManager(), "theme"); - return dialog; - } - - private static void click(ThemeDialog dialog, int label) { - View view = find(dialog.getDialog().getWindow().getDecorView(), dialog.getString(label)); - assertNotNull(view); - view.performClick(); - } - - private static View find(View view, String text) { - if (view instanceof TextView label && text.contentEquals(label.getText())) return view; - if (view instanceof ViewGroup group) for (int i = 0; i < group.getChildCount(); i++) { - View found = find(group.getChildAt(i), text); - if (found != null) return found; - } - return null; - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeEditorPanelTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeEditorPanelTest.java deleted file mode 100644 index 10892c3681..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeEditorPanelTest.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.fongmi.android.tv.theme; - -import android.content.Context; -import android.view.ContextThemeWrapper; -import android.view.KeyEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.SeekBar; -import android.widget.TextView; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import java.util.ArrayList; -import java.util.List; - -import static org.junit.Assert.*; - -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = App.class) -public class ThemeEditorPanelTest { - - @Test - public void rebindingPreservesTheSliderAndFocusWhileRemoteKeysKeepEditing() { - Context context = new ContextThemeWrapper(App.get(), R.style.Theme_App); - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - ThemePreviewView[] panel = new ThemePreviewView[1]; - int[] changes = {0}; - panel[0] = ThemePreviewView.createPanel(context, editor, true, editor.preview(true, 0), new ThemePreviewView.Callbacks() { - @Override public void onColorSlotClicked(ThemeEditor.Slot slot, boolean dark) { } - @Override public void onDraftChanged() { - changes[0]++; - panel[0].render(editor, true, editor.preview(true, 0)); - } - }); - layout(panel[0]); - SeekBar bar = panel[0].findViewWithTag("theme-opacity:SCRIM_OPACITY"); - assertNotNull(bar); - assertTrue("automatic scrim uses the resolved value, not the minimum", bar.getProgress() > 0); - SeekBar playerScrim = panel[0].findViewWithTag("theme-opacity:PLAYER_SCRIM_OPACITY"); - assertNotNull("the playback menu overlay slider must exist", playerScrim); - assertEquals("auto strength is 80% of the 20..100 range", 75, playerScrim.getProgress()); - SeekBar liveListScrim = panel[0].findViewWithTag("theme-opacity:LIVE_LIST_SCRIM_OPACITY"); - assertNotNull("the live channel drawer scrim slider must exist", liveListScrim); - assertEquals("live list overlay defaults to the shipped 80% of the 20..100 range", 75, liveListScrim.getProgress()); - assertTrue(bar.requestFocus()); - int before = bar.getProgress(); - bar.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)); - bar.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_RIGHT)); - assertTrue(bar.getProgress() > before); - assertEquals(1, changes[0]); - assertTrue(editor.isDirty()); - assertSame(bar, panel[0].findViewWithTag("theme-opacity:SCRIM_OPACITY")); - assertSame(bar, panel[0].findFocus()); - bar.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)); - assertEquals(2, changes[0]); - } - - @Test - public void everyRowShowsResolvedColorsAndSwitchingModesDoesNotPublishTheDraft() { - Context context = new ContextThemeWrapper(App.get(), R.style.Theme_App); - String persisted = ThemeProfileCodec.encode(ThemeProfileStore.load()); - ThemeTokens global = ThemeController.current(); - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - ThemeEditor.Slot[] clicked = {null}; - ThemePreviewView panel = ThemePreviewView.createPanel(context, editor, false, editor.preview(false, 0), new ThemePreviewView.Callbacks() { - @Override public void onColorSlotClicked(ThemeEditor.Slot slot, boolean dark) { clicked[0] = slot; } - @Override public void onDraftChanged() { } - }); - View success = panel.findViewWithTag("theme-slot:SUCCESS"); - success.performClick(); - assertEquals(ThemeEditor.Slot.SUCCESS, clicked[0]); - List texts = texts(panel); - assertTrue(texts.stream().anyMatch(text -> text.contains("#146C2E"))); - assertFalse(texts.contains(context.getString(R.string.theme_editor_preview))); - assertFalse(texts.contains(context.getString(R.string.theme_editor_inherit))); - editor.replace(ThemePresets.Preset.ROSE.profile()); - ThemeTokens dark = editor.preview(true, 0); - panel.render(editor, true, dark); - assertSame(success, panel.findViewWithTag("theme-slot:SUCCESS")); - assertTrue(texts(panel).contains(ThemeEditorUi.hex(dark.colorSuccess()))); - editor.reset(); - panel.render(editor, false, editor.preview(false, 0)); - assertEquals(persisted, ThemeProfileCodec.encode(ThemeProfileStore.load())); - assertEquals(global, ThemeController.current()); - } - - /** - * 条目遮罩是"底色 + 遮罩上的文字色 + 强度"三者联动的同一个机制,编辑器必须三行齐备; - * 只挂两张色卡或只挂滑杆都会让用户只能调一半。这里按既有测试的取证方式(tag 访客)确认三行 - * 真的进了面板,并且强度滑杆落在新的 5%..100% 区间上——它的上界与 overlayOpacity 不同。 - */ - @Test - public void itemOverlayGroupContributesBothColourRowsAndItsOpacitySlider() { - Context context = new ContextThemeWrapper(App.get(), R.style.Theme_App); - ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); - ThemeEditor.Slot[] clicked = {null}; - ThemePreviewView panel = ThemePreviewView.createPanel(context, editor, false, editor.preview(false, 0), new ThemePreviewView.Callbacks() { - @Override public void onColorSlotClicked(ThemeEditor.Slot slot, boolean dark) { clicked[0] = slot; } - @Override public void onDraftChanged() { } - }); - layout(panel); - - View overlay = panel.findViewWithTag("theme-slot:ITEM_OVERLAY"); - View onOverlay = panel.findViewWithTag("theme-slot:ON_ITEM_OVERLAY"); - assertNotNull("the item overlay colour row must exist", overlay); - assertNotNull("the selected text colour row must exist", onOverlay); - overlay.performClick(); - assertEquals(ThemeEditor.Slot.ITEM_OVERLAY, clicked[0]); - onOverlay.performClick(); - assertEquals(ThemeEditor.Slot.ON_ITEM_OVERLAY, clicked[0]); - - SeekBar opacity = panel.findViewWithTag("theme-opacity:ITEM_OVERLAY_OPACITY"); - assertNotNull("the item overlay opacity slider must exist", opacity); - // 默认自动值就是解析后的遮罩 alpha(40%),落在 5%..100% 区间的 37% 位置上。 - assertEquals(37, opacity.getProgress()); - assertTrue(opacity.requestFocus()); - int before = opacity.getProgress(); - opacity.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)); - opacity.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_RIGHT)); - assertTrue(opacity.getProgress() > before); - assertTrue(editor.isDirty()); - - List texts = texts(panel); - assertTrue(texts.contains(context.getString(R.string.theme_editor_slot_item_overlay))); - assertTrue(texts.contains(context.getString(R.string.theme_editor_slot_on_item_overlay))); - assertTrue(texts.contains(context.getString(R.string.theme_editor_slot_item_overlay_opacity))); - assertTrue(texts.contains(context.getString(R.string.theme_editor_group_item_overlay))); - } - - private static void layout(View view) { - view.measure(View.MeasureSpec.makeMeasureSpec(1100, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(3000, View.MeasureSpec.AT_MOST)); - view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); - } - - private static List texts(View view) { - List result = new ArrayList<>(); - if (view instanceof TextView text) result.add(text.getText().toString()); - if (view instanceof ViewGroup group) { - for (int i = 0; i < group.getChildCount(); i++) result.addAll(texts(group.getChildAt(i))); - } - return result; - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/TvDialogPaletteModeTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/TvDialogPaletteModeTest.java deleted file mode 100644 index 24266e2293..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/theme/TvDialogPaletteModeTest.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.fongmi.android.tv.theme; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.content.res.Configuration; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.GradientDrawable; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.core.content.ContextCompat; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ui.dialog.LightDialog; -import com.github.catvod.utils.Prefers; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -/** - * TV 对话框面板必须与它自己的前景取自同一张表。 - * - *

{@code LightDialog} 家族(「新增点播」{@code ConfigDialog}、{@code AppearanceDialog}、 - * {@code SpeedDialog} …)的面板由 {@link LightDialog#shell} 从 - * {@link ThemeController#current()} 画出,而面板里的文字来自 XML 的 {@code ?attr/color*} - * 与 {@code @color/webhtv_color_*},即由 Activity 解析出的编译表。两者一旦取自不同的表, - * 就是「浅底浅字」:设备实测(1920×1080,系统日间模式,TV 产品默认强制深色)面板 - * {@code #E7E8EF}(日表)配夜表前景,正文 1.06:1、提示 1.39:1、描边按钮 1.41:1。 - * - *

根因是取表来源:{@code AppCompatDelegate.setDefaultNightMode} 改写的是它代理的 - * Activity 的资源,API < 31 不会改写 Application 自己的资源;而 TV 侧 - * {@code applyNightModeToApp()} 按产品默认强制 {@code MODE_NIGHT_YES}。于是 - * 「应用上下文 + 原始系统夜间位」在日间模式的电视上判成浅色,而实际 inflate 出来的全是深色。 - * 这里断言的就是「必须按 Activity 实际解析出的表来取色」这条不变量。 - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = App.class) -public class TvDialogPaletteModeTest { - - public static class Host extends AppCompatActivity { - @Override - protected void onCreate(android.os.Bundle state) { - setTheme(R.style.Theme_App); - super.onCreate(state); - } - } - - @Before - public void reset() { - Prefers.getPrefers().edit().clear().commit(); - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); - } - - @Test - public void dialogShellMatchesTheTableTheActivityInflated() { - // TV 产品默认强制深色:设备本身处于日间模式时,Activity 仍解析 values-night。 - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - try (var host = Robolectric.buildActivity(Host.class).setup()) { - ThemeController.applyFromPreferences(host.get()); - int inflated = ContextCompat.getColor(host.get(), R.color.webhtv_color_surface_container_high); - assertEquals("fixture: the TV activity must inflate the dark table", - ThemeTokens.dark().colorSurfaceContainerHigh(), inflated); - assertEquals("the dialog shell must come from the table its foregrounds come from", - inflated, fill(LightDialog.shell(host.get()))); - } - } - - @Test - public void addVodDialogForegroundsMeetWcagAaOnTheShell() { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - try (var host = Robolectric.buildActivity(Host.class).setup()) { - ThemeController.applyFromPreferences(host.get()); - int panel = fill(LightDialog.shell(host.get())); - assertAtLeastAa(host.get(), panel, R.color.webhtv_color_on_surface, "info text"); - assertAtLeastAa(host.get(), panel, R.color.webhtv_color_on_surface_variant, "field hints"); - assertAtLeastAa(host.get(), panel, R.color.webhtv_color_primary, "outlined action text"); - } - } - - /** - * 取表必须跟着「真正 inflate 的那个 context」走,而不是跟着进程级的应用上下文走。 - * - *

Robolectric 会把 {@code setDefaultNightMode} 也应用到应用上下文,真机 API < 31 不会, - * 所以只靠上面的 Activity 断言在 JVM 里复现不出设备现象。这条用显式构造的日/夜 context - * 把「探针跟着入参 context」这条机制钉死,与设备实测互为补充。 - */ - @Test - public void paletteDecisionFollowsTheInflatingContext() { - Context app = App.get(); - assertEquals("fixture: the day context must resolve the light table", - ThemeTokens.light().colorSurfaceContainerHigh(), - ContextCompat.getColor(withNight(app, false), R.color.webhtv_color_surface_container_high)); - assertEquals("fixture: the night context must resolve the dark table", - ThemeTokens.dark().colorSurfaceContainerHigh(), - ContextCompat.getColor(withNight(app, true), R.color.webhtv_color_surface_container_high)); - assertEquals(Boolean.TRUE, ThemeController.compiledDarkPalette(withNight(app, true))); - assertEquals(Boolean.FALSE, ThemeController.compiledDarkPalette(withNight(app, false))); - assertTrue(ThemeController.resolvedDark(withNight(app, true))); - assertFalse(ThemeController.resolvedDark(withNight(app, false))); - } - - private static Context withNight(Context base, boolean night) { - Configuration configuration = new Configuration(base.getResources().getConfiguration()); - int mode = night ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO; - configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | mode; - return base.createConfigurationContext(configuration); - } - - private static void assertAtLeastAa(AppCompatActivity host, int panel, int foreground, String what) { - double ratio = ThemeContrast.ratio(ContextCompat.getColor(host, foreground), panel); - assertTrue(what + " must reach WCAG AA 4.5:1 on the dialog shell, measured " + ratio + ":1", ratio >= 4.5); - } - - private static int fill(Drawable drawable) { - assertTrue("unexpected shell drawable: " + drawable, drawable instanceof GradientDrawable); - GradientDrawable shape = (GradientDrawable) drawable; - return shape.getColor().getDefaultColor(); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/NativeEnhancedPlaybackStyleFocusTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/NativeEnhancedPlaybackStyleFocusTest.java deleted file mode 100644 index 6959158a55..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/NativeEnhancedPlaybackStyleFocusTest.java +++ /dev/null @@ -1,378 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 原生增强播放页的视觉统一与焦点链路契约。 - * - * 用户报告的三类问题: - * 1. 同一页存在多套按钮风格(4dp/8dp/14dp/28dp 圆角、白/黄两套焦点环、部分卡片无描边); - * 2. 选中后的高亮颜色不统一(白色、黄色、以及完全没有描边); - * 3. 焦点不完善:聚焦在分段(分组)按钮上按向下到不了季度按钮,评分与数据卡片无法选中。 - * - * 本测试把「唯一规范」固化成契约:焦点 3dp tv_item_focus_ring,当前态 2dp tv_item_current_ring, - * 常态 1dp tv_item_normal_stroke,圆角 8dp;并锁定纵向焦点链的顺序。 - */ -public class NativeEnhancedPlaybackStyleFocusTest { - - private static final String CHIP_SELECTOR = "app/src/leanback/res/drawable/selector_video_item.xml"; - private static final String EPISODE_CARD_SELECTOR = "app/src/main/res/drawable/selector_episode_card.xml"; - private static final String CAST_FOCUS_SELECTOR = "app/src/main/res/drawable/selector_tmdb_cast_focus.xml"; - private static final String COLORS = "app/src/main/res/values/colors.xml"; - private static final String ATTRS = "app/src/main/res/values/attrs.xml"; - private static final String LEANBACK_STYLES = "app/src/leanback/res/values/styles.xml"; - private static final String MOBILE_STYLES = "app/src/mobile/res/values/styles.xml"; - private static final String VIDEO_ACTIVITY = "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"; - private static final String VIDEO_LAYOUT = "app/src/leanback/res/layout/activity_video.xml"; - - // ---------------------------------------------------------------- 统一规范本身 - - @Test - public void unifiedFocusTokensAreDeclaredOnceAndThemeable() throws Exception { - String colors = read(COLORS); - assertTrue("焦点环色必须只有一个来源且必须跟随主题焦点槽", - colors.contains("@color/webhtv_color_focus")); - assertTrue("当前播放环色必须只有一个来源", colors.contains("#2CC56F")); - assertTrue("常态描边色必须只有一个来源", colors.contains("#33FFFFFF")); - - // 焦点语义必须可被主题覆写,而不是散落的字面量:属性定义 + 两个 flavor 的绑定都要在。 - String attrs = read(ATTRS); - assertTrue("必须声明 tvFocusRing 主题属性", attrs.contains("")); - assertTrue("必须声明 tvCurrentRing 主题属性", attrs.contains("")); - assertTrue("必须声明 tvNormalStroke 主题属性", attrs.contains("")); - for (String styles : new String[]{LEANBACK_STYLES, MOBILE_STYLES}) { - String body = read(styles); - assertTrue(styles + " 必须把 tvFocusRing 绑到统一取值", body.contains("@color/tv_item_focus_ring")); - assertTrue(styles + " 必须把 tvCurrentRing 绑到统一取值", body.contains("@color/tv_item_current_ring")); - assertTrue(styles + " 必须把 tvNormalStroke 绑到统一取值", body.contains("@color/tv_item_normal_stroke")); - } - } - - @Test - public void everySelectableSurfaceSharesOneFocusSpec() throws Exception { - // 2026-10-10 第三轮(用户报告「播放页按钮边框太粗、按钮与选中态边框颜色不统一」): - // 播放页焦点环与当前态环统一到同一宽度 token。 - // - // 分层(仓库既有「宿主分层规则」): - // 视频层环(叠在视频画面/固定深靛玻璃上)→ tvPlayerRing / tvPlayerCurrentRing - // 浅色表取值 = ThemeResolver 在固定玻璃背景上夹取到 ≥3:1 的派生色 - // (浅色表 FOCUS 深蓝在玻璃上仅 1.83:1,不可直接用); - // 深色表两类宿主的底色都是深色,直接取与应用表面焦点环同值的近白。 - // 应用表面环(叠在调色板表面/页面背景上,含与详情页/手机版共用的卡片) - // → tvFocusRing / tvCurrentRing - String episode = values(read(EPISODE_CARD_SELECTOR)); - assertTrue("选集卡(应用表面,与详情页/手机版共用)焦点环必须继续走 tvFocusRing", - episode.contains("android:color=\"?attr/tvFocusRing\"")); - assertTrue("选集卡当前态必须继续走 tvCurrentRing", - episode.contains("android:color=\"?attr/tvCurrentRing\"")); - - String chip = values(read(CHIP_SELECTOR)); - assertTrue("播放页芯片焦点态必须引用统一宽度 token", - chip.contains("android:width=\"@dimen/webhtv_focus_ring_width\"")); - // 2026-10-11(用户报告「播放页存在多个不同颜色的高亮效果」):这批芯片的宿主是播放页 - // 内容区(@+id/scroll 里的线路/清晰度/数组分段/分段/选集/快捷源行与按钮行), - // 画在页面背景上而不是叠在视频或固定玻璃上,所以归「应用表面」族,环色取调色板 - // FOCUS 槽 ?attr/tvFocusRing(此前错用视频层灰蓝 #7695C5,与页面其它按钮不同色)。 - assertTrue("播放页芯片(应用表面)焦点环必须走 tvFocusRing(受主题控制)", - chip.contains("android:color=\"?attr/tvFocusRing\"")); - assertFalse("播放页内容区芯片不允许再用视频层环色(会造成同页两种高亮色)", - chip.contains("?attr/tvPlayerRing") || chip.contains("?attr/tvPlayerCurrentRing")); - assertFalse("播放页芯片不允许再出现写死白色焦点环", - chip.contains("android:color=\"@color/white\"") || chip.contains("android:color=\"#FFFFFF\"")); - assertFalse("播放页芯片不允许再出现硬编码焦点色,必须走主题属性", - chip.contains("#FFD166") || chip.contains("#FFE16A") || chip.contains("#0077FF")); - assertTrue(CHIP_SELECTOR + " 的圆角必须统一为 8dp", - chip.contains("")); - - // 复用同一份芯片布局的固定深色玻璃对话框(dialog_episode_list / dialog_quick_search): - // 浅色表 FOCUS 深蓝 #0B57D0 画在固定玻璃 #303463 上仅 1.83:1,所以这两处在子树内 - // 用 GlassFocusRings 覆盖层把两项重绑回玻璃可读的视频层派生色。 - String glassOverlay = values(read("app/src/leanback/res/values/styles.xml")); - assertTrue("玻璃宿主必须把 tvFocusRing 重绑到视频层派生色", - glassOverlay.contains("@color/tv_player_focus_ring")); - - // 控制面板按钮(dialog_control)也在视频层固定玻璃上,同样统一到播放页派生色。 - String sheet = values(read("app/src/leanback/res/drawable/selector_control_sheet_button.xml")); - assertTrue("控制面板按钮焦点环必须走 tvPlayerRing", sheet.contains("?attr/tvPlayerRing")); - assertTrue("控制面板按钮当前态环必须走 tvPlayerCurrentRing", sheet.contains("?attr/tvPlayerCurrentRing")); - - // 演员卡是圆角卡片(应用表面层,详情页),本轮只保证圆角对齐宿主。 - String cast = values(read(CAST_FOCUS_SELECTOR)); - assertTrue("演员卡宿主卡片圆角仍必须走共享尺寸 token", - read("app/src/main/res/layout/adapter_tmdb_cast.xml").contains("app:cardCornerRadius=\"@dimen/webhtv_card_radius_large\"")); - assertTrue("演员卡焦点环声明圆角必须小于宿主圆角(实测 9.5dp 四档密度 0 溢出)", - cast.contains("")); - assertFalse("演员卡不允许再出现写死白色焦点环", cast.contains("android:color=\"@color/white\"")); - } - - @Test - public void chipSelectorKeepsCurrentStateAtTheUnifiedWidthAndNormalStateAtOneDp() throws Exception { - String body = squeeze(read(CHIP_SELECTOR)); - // 2026-10-10 第三轮:当前态也收进统一宽度 token(此前 2dp)。 - // 2026-10-11:芯片改归「应用表面」族,当前态同步收回到 ?attr/tvCurrentRing。 - assertTrue("当前播放环必须引用统一宽度 token", - body.contains("android:width=\"@dimen/webhtv_focus_ring_width\"")); - assertTrue("常态必须补上 1dp 常态描边,消除「没有边框颜色」的芯片", - body.contains("android:width=\"1dp\" android:color=\"?attr/tvNormalStroke\"")); - assertTrue("state_selected 仍然只承担跑马灯开关,不能加描边", - body.contains("跑马灯开关")); - assertTrue("chip selector 的 activated 分支(selector_chip→shape_chip_activated)必须走 tvPlayerCurrentRing", - values(read("app/src/leanback/res/drawable/shape_chip_activated.xml")).contains("?attr/tvPlayerCurrentRing")); - } - - @Test - public void playbackCardsOwnTheUnifiedRingInLeanbackPresentersWithoutTouchingSharedLayouts() throws Exception { - // adapter_tmdb_*.xml 与独立详情页共用,详情页有自己的焦点契约 - // (TmdbDetailActivityLayoutTest 会断言这些共享布局的原样取值), - // 所以播放页的统一焦点环必须落在 leanback 专属的 presenter 上,不能改共享布局。 - String[] shared = { - "app/src/main/res/layout/adapter_tmdb_photo.xml", - "app/src/main/res/layout/adapter_tmdb_video.xml", - "app/src/main/res/layout/adapter_tmdb_cast.xml", - "app/src/main/res/layout/adapter_tmdb_recommendation.xml", - "app/src/main/res/layout/adapter_tmdb_recommendation_landscape.xml", - }; - for (String card : shared) { - assertFalse(card + " 是详情页共用布局,不允许把播放页的统一描边写进去", - read(card).contains("tv_item_normal_stroke")); - } - - String cast = read("app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java"); - assertTrue("演员卡必须由 presenter 在绑定时挂上统一焦点环 selector", - cast.contains("card.setForeground(card.getContext().getDrawable(R.drawable.selector_tmdb_cast_focus))")); - assertTrue("演员卡常态描边必须是 1dp 统一取值", - cast.contains("STROKE_NORMAL = 0x33FFFFFF") && cast.contains("STROKE_WIDTH_NORMAL_DP = 1")); - assertFalse("演员卡不允许再自己画白色焦点描边(会与 foreground 双重描边)", - cast.contains("STROKE_FOCUSED")); - - String recommendation = read("app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbRecommendationPresenter.java"); - assertTrue("推荐卡此前没有任何焦点描边,必须补上统一焦点环", - recommendation.contains("FOCUS_WIDTH_DP = 3") - && recommendation.contains("applyUnifiedFocus") - && recommendation.contains("R.color.tv_item_focus_ring") - && recommendation.contains("R.color.tv_item_normal_stroke")); - } - - @Test - public void episodeHeaderToolsUseTheSameChipSelectorAsRouteAndSegmentRows() throws Exception { - String body = read(VIDEO_LAYOUT); - int header = body.indexOf("android:id=\"@+id/episodeHeader\""); - int headerEnd = body.indexOf("", " ")); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SearchFieldFocusStateTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SearchFieldFocusStateTest.java deleted file mode 100644 index d9a724f032..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SearchFieldFocusStateTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import android.app.Activity; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.drawable.Drawable; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.appcompat.view.ContextThemeWrapper; - -import com.fongmi.android.tv.R; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.android.controller.ActivityController; -import org.robolectric.annotation.Config; -import org.robolectric.annotation.GraphicsMode; - -/** - * 行为级守卫:搜索输入框的焦点环必须在**真实交互**里点亮。 - * - *

此前的缺陷是声明式就能看出来的那种:{@code #keywordField} 挂着的 - * {@code selector_tv_search_field} 有一个 {@code state_focused} 分支,但 - * {@code SearchActivity} 的所有焦点落点都在子控件 {@code #keyword} 上(:185、 - * :489-:500、:612 都是 {@code mBinding.keyword.requestFocus()}),容器自己从来拿不到焦点, - * 也没有 {@code addStatesFromChildren},于是那个分支永不点亮——浅色模式下搜索框依旧是 - * 「没有框」的原症状。同时容器 {@code focusable=true} 还多出一个按下无动作的 D-pad 停靠点。 - * - *

本测试因此不读源码字符串,而是把**真实布局**充气出来、把焦点交给子控件, - * 再检查容器的 drawable state 与栅格化后的背景:容器必须合并子控件状态并画出焦点环。 - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = com.fongmi.android.tv.App.class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -public class SearchFieldFocusStateTest { - - private static final int SIZE = 96; - - @Test - public void focusedInputLightsUpTheFieldHostRing() { - ActivityController controller = Robolectric.buildActivity(Activity.class).setup(); - Activity activity = controller.get(); - activity.setTheme(R.style.Theme_App); - - View root = LayoutInflater.from(activity).inflate(R.layout.activity_search, null); - assertNotNull("搜索页布局必须能充气出来", root); - View field = root.findViewById(R.id.keywordField); - View keyword = root.findViewById(R.id.keyword); - assertNotNull("搜索输入框的实体宿主不存在", field); - assertNotNull("搜索输入框本身不存在", keyword); - - assertTrue("#keywordField 必须合并子控件的 drawable 状态(ViewGroup.addStatesFromChildren)," + - "否则 selector_tv_search_field 的 state_focused 分支永远不会点亮(浅色下依旧看不见框)", - field instanceof ViewGroup group && group.addStatesFromChildren()); - assertFalse("#keywordField 不允许自己可聚焦:焦点全部落在子控件 #keyword 上," + - "容器可聚焦只会多出一个按下无动作的 D-pad 停靠点", - field.isFocusable()); - - int ringColor = ringColor(activity); - assertTrue("测试前提:主题必须能解析出 ?attr/tvFocusRing", ringColor != 0); - - // 未挂到窗口之前任何视图都不可能持有焦点,此时才是可靠的「常态」基准。 - Bitmap idle = rasterize(field.getBackground()); - assertFalse("测试前提:未聚焦时不允许已经出现焦点环色 " + Integer.toHexString(ringColor), - containsColor(idle, ringColor)); - assertFalse("测试前提:未聚焦时 drawable 状态不允许含 state_focused", - hasFocusedState(field.getDrawableState())); - - activity.setContentView(root, new ViewGroup.LayoutParams(1280, 720)); - controller.visible(); - - assertTrue("焦点必须能交给输入控件本身", keyword.requestFocus()); - assertTrue("测试前提:输入控件必须真正持有焦点", keyword.hasFocus()); - - assertTrue("子控件获得焦点后,#keywordField 的 drawable 状态必须包含 state_focused", - hasFocusedState(field.getDrawableState())); - Bitmap focused = rasterize(field.getBackground()); - assertTrue("子控件获得焦点后,搜索框必须真的画出焦点环色 " + Integer.toHexString(ringColor) - + "(这正是浅色模式下「框不见了」的修复目标)", - containsColor(focused, ringColor)); - } - - private static Context themedContext(Context base) { - return new ContextThemeWrapper(base, R.style.Theme_App); - } - - /** 视图主题解析出的 {@code ?attr/tvFocusRing}(与 {@code ThemeBinder} 判定同源)。 */ - private static int ringColor(Context base) { - Context context = themedContext(base); - TypedValue value = new TypedValue(); - if (!context.getTheme().resolveAttribute(R.attr.tvFocusRing, value, true)) return 0; - if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { - return value.data; - } - return value.resourceId == 0 ? 0 : context.getResources().getColor(value.resourceId, null); - } - - private static boolean hasFocusedState(int[] state) { - if (state == null) return false; - for (int item : state) { - if (item == android.R.attr.state_focused) return true; - } - return false; - } - - private static boolean containsColor(Bitmap bitmap, int color) { - int rgb = color & 0x00FFFFFF; - for (int y = 0; y < bitmap.getHeight(); y++) { - for (int x = 0; x < bitmap.getWidth(); x++) { - if ((bitmap.getPixel(x, y) & 0x00FFFFFF) == rgb) return true; - } - } - return false; - } - - private static Bitmap rasterize(Drawable drawable) { - Bitmap bitmap = Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888); - if (drawable != null) { - drawable.setBounds(0, 0, SIZE, SIZE); - drawable.draw(new Canvas(bitmap)); - } - return bitmap; - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SearchResultDownFocusTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SearchResultDownFocusTest.java deleted file mode 100644 index bf5aa27c06..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SearchResultDownFocusTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class SearchResultDownFocusTest { - - @Test - public void validDownPressFocusesTheNextResultImmediately() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java"); - int start = source.indexOf("private boolean onSearchDown(int position, int count)"); - int end = source.indexOf("boolean onLoadMore", start); - - assertTrue("result-row navigation must define its down policy", start >= 0 && end > start); - String down = source.substring(start, end); - assertFalse("a valid down press must not defer movement to the system focus search", - down.contains("if (bottom) mScroller.checkMore();\n return bottom;")); - assertTrue("a valid down press must return true before a new row is focused", - down.contains("focusSearchTarget(next);") && down.contains("return true;")); - assertTrue("navigation must still load the next row before focusing it", - down.contains("mSearchAdapter.ensureLoaded(next + 1, count * 3);")); - } - - @Test - public void deferredResultFocusScrollsFirstAndRejectsStaleRequests() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java"); - int start = source.indexOf("private void focusSearchTarget(int position)"); - int end = source.indexOf("private boolean canFocusSearchResult(int position)"); - - assertTrue("search results must define a deferred focus policy", start >= 0 && end > start); - String focus = source.substring(start, end); - assertTrue("a missing target must scroll to the requested row before focusing it", - focus.contains("scrollToSearchResult(position);")); - assertTrue("focus must be restored after the target is laid out", - focus.contains("mBinding.recycler.post(") && focus.contains("requestSearchResultFocus(position, generation);")); - assertTrue("a focused target must be aligned fully into view", - focus.contains("alignSearchResultCard(position, target);") - && focus.contains("alignSearchResultCard(position, laidOutTarget);")); - assertTrue("focus must be requested after the explicit row layout completes", - focus.contains("private void requestSearchResultFocus(int position, int generation)") - && focus.contains("if (target != null) target.requestFocus();")); - assertTrue("a later down press must invalidate a queued focus request", - focus.contains("int generation = ++mSearchFocusGeneration;") - && focus.contains("if (generation != mSearchFocusGeneration")); - } - - @Test - public void downNavigationAlignsTheFocusedResultCardToTheViewportBottom() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java"); - int start = source.indexOf("private void alignSearchResultCard(int position, View focusedView)"); - int end = source.indexOf("private void preloadNextRows(int count)"); - - assertTrue("search results must define focused-card alignment", start >= 0 && end > start); - String align = source.substring(start, end); - assertTrue("down navigation must align the complete target row instead of relying on default focus scrolling", - align.contains("int rowStart = Math.max(0, position - position % spanCount);") - && align.contains("layoutManager.scrollToPositionWithOffset(rowStart, offset);")); - assertTrue("the target card bottom must be placed inside the result viewport", - align.contains("int bottom = mBinding.recycler.getHeight() - mBinding.recycler.getPaddingBottom() - padding;") - && align.contains("int offset = Math.max(mBinding.recycler.getPaddingTop(), bottom - cardHeight);")); - assertTrue("default focus animation must not overwrite the explicit row position", - align.contains("mBinding.recycler.stopScroll();")); - } - - @Test - public void scrollCallbacksDeferResultLoadingUntilAfterLayout() throws Exception { - String source = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/CollectActivity.java"); - int listenerStart = source.indexOf("public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy)"); - int listenerEnd = source.indexOf("public void onScrollStateChanged", listenerStart); - assertTrue("scroll callback must defer all row preparation", listenerStart >= 0 && listenerEnd > listenerStart); - String listener = source.substring(listenerStart, listenerEnd); - assertTrue("onScrolled must post row preparation after the callback returns", - listener.contains("recyclerView.post(() ->") - && listener.contains("ensureSearchRows(count, 2);") - && listener.contains("preloadNextRows(count);")); - int start = source.indexOf("private void preloadNextRows(int count)"); - int end = source.indexOf("@Override\n public void onItemClick", start); - - assertTrue("search scroll helpers must be present", start >= 0 && end > start); - String scroll = source.substring(start, end); - assertTrue("scroll callbacks must route adapter changes through the layout-safe helper", - scroll.contains("ensureSearchItemsLoaded(direction, count * 2);") - && scroll.contains("ensureSearchItemsLoaded(last + 1, count * rows);")); - assertTrue("adapter insertion must be posted when RecyclerView is computing its layout", - scroll.contains("if (mBinding.recycler.isComputingLayout())") - && scroll.contains("mBinding.recycler.post(() -> ensureSearchItemsLoaded(position, count));")); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SpeedAdjustKeyContractTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SpeedAdjustKeyContractTest.java deleted file mode 100644 index 0267237edb..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/SpeedAdjustKeyContractTest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 控制栏「倍速」按钮上下微调的契约。 - * - *

用户报告:打开播放菜单后选中倍速,按遥控器 DOWN 会降速,按 UP 完全没有反应。 - * - *

根因不在控件也不在播放器:{@code CustomUpDownView} 上下对称,{@code PlayerManager} - * 的加减方向也正确。缺口在 Activity 层——控制栏样式只给了 DOWN 的邻居 - * ({@code Control.nextFocusDown=timeBar}),UP 方向在控制栏内没有可聚焦目标, - * {@link com.fongmi.android.tv.ui.helper.PlayerControlFocusHelper#handleKey} 于是把 UP 判成 - * 「会把焦点甩出控制栏」并吞掉,事件根本到不了 {@code speed.setUpListener()}。 - * - *

本契约钉住三件事: - *

    - *
  1. 倍速按钮持有焦点时,UP 走 {@code onSpeedAdd()}、DOWN 走 {@code onSpeedSub()}, - * 且两者都在门禁之前返回,不再依赖焦点搜索;
  2. - *
  3. 接管发生在 {@code PlayerControlFocusHelper.handleKey} 之前,但排在片头/片尾之后 - * ——片头/片尾的既有语义不能被抢;
  4. - *
  5. 不靠放宽 helper 的吞键来解决:那是 - * {@code timeBar.setNextFocusUpId} 回跳与片头快捷修复依赖的行为。
  6. - *
- */ -public class SpeedAdjustKeyContractTest { - - private static final String VIDEO = "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"; - private static final String HELPER = "app/src/main/java/com/fongmi/android/tv/ui/helper/PlayerControlFocusHelper.java"; - - private static final String SPEED_SIGNATURE = "private boolean dispatchSpeedAdjust(KeyEvent event)"; - private static final String DISPATCH_SIGNATURE = "public boolean dispatchKeyEvent(KeyEvent event)"; - - @Test - public void speedButtonAdjustsOnBothArrows() throws Exception { - String body = methodBody(read(VIDEO), SPEED_SIGNATURE); - assertTrue("倍速按钮上的 UP 必须加档", body.contains("onSpeedAdd()")); - assertTrue("倍速按钮上的 DOWN 必须减档", body.contains("onSpeedSub()")); - int up = body.indexOf("KeyUtil.isUpKey(event)"); - int down = body.indexOf("KeyUtil.isDownKey(event)"); - assertTrue("必须分别识别上下键", up > 0 && down > 0); - assertTrue("UP 分支必须排在 DOWN 之前", up < down); - assertTrue("UP 命中后必须把事件吃掉", body.substring(up, down).contains("return true")); - assertTrue("DOWN 命中后必须把事件吃掉", body.substring(down).contains("return true")); - } - - @Test - public void adjustmentOnlyRunsForTheFocusedSpeedButtonWhileControlsAreShown() throws Exception { - String body = methodBody(read(VIDEO), SPEED_SIGNATURE); - int actionDown = body.indexOf("KeyUtil.isActionDown(event)"); - int visible = body.indexOf("isVisible(mBinding.control.getRoot())"); - int focus = body.indexOf("mBinding.control.action.speed"); - int up = body.indexOf("KeyUtil.isUpKey(event)"); - assertTrue("必须只在按下时接管(长按重复也要走到这里,但抬起不能改倍速)", actionDown > 0); - assertTrue("控制栏不可见时不得接管", visible > 0); - assertTrue("必须要求焦点就是倍速按钮,否则会抢走别人(例如片头/片尾、字幕)", focus > 0); - assertTrue("三道门禁必须排在方向判断之前", actionDown < up && visible < up && focus < up); - } - - @Test - public void speedAdjustmentIsDispatchedBeforeTheFocusFinderSwallowsUp() throws Exception { - String dispatch = methodBody(read(VIDEO), DISPATCH_SIGNATURE); - int opening = dispatch.indexOf("dispatchOpeningEndingAdjust(event)"); - int speed = dispatch.indexOf("dispatchSpeedAdjust(event)"); - int handle = dispatch.indexOf("PlayerControlFocusHelper.handleKey("); - assertTrue("dispatchKeyEvent 必须调用 dispatchSpeedAdjust", speed > 0); - assertTrue("倍速接管必须排在 PlayerControlFocusHelper.handleKey 之前,否则 UP 已被吞", - speed < handle); - assertTrue("接管必须真的生效,不能被条件短路掉(例如写成 false && ...)", - dispatch.contains("if (dispatchSpeedAdjust(event)) return true;")); - assertTrue("片头/片尾的接管必须仍在最前面,它们的上下键语义不能被倍速抢走", - opening > 0 && opening < speed); - } - - /** - * 修在 Activity 的按键分发上,而不是把 helper 的「无邻居就吞键」打开一个口子。 - */ - @Test - public void theFixDoesNotWeakenTheFocusFinderGuard() throws Exception { - String helper = read(HELPER); - String handleKey = methodBody(helper, "public static boolean handleKey(View root, View preferred, KeyEvent event)"); - assertTrue("无邻居时吞键的保护必须保留", - handleKey.contains("if (next == null || !isDescendant(root, next)) return true;")); - assertFalse("helper 不得为倍速开特例(那会让所有按钮的 UP 都往外甩)", - withoutJavaComments(handleKey).contains("speed")); - } - - // ------------------------------------------------------------------ helpers - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("必须存在方法 " + signature, start > 0); - int end = source.indexOf("\n }", start); - return source.substring(start, end > 0 ? end : source.length()); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } - - /** 剥掉 Java 注释,避免负向断言命中说明文字(本仓库已踩过同类坑)。 */ - private static String withoutJavaComments(String source) { - return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//.*$", " "); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/StateSurfaceScrimContractTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/StateSurfaceScrimContractTest.java deleted file mode 100644 index fb05aa85fd..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/StateSurfaceScrimContractTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 空态/错误态浮层的 scrim 契约(设计文档 §5.3 / §5.4)。 - * - *

§5.3 要求「图片上的文字必须有 scrim/gradient」,§5.4 要求「所有内容表面必须有不透明或 - * 可控的 surface 层」。空态/错误态的文字此前**直接压在壁纸(一张图)上、没有任何 scrim**。 - * - *

靠调色无解:实测壁纸合成亮度 L=0.1217/0.1476/0.2019,正文(16sp)门槛 4.5:1, - * 80% 白文字只有 4.61/4.08/3.30:1;**即使换成纯白,最亮壁纸上限也只有 4.17:1**。 - * 加 scrim 后(overlay_dark ≈32% 黑)为 6.02/5.43/4.53:1,全部达标。 - */ -public class StateSurfaceScrimContractTest { - - private static final String PROGRESS_LAYOUT = - "app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java"; - private static final String STYLES = "app/src/main/res/values/webhtv_styles.xml"; - private static final String TOKENS = "app/src/main/res/values/webhtv_tokens.xml"; - private static final String NIGHT_TOKENS = "app/src/main/res/values-night/webhtv_tokens.xml"; - - /** 空态/错误态必须带 scrim,否则文字直接压在壁纸上(违反 §5.3/§5.4)。 */ - @Test - public void emptyAndErrorStatesCarryAScrim() throws Exception { - String source = read(PROGRESS_LAYOUT); - String scrim = methodBody(source, "private void applyScrim(State state)"); - assertTrue("空态/错误态必须设 scrim", scrim.contains("setBackgroundColor(themeColor(R.attr.webhtvColorOverlayDark))")); - assertTrue("必须同时覆盖 EMPTY 与 ERROR", scrim.contains("State.EMPTY") && scrim.contains("State.ERROR")); - assertTrue("其余状态必须清掉背景", scrim.contains("setBackground(null)")); - assertTrue("必须在状态切换处调用", source.contains("applyScrim(state)")); - } - - /** - * PROGRESS 态刻意不加 scrim:它是短暂的加载瞬间,加 scrim 会让页面闪一下暗。 - * 这条是**边界守卫**——防止有人"顺手"把它也加进去。 - */ - @Test - public void progressStateDeliberatelyHasNoScrim() throws Exception { - String scrim = methodBody(read(PROGRESS_LAYOUT), "private void applyScrim(State state)"); - assertFalse("PROGRESS 不得进入加 scrim 的分支", scrim.contains("State.PROGRESS")); - assertFalse("CONTENT 不得进入加 scrim 的分支", scrim.contains("State.CONTENT")); - } - - /** - * scrim 角色必须真的接上线。此前 {@code webhtvColorScrim} / {@code webhtvColorOverlayDark} / - * {@code webhtvColorOverlayLight} 三个属性都只声明、从未赋值——任何想用它们的布局都取不到值。 - */ - @Test - public void theScrimRoleIsActuallyWiredToATheme() throws Exception { - assertTrue("必须声明 webhtvColorOverlayDark 属性", - read("app/src/main/res/values/webhtv_attrs.xml").contains("name=\"webhtvColorOverlayDark\"")); - assertTrue("Theme.WebHTV 必须真的给它赋值(只声明不接线等于没有)", - read(STYLES).contains("@color/webhtv_color_overlay_dark")); - String light = read(TOKENS); - assertTrue("浅色表必须有该 token", light.contains("name=\"webhtv_color_overlay_dark\"")); - assertTrue("深色表必须有该 token", read(NIGHT_TOKENS).contains("name=\"webhtv_color_overlay_dark\"")); - } - - /** - * 空态/错误态的描述文字必须仍是正文档(16sp),因为 scrim 是按正文门槛算的。 - * 若有人把字号降到 14sp 以下而仍按 4.5:1 验收,这条会提醒重新核算。 - */ - @Test - public void descriptionStaysBodySizedSoTheScrimMathHolds() throws Exception { - String layout = read("app/src/leanback/res/layout/view_empty.xml"); - int at = layout.indexOf("@+id/description"); - assertTrue("空态必须有 description 槽位", at > 0); - String block = layout.substring(at, Math.min(layout.length(), at + 600)); - assertTrue("描述文字必须是 16sp(scrim 的对比度按正文门槛核算)", block.contains("android:textSize=\"16sp\"")); - assertTrue("描述文字必须用壁纸次级前景角色", block.contains("?attr/webhtvColorOnWallpaperVariant")); - } - - // ------------------------------------------------------------------ helpers - - private static String methodBody(String source, String signature) { - String cleaned = source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//.*$", " "); - int start = cleaned.indexOf(signature); - assertTrue("必须存在方法 " + signature, start > 0); - int open = cleaned.indexOf('{', start); - int depth = 0; - for (int i = open; i < cleaned.length(); i++) { - char c = cleaned.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return cleaned.substring(start, i + 1); - } - return cleaned.substring(start); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TmdbDetailChipFillTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TmdbDetailChipFillTest.java deleted file mode 100644 index 9568e46980..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TmdbDetailChipFillTest.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.InsetDrawable; -import android.graphics.drawable.LayerDrawable; -import android.graphics.drawable.RippleDrawable; -import android.text.TextUtils; -import android.view.ContextThemeWrapper; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.shape.MaterialShapeDrawable; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; -import org.robolectric.annotation.GraphicsMode; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; - -/** - * 用户报告:电视版「线路」和「选集」chip 只剩描边、没有底色,压在亮色剧照上看不清。 - * - *

根因是芯片填充走的通道不同: - *

    - *
  • 「线路」「选集」走 {@code setBackgroundColor},它只把颜色写进当时那个 - * {@code MaterialShapeDrawable} 实例,并不更新 Material 的 {@code backgroundTint} 字段;
  • - *
  • 同一页的「第 N 季」按钮走 {@code setBackgroundTintList},写的是那个持久字段, - * 所以它一直有可见底色。
  • - *
- * - *

Material 会在 inset 变化({@code setInsetTop/Bottom})、圆角变化或测量等时机 - * 用 {@code backgroundTint} 字段重建背景。此时 {@code setBackgroundColor} 写进旧实例的填充 - * 会被丢掉并退化为全透明,而 {@code setBackgroundTintList} 的填充会保留。 - * - *

本测试用真实渲染像素把这条差异固定成契约:它既证明当前实现(tint 通道)在背景重建后 - * 仍有底色,也证明旧的 {@code setBackgroundColor} 写法会掉色——避免日后有人"简化"回去。 - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = App.class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -public class TmdbDetailChipFillTest { - - /** 与 {@code TmdbDetailActivity#createChipButton} 保持一致的构造方式。 */ - private MaterialButton chip(Context context) { - MaterialButton button = new MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle); - button.setText("线路01"); - button.setCheckable(false); - button.setMinHeight(0); - button.setMinimumHeight(0); - button.setInsetTop(0); - button.setInsetBottom(0); - button.setMaxWidth(320); - button.setSingleLine(true); - button.setTextColor(0xFFFFFFFF); - button.setPadding(24, 12, 24, 12); - return button; - } - - private MaterialShapeDrawable fillOf(MaterialButton button) { - Drawable background = button.getBackground(); - if (background instanceof RippleDrawable ripple && ripple.getNumberOfLayers() > 0) { - Drawable layer = ripple.getDrawable(0); - if (layer instanceof InsetDrawable inset) { - Drawable inner = inset.getDrawable(); - if (inner instanceof LayerDrawable layers && layers.getNumberOfLayers() > 1) { - return (MaterialShapeDrawable) layers.getDrawable(1); - } - } - } - return null; - } - - private int fillColor(MaterialButton button) { - MaterialShapeDrawable fill = fillOf(button); - assertNotNull("chip must expose a Material fill drawable", fill); - ColorStateList tint = fill.getTintList(); - return tint == null ? 0 : tint.getDefaultColor(); - } - - /** 真实渲染一个已测量的 chip,返回其填充区域中心像素。 */ - private int renderedPixel(MaterialButton button) { - LinearLayout parent = new LinearLayout(button.getContext()); - parent.addView(button); - parent.measure(View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(120, View.MeasureSpec.EXACTLY)); - parent.layout(0, 0, 600, 120); - Bitmap bitmap = Bitmap.createBitmap(600, 120, Bitmap.Config.ARGB_8888); - parent.draw(new Canvas(bitmap)); - return bitmap.getPixel(button.getLeft() + 6, button.getTop() + button.getHeight() / 2); - } - - private void applyChipFill(MaterialButton button, int color, boolean viaTint) { - if (viaTint) button.setBackgroundTintList(ColorStateList.valueOf(color)); - else button.setBackgroundColor(color); - } - - @Test - public void persistentTintChannelKeepsChipFillThroughMaterialBackgroundRebuild() { - Context context = new ContextThemeWrapper(App.get(), R.style.Theme_App); - int chip = 0xFFEAF0F5; - - MaterialButton button = chip(context); - applyChipFill(button, chip, true); - assertEquals("tint channel must reach the fill immediately", chip, fillColor(button)); - - // Material rebuilds the background on inset changes; this is what dropped the old fill. - button.setInsetTop(0); - button.setInsetBottom(0); - - assertEquals("chip fill must survive a Material background rebuild", chip, fillColor(button)); - assertEquals("rendered chip must stay visibly filled", chip, renderedPixel(button)); - } - - @Test - public void legacySetBackgroundColorLosesItsFillWhenMaterialRebuildsTheBackground() { - Context context = new ContextThemeWrapper(App.get(), R.style.Theme_App); - int chip = 0xFFEAF0F5; - - MaterialButton button = chip(context); - applyChipFill(button, chip, false); - assertEquals("setBackgroundColor reaches the current fill instance", chip, fillColor(button)); - - button.setInsetTop(0); - button.setInsetBottom(0); - - // This is the regression the user reported: the fill is gone and only the stroke remains. - assertNotEquals("setBackgroundColor must not be used for chip fill: the rebuild drops it", - chip, fillColor(button)); - assertEquals("and the fill degrades to fully transparent", 0, fillColor(button)); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TmdbDetailSeekProgressSourceTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TmdbDetailSeekProgressSourceTest.java deleted file mode 100644 index ccb44efa9b..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TmdbDetailSeekProgressSourceTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 电视端「打开历史播放」走的是 TmdbDetailActivity 的内联播放,不是 leanback VideoActivity。 - * 设置里的播放内核与历史记录记住的内核不一致时,startInlinePlayer() 里的 preparePlayer() 会在 - * PlaySpec 建立之前重建引擎,那一刻归属判定为假,PlaybackActivity.onPlayerRebuild() 整段跳过 - * (其中包含 setProgressPlayer),进度条便继续读已释放的旧引擎,左右都显示 00:00。 - * 这里钉住内联起播这条自持路径也必须在内核重建后显式跟上新引擎。 - */ -public class TmdbDetailSeekProgressSourceTest { - - @Test - public void inlineKernelRebuildRebindsSeekView() throws Exception { - String body = methodBody(readActivity(), "private void startInlinePlayer(Result result, long resumePosition)"); - - int prepare = body.indexOf("player().preparePlayer(inlineHistoryPlayerKernel());"); - int rebind = body.indexOf("getSeekView().setProgressPlayer(player().getPlayer());", prepare); - int start = body.indexOf("startPlayer(getHistoryKey(), result,", prepare); - - assertTrue("startInlinePlayer must still prepare the remembered kernel", prepare >= 0); - assertTrue("the inline seek view must be rebound to the rebuilt engine, otherwise the TV progress bar " - + "keeps reading the released player and shows 00:00", rebind > prepare); - assertTrue("the rebind must happen before startPlayer() establishes the new PlaySpec, because " - + "onPlayerRebuild() runs while the ownership key is still empty", start > rebind); - } - - @Test - public void pendingInlinePlaybackReusesTheSameStartPath() throws Exception { - String activity = readActivity(); - String body = methodBody(activity, "private void startPendingInlinePlayer()"); - - assertTrue("a delayed inline start must keep delegating to startInlinePlayer(), so the rebind above " - + "also covers the pending branch", body.contains("startInlinePlayer(pendingInlineResult)")); - - String pending = methodBody(activity, "private void restoreInlinePlaybackIfNeeded()"); - assertTrue("resume-after-service-reconnect must also go through startInlinePlayer()", - pending.contains("startInlinePlayer(currentInlineResult")); - } - - @Test - public void inlineRebindGoesThroughTheSharedSeekAccessor() throws Exception { - String activity = readActivity(); - assertTrue("getSeekView() must keep resolving the inline seek view", - methodBody(activity, "protected CustomSeekView getSeekView()").contains("return inlineSeek();")); - assertFalse("the inline rebind must go through getSeekView() rather than binding the seek view directly", - methodBody(activity, "private void startInlinePlayer(Result result, long resumePosition)") - .contains("binding.seek")); - } - - private static String readActivity() throws Exception { - Path path = Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java"); - if (!Files.isRegularFile(path)) path = Path.of("app").resolve(path); - return Files.readString(path, StandardCharsets.UTF_8); - } - - private static String methodBody(String source, String signature) { - int start = source.indexOf(signature); - assertTrue("missing method: " + signature, start >= 0); - int end = source.indexOf("\n }", start); - assertTrue("unterminated method: " + signature, end > start); - return source.substring(start, end); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvAppSurfaceFocusRingDeviceTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvAppSurfaceFocusRingDeviceTest.java deleted file mode 100644 index cd40bc021d..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvAppSurfaceFocusRingDeviceTest.java +++ /dev/null @@ -1,551 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.StateListDrawable; -import android.util.TypedValue; - -import androidx.appcompat.view.ContextThemeWrapper; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.R; - -import org.junit.After; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.annotation.Config; -import org.robolectric.annotation.GraphicsMode; - -/** - * 电视端「应用表面」焦点环的像素级证据(Robolectric 真实栅格化)。 - * - *

用户报告(两张实机截图): - *

TV端上面两行按钮是 蓝色边框,下面的是 白色边框,边框粗细也貌似不一样, - * 请统一风格并受到主题色彩控制
- * - *

静态契约测试({@code TvFocusRingContractTest})只能证明 XML 文本写对了。本测试把 - * drawable 真正栅格化到 {@link Bitmap},再逐像素量出**实际**的描边厚度与颜色——也就是 - * 用户肉眼看到的那两条边框。因此它能抓出「XML 看着对、渲染出来其实不同」的情况, - * 例如 1.5dp 与 3dp 在不同密度下取整到不同像素数。 - * - *

断言分三条: - *

    - *
  1. 首页两族焦点环(功能按钮 vs 内容卡片)的实测厚度与颜色必须字节相同;
  2. - *
  3. 全部应用表面焦点环的实测厚度必须等于 {@code @dimen/webhtv_focus_ring_width};
  4. - *
  5. 环色必须等于主题 FOCUS 槽,并在浅色/深色两张色板下各自跟随变化(真正的主题可控性)。
  6. - *
- * - *

说明:{@code ?attr/tvFocusRing} 是编译期静态资源,用户档案覆写在运行期由 - * {@code ThemeBinder} 作用于视图树(另由 {@code TvFocusRingResolutionDeviceTest} 覆盖)。 - * 本测试证明的是「环色来自主题色板」——同一 drawable 在 day / night 两张表下解析出不同颜色。 - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 28, application = App.class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -public class TvAppSurfaceFocusRingDeviceTest { - - /** 首页功能按钮行(搜索/历史):adapter_func -> selector_item -> shape_item_focused。 */ - private static final int BUTTON_SELECTOR = R.drawable.selector_item; - /** 首页内容卡片行(最近观看/更新推荐):adapter_vod -> selector_vod -> shape_vod_focused。 */ - private static final int CARD_SELECTOR = R.drawable.selector_vod; - - /** 全部「应用表面」焦点环(宿主为调色板表面或对话框面板)。 */ - private static final int[] APP_SURFACE_RINGS = { - R.drawable.selector_item, - R.drawable.selector_item_round, - R.drawable.selector_vod, - R.drawable.selector_vod_oval, - R.drawable.selector_keyboard, - R.drawable.selector_search_hot_word, - }; - - /** 渲染尺寸:足够大,四条边都不会被裁掉。 */ - private static final int SIZE = 120; - - /** 判定「这个像素属于环」的 alpha 门槛(排除抗锯齿边缘的半透明过渡)。 */ - private static final int OPAQUE = 200; - - @After - public void tearDown() { - RuntimeEnvironment.setQualifiers(""); - } - - private static Context themedContext() { - return new ContextThemeWrapper(RuntimeEnvironment.getApplication(), R.style.Theme_App); - } - - /** 取 selector 的 focused 分支(这些 selector 的 state_focused 都是第一项)。 */ - private static Drawable focusedDrawable(Context context, int resId) { - Drawable drawable = context.getDrawable(resId); - assertTrue(resId + " 必须能解析出 drawable", drawable != null); - if (drawable instanceof StateListDrawable states) { - states.setState(new int[]{android.R.attr.state_focused}); - Drawable current = states.getCurrent(); - assertTrue(resId + " 的 focused 状态必须解析出一个 drawable", current != null); - return current; - } - return drawable; - } - - /** 把 focused 分支栅格化。 */ - private static Bitmap render(Context context, int resId) { - return render(context, resId, SIZE); - } - - private static Bitmap render(Context context, int resId, int size) { - return rasterize(focusedDrawable(context, resId), size); - } - - /** - * 实测环的厚度(像素):从图像中部左侧起,数连续的不透明像素个数。 - * 矩形/圆形的左边框中点都是竖直的直线段,所以这是一段干净的游程。 - */ - private static int ringThicknessPx(Bitmap bitmap) { - int y = SIZE / 2; - int x = 0; - while (x < SIZE && alpha(bitmap, x, y) < OPAQUE) x++; - int run = 0; - while (x < SIZE && alpha(bitmap, x, y) >= OPAQUE) { - run++; - x++; - } - assertTrue("中部左侧必须能找到一段不透明的焦点环(实际为 0)", run > 0); - return run; - } - - /** 实测环的颜色:取环游程中点那个像素。 */ - private static int ringColour(Bitmap bitmap) { - int y = SIZE / 2; - int x = 0; - while (x < SIZE && alpha(bitmap, x, y) < OPAQUE) x++; - int run = 0; - int start = x; - while (x < SIZE && alpha(bitmap, x, y) >= OPAQUE) { - run++; - x++; - } - assertTrue("中部左侧必须能找到一段不透明的焦点环(实际为 0)", run > 0); - return bitmap.getPixel(start + run / 2, y); - } - - private static int alpha(Bitmap bitmap, int x, int y) { - return (bitmap.getPixel(x, y) >>> 24) & 0xFF; - } - - private static int expectedWidthPx(Context context) { - return context.getResources().getDimensionPixelSize(R.dimen.webhtv_focus_ring_width); - } - - /** 主题 FOCUS 槽:{@code ?attr/tvFocusRing} 的取值来源。 */ - private static int focusSlot(Context context) { - TypedValue value = new TypedValue(); - assertTrue("主题必须声明 tvFocusRing", - context.getTheme().resolveAttribute(R.attr.tvFocusRing, value, true)); - if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { - return value.data; - } - return context.getColor(value.resourceId); - } - - private static String name(Context context, int resId) { - return context.getResources().getResourceEntryName(resId); - } - - // ------------------------------------------------------------------ R1 两族同款 - - /** - * 用户报告的核心:首页上面两行按钮是蓝框、下面的内容是白框,粗细也不同。 - * 两族实测的厚度与颜色必须完全一致。 - */ - @Test - public void homeFunctionButtonsAndContentCardsRenderTheSameRing() { - Context context = themedContext(); - Bitmap button = render(context, BUTTON_SELECTOR); - Bitmap card = render(context, CARD_SELECTOR); - - assertEquals("首页功能按钮与内容卡片的焦点环厚度必须相同(用户报告粗细不一样)", - ringThicknessPx(button), ringThicknessPx(card)); - assertEquals("首页功能按钮与内容卡片的焦点环颜色必须相同(用户报告蓝色/白色两种)", - ringColour(button), ringColour(card)); - } - - @Test - public void everyAppSurfaceRingRendersAtTheSingleWidthToken() { - Context context = themedContext(); - int expected = expectedWidthPx(context); - for (int resId : APP_SURFACE_RINGS) { - int measured = ringThicknessPx(render(context, resId)); - assertTrue(name(context, resId) + " 的实测焦点环厚度 " + measured - + "px 必须等于 @dimen/webhtv_focus_ring_width (" + expected + "px)", - Math.abs(measured - expected) <= 1); - } - } - - // ------------------------------------------------------------------ R2 主题可控 - - @Test - public void appSurfaceRingsRenderInTheThemeFocusColour() { - Context context = themedContext(); - int slot = focusSlot(context); - assertEquals("焦点环色必须来自 webhtv_color_focus,而不是别的角色", - context.getColor(R.color.webhtv_color_focus), slot); - - for (int resId : new int[]{BUTTON_SELECTOR, CARD_SELECTOR}) { - assertEquals(name(context, resId) + " 的实测焦点环颜色必须等于主题 FOCUS 槽", - slot, ringColour(render(context, resId))); - } - } - - /** - * 真正的「受到主题色彩控制」:同一个 drawable 在浅色表与深色表下必须渲染出不同的环色。 - * 只看 XML 文本无法证明这一点,只有栅格化后取像素才能证明。 - */ - @Test - public void appSurfaceRingsFollowTheThemePaletteAcrossDayAndNight() { - Context day = themedContext(); - int dayColour = ringColour(render(day, CARD_SELECTOR)); - int daySlot = focusSlot(day); - - RuntimeEnvironment.setQualifiers("+night"); - Context night = themedContext(); - int nightColour = ringColour(render(night, CARD_SELECTOR)); - int nightSlot = focusSlot(night); - - assertEquals("浅色表下环色必须等于浅色 FOCUS 槽", daySlot, dayColour); - assertEquals("深色表下环色必须等于深色 FOCUS 槽", nightSlot, nightColour); - assertNotEquals("浅色表与深色表的 FOCUS 槽本身就不同,测试前提必须成立", daySlot, nightSlot); - assertNotEquals("换色板后焦点环颜色必须跟着变,否则主题控制无效", dayColour, nightColour); - - // 回归钉子:修复前这里写死 @color/white,两张表都会渲染成同一个白色。 - assertNotEquals("焦点环不允许再渲染成与调色板无关的固定白色", - 0xFFFFFFFF, nightColour); - } - - /** 环必须完全不透明,否则焦点不可见。 */ - @Test - public void focusedRingRendersFullyOpaque() { - Context context = themedContext(); - for (int resId : new int[]{BUTTON_SELECTOR, CARD_SELECTOR}) { - int colour = ringColour(render(context, resId)); - assertEquals(name(context, resId) + " 的焦点环必须完全不透明,否则焦点不可见", - 0xFF, (colour >>> 24) & 0xFF); - } - } - - /** - * 变异检验:本测试的度量方法必须真的能区分「细环」与「粗环」。 - * 直接渲染 1.5dp 与 3dp 两种描边,实测厚度必须不同——否则上面的断言就是空断言。 - */ - @Test - public void theMeasurementCanActuallyTellThinRingsFromThickOnes() { - Context context = themedContext(); - float density = context.getResources().getDisplayMetrics().density; - - Bitmap thin = renderStroke(context, Math.round(1.5f * density)); - Bitmap thick = renderStroke(context, Math.round(3f * density)); - - int thinPx = ringThicknessPx(thin); - int thickPx = ringThicknessPx(thick); - assertTrue("度量方法必须能区分细环与粗环(实测 thin=" + thinPx + "px thick=" + thickPx + "px)", - thickPx > thinPx); - } - - /** 造一个与生产 drawable 同构的描边,用于度量方法的变异检验。 */ - private static Bitmap renderStroke(Context context, int widthPx) { - android.graphics.drawable.GradientDrawable shape = new android.graphics.drawable.GradientDrawable(); - shape.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); - shape.setCornerRadius(0f); - shape.setStroke(widthPx, 0xFFFFFFFF); - Bitmap bitmap = Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - shape.setBounds(0, 0, SIZE, SIZE); - shape.draw(canvas); - return bitmap; - } - - // ------------------------------------------------------------ R4 卡片环圆角对齐卡片自身圆角 - - /** - * 卡片宿主的可见表面圆角:{@code @style/Vod.Grid}(图片上圆角)、{@code shape_vod_name} - * (标题块下圆角)、{@code shape_vod_list}(列表型宿主四角)都是一律 8dp。 - */ - private static final float CARD_SURFACE_RADIUS_DP = 8f; - - /** 角落几何用的栅格尺寸:密度 3 下为 80dp,四条边都远离角落弧,不会被裁掉。 */ - private static final int CORNER_SIZE = 240; - - /** 电视端主档密度:要求严格 0 溢出,最接近 dev1 实机(`wm density`=280 → 2.35px/dp)。 */ - private static final String TV_PRIMARY_DENSITY = "xxhdpi"; - - /** 其余密度的亚像素容差:每角最多 2px;用户报告的缺陷特征 ≥25px,不会漏过。 */ - private static final int SUBPIXEL_LEAK_TOLERANCE_PX = 8; - - /** - * 用户报告(2026-10-10 dev1 实机):「边框在卡片上的圆角部分匹配不完美,感觉是圆角比卡片小, - * 导致卡片的圆角还露出一节了」。 - * - *

实机像素复核(1920x1080 / 密度 280,应用内有效缩放 ≈ 2.35px/dp)量到:卡片表面右边界 - * 396.5px、环外边界 403.46px(环带 7.06px = 3dp),而环的 外边界圆角弧半径 ≈ 21.2px - * ≈ 9dp = 声明 8dp + w/2;卡片自身弧半径 8dp。两者中心都在角上 8–9.5dp 处,沿对角线卡片 - * 弧反而更靠外 1.03px,也就是实机上那 1–2px 米色月牙。栅格化扫描(density 1/1.75/2/2.353/3/4) - * 也复现了同一个方向:声明 8dp 时卡片溢出高数十倍于修复后。 - * - *

本测试不看 XML,直接把真实 {@code selector_vod} 焦点分支与卡片表面栅格化后逐像素验证: - * 卡片不能有任何像素落在环的 外边界之外。断言覆盖电视端实际存在的四档密度 - * (hdpi/xhdpi/xxhdpi/xxxhdpi),因为描边宽度是按整数像素向上取整的,同一 dp 取值在不同 - * 密度下取整结果不同。栅格化扫描实测卡片溢出像素:声明 8dp 在四档下为 25/52/114/181, - * 声明 7dp 在 1.5/2/3/4 四档下为 0(含全量套件下的重跑)。 - * - *

容差:主档密度(xxhdpi,与 dev1 实机 ~2.35px/dp 最接近的档位)要求**严格 0**; - * 其余密度允许每角最多 2px 的亚像素平局(实测 xhdpi 下 5px)——卡片的 16px 弧与环外边界 - * 的 15.5px 弧只差半个像素,栅格化在切线处会把这一点点像素判给任一侧;而用户报告的缺陷 - * 特征是最少 25px,所以这个容差不会让缺陷漏过(变异检验会验证这一点)。 - * - *

密度限定符必须用**绝对**写法({@code "xhdpi"} 而不是 {@code "+xhdpi"}): - * {@code RuntimeEnvironment.setQualifiers("+xhdpi")} 是「在现有限定符上追加」, - * 全量套件里前面别的测试类留下的密度限定符会一起参与解析,使实际密度不确定。 - * - *

说明:真实卡片轮廓是四角 8dp 的圆角矩形(顶部来自图片、底部来自标题块,列表型宿主 - * 四角同圆角),所以这里用统一 8dp 的圆角矩形当卡片表面;单环覆盖全部宿主的前提由 - * {@code TvFocusRingContractTest.cardRingCornerRadiusIsTheHostRadiusMinusHalfTheRingWidth} 钉住。 - */ - @Test - public void cardRingOuterBoundaryContainsEveryCardSurfacePixel() { - for (String density : new String[]{"hdpi", "xhdpi", "xxhdpi", "xxxhdpi"}) { - RuntimeEnvironment.setQualifiers(density); - Context context = themedContext(); - int outside = cardPixelsOutsideRing( - render(context, CARD_SELECTOR, CORNER_SIZE), - cardSurface(context, CARD_SURFACE_RADIUS_DP)); - String where = "(限定符 " + density + ",实际密度 " - + context.getResources().getDisplayMetrics().density + ")"; - if (TV_PRIMARY_DENSITY.equals(density)) { - assertEquals("卡片表面不允许有任何像素落在焦点环外边界之外" + where - + "——这正是用户报告的「卡片的圆角还露出一节」,声明圆角必须取 宿主圆角 - 环宽/2", - 0, outside); - } else { - assertTrue("卡片溢出 " + outside + "px 必须落在亚像素上限 " + SUBPIXEL_LEAK_TOLERANCE_PX - + "px 内" + where, outside <= SUBPIXEL_LEAK_TOLERANCE_PX); - } - } - } - - // ------------------------------------------------------------ R4b 真实宿主(详情页/播放页) - - /** - * 把「环外边界必须包住宿主真实轮廓」这条要求,直接跑在**真实布局**上。 - * - *

与上面 R4 的区别:R4 用代码搭一个 8dp 圆角矩形当卡片表面;这里直接 inflate 生产布局 - * (adapter_episode_card / adapter_tmdb_video / adapter_tmdb_photo / adapter_tmdb_cast / - * dialog_episode_detail 的 stillCard),取它们的真实 background 作为「宿主表面」,再把真实环 - * 栅格化后逐像素判定。宿主圆角、内缩、自带描边全部是生产值,没有任何测试侧假设。 - * - *

宿主与环的对应关系(详情页/播放页,2026-10-10 新增): - *

    - *
  • {@code adapter_episode_card} ↔ {@code selector_episode_card}(8dp 宿主)
  • - *
  • {@code adapter_tmdb_video} / {@code adapter_tmdb_photo} ↔ {@code selector_tmdb_media_focus}(8dp)
  • - *
  • {@code adapter_tmdb_cast} ↔ {@code selector_tmdb_cast_focus}(12dp)
  • - *
  • {@code dialog_episode_detail#stillCard} ↔ {@code selector_episode_photo}(8dp)
  • - *
  • 播放页画面框 {@code @id/video} 是直角 FrameLayout ↔ {@code selector_video}(0dp)
  • - *
- * - *

容差与 R4 同口径:主档密度严格 0,其余档位允许每角 2px 的亚像素平局。 - */ - @Test - public void realDetailAndPlayerHostsContainTheirFocusRingCorner() { - for (String density : new String[]{"hdpi", "xhdpi", "xxhdpi", "xxxhdpi"}) { - RuntimeEnvironment.setQualifiers(density); - Context context = themedContext(); - float d = context.getResources().getDisplayMetrics().density; - - // 1) 详情页选集卡:CardView 8dp + selector_episode_card - checkHost(context, "adapter_episode_card/selector_episode_card", density, - R.layout.adapter_episode_card, 0, R.drawable.selector_episode_card, - (int) (280 * d), (int) (160 * d)); - // 2) 详情页相关视频 / 剧照:MaterialCardView 8dp + selector_tmdb_media_focus - checkHost(context, "adapter_tmdb_video/selector_tmdb_media_focus", density, - R.layout.adapter_tmdb_video, 0, R.drawable.selector_tmdb_media_focus, - (int) (276 * d), (int) (156 * d)); - checkHost(context, "adapter_tmdb_photo/selector_tmdb_media_focus", density, - R.layout.adapter_tmdb_photo, 0, R.drawable.selector_tmdb_media_focus, - (int) (220 * d), (int) (124 * d)); - // 3) 详情页演员卡:MaterialCardView 12dp + selector_tmdb_cast_focus - checkHost(context, "adapter_tmdb_cast/selector_tmdb_cast_focus", density, - R.layout.adapter_tmdb_cast, 0, R.drawable.selector_tmdb_cast_focus, - (int) (90 * d), (int) (150 * d)); - // 4) 剧照详情卡:CardView 8dp + selector_episode_photo - checkHost(context, "dialog_episode_detail#stillCard/selector_episode_photo", density, - R.layout.dialog_episode_detail, R.id.stillCard, R.drawable.selector_episode_photo, - (int) (400 * d), (int) (225 * d)); - // 5) 播放页画面框:直角 FrameLayout + selector_video(声明 0dp) - checkHost(context, "activity_video#video/selector_video", density, - R.layout.activity_video, R.id.video, R.drawable.selector_video, - (int) (400 * d), (int) (225 * d)); - } - } - - /** - * inflate 真实宿主(可指定内部 id),用它的 background 当表面,与真实环逐像素比。 - * 表面为 null 时(直角宿主如播放页画面框)改用「整块不透明矩形」当表面, - * 这样仍能抓到「环把直角宿主包出圆角」的错位。 - */ - private static void checkHost(Context context, String label, String density, int layoutRes, int innerId, - int ringRes, int w, int h) { - android.view.View root = android.view.LayoutInflater.from(context).inflate(layoutRes, null, false); - android.view.View host = innerId == 0 ? root : root.findViewById(innerId); - assertTrue(label + " 必须能在真实布局里找到宿主", host != null); - host.measure(android.view.View.MeasureSpec.makeMeasureSpec(w, android.view.View.MeasureSpec.EXACTLY), - android.view.View.MeasureSpec.makeMeasureSpec(h, android.view.View.MeasureSpec.EXACTLY)); - host.layout(0, 0, w, h); - - Drawable ring = focusedDrawable(context, ringRes); - Bitmap surfaceBitmap = hostSurface(host, w, h); - int outside = cardPixelsOutsideRing(rasterize(ring, w, h), surfaceBitmap, w, h); - - String where = label + "(限定符 " + density + ",实际密度 " - + context.getResources().getDisplayMetrics().density + ")"; - if (TV_PRIMARY_DENSITY.equals(density)) { - assertEquals("宿主表面不允许有任何像素落在焦点环外边界之外 " + where - + "——环声明圆角必须取 宿主圆角 − 环宽/2", 0, outside); - } else { - assertTrue(where + " 的溢出 " + outside + "px 必须落在亚像素上限 " - + SUBPIXEL_LEAK_TOLERANCE_PX + "px 内", outside <= SUBPIXEL_LEAK_TOLERANCE_PX); - } - } - - /** - * 取宿主的**真实**可见轮廓:{@code MaterialCardView} 不暴露 {@code getBackground()} - * (它用内部 ShapeThemingDrawable),必须从 {@code getShapeAppearanceModel()} 重建; - * 其它视图直接用 background;直角宿主(如播放页画面框)退化为整块矩形。 - */ - private static Bitmap hostSurface(android.view.View host, int w, int h) { - Drawable background = host.getBackground(); - if (background != null) return rasterize(background, w, h); - if (host instanceof com.google.android.material.card.MaterialCardView card) { - com.google.android.material.shape.MaterialShapeDrawable shape = - new com.google.android.material.shape.MaterialShapeDrawable(card.getShapeAppearanceModel()); - shape.setTint(0xFFF3E7D9); - return rasterize(shape, w, h); - } - return solidRect(w, h); - } - - /** 直角宿主的表面:整块不透明矩形。 */ - private static Bitmap solidRect(int w, int h) { - Bitmap bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - canvas.drawColor(0xFFF3E7D9); - return bitmap; - } - - /** - * 变异检验:把用户报告时的旧取值(声明 8dp + 3dp 环宽)搭回来,上面的度量必须能抓到卡片 - * 圆角溢出,否则 {@code cardRingOuterBoundaryContainsEveryCardSurfacePixel} 就是空断言。 - */ - @Test - public void theCornerMeasurementCatchesTheReportedCardCornerBleed() { - RuntimeEnvironment.setQualifiers("xxhdpi"); - Context context = themedContext(); - - int outside = cardPixelsOutsideRing( - renderLegacyCardRing(context, 8f, 3f), - cardSurface(context, CARD_SURFACE_RADIUS_DP)); - - assertTrue("度量方法必须能抓到「环外边界圆角大于卡片圆角」时卡片露出的月牙" - + "(实测落在环外的卡片像素 = " + outside + "px)", - outside > 0); - } - - /** 栅格化一张卡片表面:统一圆角的圆角矩形(几何与 ShapeableImageView 的 8dp 轮廓等价)。 */ - private static Bitmap cardSurface(Context context, float radiusDp) { - float density = context.getResources().getDisplayMetrics().density; - android.graphics.drawable.GradientDrawable surface = new android.graphics.drawable.GradientDrawable(); - surface.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); - surface.setCornerRadius(radiusDp * density); - surface.setColor(0xFFF3E7D9); - return rasterize(surface, CORNER_SIZE); - } - - /** 用户报告时的旧卡片环:声明圆角 8dp(= 宿主圆角,没减 w/2)、环宽 3dp。 */ - private static Bitmap renderLegacyCardRing(Context context, float radiusDp, float widthDp) { - float density = context.getResources().getDisplayMetrics().density; - android.graphics.drawable.GradientDrawable ring = new android.graphics.drawable.GradientDrawable(); - ring.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); - ring.setCornerRadius(radiusDp * density); - ring.setStroke(Math.round(widthDp * density), focusSlot(context)); - return rasterize(ring, CORNER_SIZE); - } - - private static Bitmap rasterize(Drawable drawable, int size) { - return rasterize(drawable, size, size); - } - - private static Bitmap rasterize(Drawable drawable, int width, int height) { - Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - drawable.setBounds(0, 0, width, height); - drawable.draw(canvas); - return bitmap; - } - - /** - * 数出有多少卡片像素落在环的 外边界之外。做法:从图像四边向内扩散所有「非环」像素, - * 扩散得到的就是环外侧的区域(环内部被不透明环带封住,扩散不进去)。 - */ - private static int cardPixelsOutsideRing(Bitmap ring, Bitmap card) { - return cardPixelsOutsideRing(ring, card, CORNER_SIZE, CORNER_SIZE); - } - - /** 同上,但接受任意尺寸(详情页/播放页的真实宿主不是正方形)。 */ - private static int cardPixelsOutsideRing(Bitmap ring, Bitmap card, int width, int height) { - int total = width * height; - boolean[] cardPixel = new boolean[total]; - boolean[] ringPixel = new boolean[total]; - boolean[] outside = new boolean[total]; - int[] queue = new int[total]; - int head = 0; - int tail = 0; - - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - int at = y * width + x; - ringPixel[at] = alpha(ring, x, y) >= OPAQUE; - cardPixel[at] = alpha(card, x, y) >= OPAQUE; - boolean border = x == 0 || y == 0 || x == width - 1 || y == height - 1; - if (border && !ringPixel[at] && !outside[at]) { - outside[at] = true; - queue[tail++] = at; - } - } - } - - while (head < tail) { - int at = queue[head++]; - int x = at % width; - int y = at / width; - for (int[] step : new int[][]{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) { - int nx = x + step[0]; - int ny = y + step[1]; - if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue; - int next = ny * width + nx; - if (ringPixel[next] || outside[next]) continue; - outside[next] = true; - queue[tail++] = next; - } - } - - int leaked = 0; - for (int at = 0; at < total; at++) { - if (cardPixel[at] && outside[at]) leaked++; - } - return leaked; - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvCardBadgeContrastTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvCardBadgeContrastTest.java deleted file mode 100644 index 3f323dc2a8..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvCardBadgeContrastTest.java +++ /dev/null @@ -1,211 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * TV 卡片角标(进度 / 片源 / 年份)的语义与可读性契约。 - * - *

改动前,这三个角标是 leanback 独有的裸十六进制:{@code #177535}、{@code #F44336}、 - * {@code #2196F3},且都带 {@code android:alpha="0.7"}。手机端同一组角标早就是语义色 - * ({@code ?attr/colorSecondaryContainer} / {@code ?attr/colorErrorContainer} / - * {@code ?attr/colorSurfaceVariant}),只有 TV 端落后。 - * - *

透明度带来的问题比"没走 token"更严重:角标上的文字恒为 - * {@code ?attr/webhtvColorOnWallpaper}(纯白),而 0.7 的底色叠在**浅色海报**上会被提亮, - * 白字对比度跌到约 2.0–3.7:1。海报内容不受控,所以这个组合在真实内容上是不可读的。 - * - *

{@code LeanbackForegroundContrastTest} 抓不到它:那里的 {@code luminance()} 会丢弃 - * alpha 通道,把半透明底色当成不透明来算,于是 {@code #177535} 被判定为"深色表面,通过"。 - * 本测试补上这一步——**把角标底色按 alpha 合成到最坏底(纯白海报)上再算对比度**, - * 因此将来有人重新引入透明度,这里会先失败。 - * - *

角标刻意用直接的 {@code @color} 引用而不是 {@code ?attr}:可读性依赖"底色足够暗", - * 做成可被主题改写的角色就等于放弃这个保证;同时 {@code ?attr} 填充会被 - * {@code LeanbackForegroundContrastTest} 判为"调色板跟随背景(日间为浅色)"而误报。 - */ -public class TvCardBadgeContrastTest { - - private static final String TOKENS = "app/src/main/res/values/webhtv_tokens.xml"; - private static final String TOKENS_NIGHT = "app/src/main/res/values-night/webhtv_tokens.xml"; - private static final String ADAPTER = "app/src/leanback/res/layout/adapter_vod.xml"; - private static final String LEANBACK_COLOR = "app/src/leanback/res/color/"; - - /** 角标上的文字恒为纯白({@code ?attr/webhtvColorOnWallpaper} 两张表同值)。 */ - private static final String BADGE_FOREGROUND = "#FFFFFF"; - - /** 正文门槛;角标文字是 14sp,不属于大字号豁免。 */ - private static final double MIN_CONTRAST = 4.5; - - /** 角标文件名(不含 .xml) → { 应引用的 token, 对应布局里的 id }。 */ - private static final Map BADGES = new LinkedHashMap<>(); - - static { - BADGES.put("bg_remark", new String[]{"webhtv_color_badge_progress", "playback"}); - BADGES.put("bg_site", new String[]{"webhtv_color_badge_site", "site"}); - BADGES.put("bg_year", new String[]{"webhtv_color_badge_year", "year"}); - } - - private static final Pattern COLOR_ENTRY = Pattern.compile("([^<]+)"); - private static final Pattern SELECTOR_ITEM = Pattern.compile("]*)/?>"); - private static final Pattern ATTRIBUTE = Pattern.compile("android:(alpha|color)=\"([^\"]+)\""); - private static final Pattern RAW_HEX = Pattern.compile("#[0-9A-Fa-f]{3,8}"); - - // ------------------------------------------------------------------ 语义 - - @Test - public void cardBadgesUseTokensInsteadOfRawHex() throws Exception { - for (Map.Entry badge : BADGES.entrySet()) { - String name = badge.getKey(); - String token = badge.getValue()[0]; - String source = read(LEANBACK_COLOR + name + ".xml"); - assertFalse(name + " 不允许再出现裸十六进制", - RAW_HEX.matcher(withoutComments(source)).find()); - assertTrue(name + " 必须引用 @" + token, source.contains("@color/" + token)); - assertFalse(name + " 不应再带透明度:半透明底色叠在浅色海报上会让白字跌破 4.5:1", - withoutComments(source).contains("android:alpha")); - assertFalse(name + " 不得改成 ?attr 角色:那会让可读性依赖主题取值", - withoutComments(source).contains("?attr/")); - } - } - - @Test - public void badgeTokensAreDeclaredInBothPalettesWithTheSameValue() throws Exception { - String light = read(TOKENS); - String night = read(TOKENS_NIGHT); - for (String[] badge : BADGES.values()) { - String token = badge[0]; - String lightValue = tokenValue(light, token); - assertTrue(token + " 必须声明在日间表", lightValue != null); - assertEquals(token + " 两张表必须同值:角标画在海报上,与调色板无关", - lightValue, tokenValue(night, token)); - } - } - - // ------------------------------------------------------------------ 可读性 - - /** - * 角标白字必须在**任意海报**上都达标。 - * - *

做法是按角标色表里声明的 alpha,把底色合成到最坏底(纯白海报)上再算对比度; - * 角标一旦不透明,合成结果就是它自身,对比度与海报无关。 - */ - @Test - public void badgeTextStaysReadableOverAnyPoster() throws Exception { - String tokens = read(TOKENS); - for (Map.Entry badge : BADGES.entrySet()) { - String name = badge.getKey(); - String token = badge.getValue()[0]; - String literal = tokenValue(tokens, token); - assertTrue(token + " 必须是可直接解析的十六进制取值", literal != null && literal.startsWith("#")); - - double alpha = declaredAlpha(read(LEANBACK_COLOR + name + ".xml")); - double ratio = contrast(BADGE_FOREGROUND, hex(compositeOver(literal, alpha, 0xFFFFFF))); - - assertTrue(name + " 的白字在纯白海报上只有 " - + String.format(Locale.ROOT, "%.2f:1", ratio) - + "(alpha=" + alpha + "),低于 " + MIN_CONTRAST + ":1", - ratio >= MIN_CONTRAST); - } - } - - /** - * 角标文字必须走壁纸前景角色。 - * - *

如果哪天把角标文字换成调色板跟随的角色(例如 {@code ?attr/colorOnSurface}), - * 上面那条"白字对深色角标"的保证就不再成立,这里先把配对钉住。 - */ - @Test - public void badgeLabelsUseTheWallpaperForegroundRole() throws Exception { - String layout = read(ADAPTER); - for (String[] badge : BADGES.values()) { - assertEquals(badge[1] + " 角标文字必须走 ?attr/webhtvColorOnWallpaper", - "?attr/webhtvColorOnWallpaper", textColourOf(layout, badge[1])); - } - } - - // ------------------------------------------------------------------ helpers - - private static String tokenValue(String tokens, String token) { - Matcher matcher = COLOR_ENTRY.matcher(withoutComments(tokens)); - while (matcher.find()) { - if (matcher.group(1).equals(token)) return matcher.group(2).trim(); - } - return null; - } - - /** 角标色表声明的 alpha;未声明即不透明。 */ - private static double declaredAlpha(String source) { - Matcher item = SELECTOR_ITEM.matcher(withoutComments(source)); - assertTrue("角标色表必须至少有一段 ", item.find()); - Matcher attribute = ATTRIBUTE.matcher(item.group(1)); - while (attribute.find()) { - if ("alpha".equals(attribute.group(1))) return Double.parseDouble(attribute.group(2)); - } - return 1.0; - } - - /** 把 {@code foreground} 按 {@code alpha} 合成到 {@code backdrop} 上(逐通道 sRGB)。 */ - private static int compositeOver(String foreground, double alpha, int backdrop) { - int value = Integer.parseInt(foreground.substring(1), 16); - return (mix((value >> 16) & 0xFF, (backdrop >> 16) & 0xFF, alpha) << 16) - | (mix((value >> 8) & 0xFF, (backdrop >> 8) & 0xFF, alpha) << 8) - | mix(value & 0xFF, backdrop & 0xFF, alpha); - } - - private static int mix(int channel, int backdrop, double alpha) { - return (int) Math.round(alpha * channel + (1 - alpha) * backdrop); - } - - private static String hex(int value) { - return String.format("#%06X", value & 0xFFFFFF); - } - - private static double contrast(String first, String second) { - double a = luminance(first); - double b = luminance(second); - return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); - } - - private static double luminance(String hex) { - String digits = hex.startsWith("#") ? hex.substring(1) : hex; - if (digits.length() == 8) digits = digits.substring(2); - double[] channels = new double[3]; - for (int i = 0; i < 3; i++) { - double value = Integer.parseInt(digits.substring(i * 2, i * 2 + 2), 16) / 255.0; - channels[i] = value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); - } - return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; - } - - private static String textColourOf(String layout, String id) { - Matcher block = Pattern.compile("@\\+id/" + Pattern.quote(id) + "\"(.*?)(?:/>|", " "); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvContentInsetContractTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvContentInsetContractTest.java deleted file mode 100644 index 50593ef6c9..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvContentInsetContractTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 电视端内容区必须等于整块窗口:底部不允许再出现整条黑带。 - * - *

实机证据(模拟器 5554,1920x1080/280dpi):该屏带底部 overscan 90px - * ({@code dumpsys window displays} → {@code overscan (0,0,0,90)}),系统把这条内边距 - * 下发给窗口。AppCompat 的 decor 里有两层各自应用了一次 fitsSystemWindows 内边距 - * (外层 window content 与内层 {@code android:id/content}),于是内容区被扣掉 - * 2×90=180px —— 实测 {@code android:id/content} 只有 {@code 0,0-1920,900} 而窗口是 - * {@code 0,0-1920,1080};同一块屏上 launcher 的内容区是 990(只扣一次)。表现就是 - * 首页卡片标题被齐腰切断、{@code 900..1080} 整条是窗口背景黑。 - * - *

修复走 leanback {@code BaseActivity#bindFullWindowContent()}:在 DecorView 上装 - * "只消费、不加内边距"的监听器,inset 不再下发给任何一层,内容铺满窗口。 - * 本守卫锁住三件事,防止以后回退或被复制到手机端: - *

    - *
  1. leanback 基类确实按上面的方式消费 inset;
  2. - *
  3. 它没有把 inset 转成 padding/margin(那就又变成黑带);
  4. - *
  5. 手机端基类不消费 inset(手机需要系统栏内边距,行为必须分开)。
  6. - *
- */ -public class TvContentInsetContractTest { - - private static final String LEANBACK_BASE = "app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java"; - private static final String MOBILE_BASE = "app/src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java"; - - @Test - public void leanbackActivitiesFillTheWholeWindow() throws Exception { - String source = read(LEANBACK_BASE); - - assertTrue("电视端必须有一个统一的内容铺满窗口入口", - source.contains("private void bindFullWindowContent()")); - assertTrue("setContentView 之后必须立即调用该入口", - source.contains("setContentView(content);\n bindFullWindowContent();")); - assertTrue("必须显式关闭 decor 的 fitsSystemWindows 布局(否则系统仍会把内容压进安全区)", - source.contains("WindowCompat.setDecorFitsSystemWindows(getWindow(), false)")); - assertTrue("必须在 DecorView 上只消费 inset,不再向下分发", - source.contains("ViewCompat.setOnApplyWindowInsetsListener(getWindow().getDecorView()") - && source.contains("WindowInsetsCompat.CONSUMED")); - assertTrue("内层 android:id/content 不能再自行应用一次内边距", - source.contains("decorContent.setFitsSystemWindows(false)")); - } - - @Test - public void theInsetConsumerNeverTurnsInsetsIntoPadding() throws Exception { - String source = sourceOfMethod(read(LEANBACK_BASE), "bindFullWindowContent"); - - // 正向锚点:方法体必须真的被切出来,否则下面两条否定断言会对着空串空转。 - assertTrue("必须能定位到 bindFullWindowContent 的方法体", - source.contains("void bindFullWindowContent()") - && source.contains("setDecorFitsSystemWindows")); - assertFalse("消费 inset 后不允许再用 bottom inset 设置 padding(那会把黑带换个形式留下)", - source.contains("getSystemWindowInsetBottom") - || source.contains("Type.systemBars()") - || source.contains("setPadding(")); - } - - @Test - public void mobileKeepsItsSystemBarInsets() throws Exception { - String source = read(MOBILE_BASE); - - assertFalse("手机端不能被改成消费 inset:状态栏/导航栏区域会盖住内容", - source.contains("WindowInsetsCompat.CONSUMED")); - assertFalse("手机端不能复用电视端的铺满窗口入口", - source.contains("bindFullWindowContent")); - } - - private static String sourceOfMethod(String source, String name) { - // 必须从方法声明起切,不能从调用点起:否则先撞上 javadoc 里的 {@link ...} 花括号, - // 返回的是一段注释片段,否定断言就永远对着空串空转。 - int start = source.indexOf("void " + name + "("); - if (start < 0) return ""; - int brace = source.indexOf('{', start); - if (brace < 0) return ""; - int depth = 0; - for (int i = brace; i < source.length(); i++) { - char c = source.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return source.substring(start, i + 1); - } - return source.substring(start); - } - - private static String read(String path) throws Exception { - Path root = Files.exists(Path.of(path)) ? Path.of(".") : Path.of(".."); - // 仓库里的 Java 源码是 CRLF,多行断言必须先归一化,否则永远匹配不上。 - return new String(Files.readAllBytes(root.resolve(path)), StandardCharsets.UTF_8) - .replace("\r\n", "\n"); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvErrorStateContractTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvErrorStateContractTest.java deleted file mode 100644 index bb12f8c378..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvErrorStateContractTest.java +++ /dev/null @@ -1,380 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * 电视端「错误态」的规格契约。 - * - *

依据是本仓库自己的设计文档 §9.9:「错误态:错误色 + 原因 + 重试动作」。 - * - *

改动前 TV 上没有任何错误态:配置加载失败只弹一个会消失的吐司(`Notify.show`), - * 然后 `showContent()` 留一个没有任何内容源的空首页——用户既不知道原因,也没有重试入口; - * 设置页则把 `error_config_get` 写进普通的取值 TextView,与正常值**颜色完全相同**。 - * - *

本测试同时把该角色在真机上实测的对比度钉住(见 {@link #MEASURED_*})。数字来自 - * 192.168.50.3:5561(1920×1080 / 280dpi)的壁纸与行色带取样,取的是**最不利**样本, - * 而不是平均值——平均值会掩盖最亮处的问题。 - */ -public class TvErrorStateContractTest { - - private static final String TOKENS = "app/src/main/res/values/webhtv_tokens.xml"; - private static final String TOKENS_NIGHT = "app/src/main/res/values-night/webhtv_tokens.xml"; - private static final String ATTRS = "app/src/main/res/values/webhtv_attrs.xml"; - private static final String STYLES = "app/src/main/res/values/webhtv_styles.xml"; - private static final String PROGRESS_LAYOUT = - "app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java"; - private static final String HOME = "app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"; - private static final String ENHANCE = - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingEnhanceActivity.java"; - private static final String AD = - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java"; - private static final String KEEP = - "app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java"; - - /** 实机实测的最不利底材亮度(sRGB 相对亮度)。 */ - private static final double MEASURED_WALLPAPER_WORST = 0.2019; - private static final double MEASURED_BAND_WORST = 0.1506; - - private static final String ERROR_ROLE = "webhtv_color_on_wallpaper_error"; - private static final int ERROR_VALUE = 0xFFDAD6; - /** 错误取值的实心深色底(TV-6 底材改造)。 */ - private static final String ERROR_BG_ROLE = "webhtv_color_error_value_bg"; - private static final int ERROR_BG_VALUE = 0x4A3438; - - // ------------------------------------------------------------------ 角色本身 - - @Test - public void errorRoleIsPaletteIndependentAndSharedByBothTables() throws Exception { - assertEquals("错误前景色在两个调色板里必须同值(壁纸在两种模式下都是深色)", - tokenValue(TOKENS), tokenValue(TOKENS_NIGHT)); - assertEquals("取值必须与注释记录的 #FFDAD6 一致", ERROR_VALUE, tokenValue(TOKENS)); - } - - @Test - public void errorRoleIsWiredAsItsOwnAttributeNotBorrowingColorError() throws Exception { - assertTrue("必须声明 webhtvColorOnWallpaperError", - read(ATTRS).contains("webhtvColorOnWallpaperError")); - assertTrue("必须在 Theme.WebHTV 里接线", - read(STYLES).contains("webhtvColorOnWallpaperError\">@color/" + ERROR_ROLE)); - // 关键点:不能用跟随调色板的 colorError——浅色表的深红压在深色壁纸上不可读。 - for (String source : new String[]{read(ATTRS), read(STYLES)}) { - assertFalse("错误前景色不得复用 colorError(它是跟随调色板的角色)", - source.contains("webhtvColorOnWallpaperError\">?attr/colorError")); - } - } - - /** - * 稳定满足的是**大字号**门槛:错误态标题按 §6.2 属 Display 档,门槛 3:1。 - */ - @Test - public void errorRoleMeetsTheLargeTextFloorOnEveryMeasuredSubstrate() { - double luminance = luminance(ERROR_VALUE); - double onBand = contrast(luminance, MEASURED_BAND_WORST); - double onWallpaper = contrast(luminance, MEASURED_WALLPAPER_WORST); - assertTrue("对行色带最亮处应满足大字号 3:1,实测 " + round(onBand), onBand >= 3.0); - assertTrue("对壁纸最亮处应满足大字号 3:1,实测 " + round(onWallpaper), onWallpaper >= 3.0); - } - - /** - * 如实钉住**正文**对比度,包括那个达不到 AA 的缺口。 - * - *

壁纸/色带是中亮度(L≈0.13–0.15),任何带明显红相的前景色要达到正文门槛 4.5:1 - * 就必须接近纯白(L≥0.86),那时"错误色"在观感上已与普通白字无异、失去语义。 - * 所以当前取值停在 4.05–4.50:1。这里断言的是"不许再低",而不是"已经达标"—— - * 后者是假的,写进去只会掩盖问题。 - */ - @Test - public void errorRoleKeepsItsDocumentedBodyContrastFloor() { - double luminance = luminance(ERROR_VALUE); - double onBand = contrast(luminance, MEASURED_BAND_WORST); - assertTrue("正文对比度不得低于已记录的 4.0:1(当前实测 " + round(onBand) - + ",正文门槛 4.5:1,缺口与替代方案见 tokens 注释与任务文档 §7)", onBand >= 4.0); - } - - // ------------------------------------------------------------------ 错误态本身 - - @Test - public void progressLayoutExposesAnErrorStateWithReasonAndRetry() throws Exception { - String source = read(PROGRESS_LAYOUT); - assertTrue("状态机必须有 ERROR", source.contains("ERROR")); - assertTrue("必须提供 showError", source.contains("public void showError(")); - assertTrue("错误标题必须用错误角色", source.contains("R.attr.webhtvColorOnWallpaperError")); - assertTrue("错误态必须隐藏空态插画(空箱子表达的是'没有内容',用在错误上会误导)", - source.contains("R.id.illustration")); - assertTrue("重试再次失败要能刷新原因文案", - source.contains("if (mState == State.ERROR) applyErrorContent();")); - } - - @Test - public void emptyStateLayoutsExposeTheIllustrationSlotForTheErrorState() throws Exception { - for (String layout : new String[]{ - "app/src/leanback/res/layout/view_empty.xml", - "app/src/mobile/res/layout/view_empty.xml"}) { - assertTrue(layout + " 必须给插画加 @+id/illustration(错误态要隐藏它)", - read(layout).contains("@+id/illustration")); - } - } - - /** - * 首页配置加载失败必须进入错误态,而不是只弹吐司后留一个空页面。 - */ - @Test - public void homeConfigFailureEntersTheErrorStateWithAWorkingRetry() throws Exception { - String source = read(HOME); - assertTrue("失败回调必须进入错误态", source.contains("showConfigError(msg)")); - assertTrue("错误态标题必须是 error_config_load", - source.contains("R.string.error_config_load")); - assertTrue("必须有重试按钮文案", source.contains("R.string.error_retry")); - assertTrue("重试必须真的重新加载配置", source.contains("initConfig();")); - // 吐司是既有行为,不删。 - assertTrue("应保留原有的吐司提示", source.contains("Notify.show(msg);")); - // 失败后不能再走 showContent(),否则又回到"空页面"。 - int handler = source.indexOf("public void error(String msg)"); - assertTrue("必须存在配置失败回调", handler > 0); - // 注释里会解释"改动前是 showContent()",所以先剥掉注释再查, - // 否则负向断言会命中说明文字(本仓库已踩过两次同类坑)。 - String body = withoutJavaComments(source.substring(handler, source.indexOf("};", handler))); - assertFalse("失败回调不得再调用 showContent()", body.contains("showContent()")); - } - - /** - * 「还没配接口」必须与「加载失败」分开:前者不是失败。 - * - *

重置应用、重装或恢复出厂后的首次启动就是这个状态。改动前 {@code BaseConfig} 把它 - * 当成 {@code error("")} 抛出来,首页显示「内容源加载失败 / 重试」——一个不存在的故障 - * 加一个点了永远没用的按钮(用户实机截图确认:只有标题与重试,没有原因行)。 - */ - @Test - public void missingConfigIsNotTreatedAsAFailure() throws Exception { - String base = read("app/src/main/java/com/fongmi/android/tv/api/config/BaseConfig.java"); - assertFalse("BaseConfig 不得再把'没配地址'当成 error(\"\")", base.contains("callback.error(\"\")")); - assertTrue("没有配置地址必须走 callback.noConfig()", base.contains("callback::noConfig")); - assertTrue("Callback 必须提供 noConfig 钩子", - read("app/src/main/java/com/fongmi/android/tv/impl/Callback.java").contains("public void noConfig()")); - - String home = read(HOME); - assertTrue("HomeActivity 必须实现 noConfig 回调", home.contains("public void noConfig()")); - assertTrue("未配置必须走 noConfig 分支", home.contains("showNoConfig()")); - String showNoConfig = methodBody(home, "private void showNoConfig()"); - // 未配置 = 正常首页:点播/直播/设置这一行是首页列表的第一行,任何铺满首页的状态浮层 - // 都会把它整块隐藏(ProgressLayout 把内容视图置为 INVISIBLE),新装用户就只剩一个按钮。 - assertTrue("未配置必须回到内容态(导航可用)", showNoConfig.contains("showContent()")); - assertFalse("未配置不得再用全屏空态接管首页", showNoConfig.contains("showEmpty()")); - assertFalse("未配置不得调用 showError", showNoConfig.contains("showError(")); - // 导航行只由既有按钮构成:未配置时不再额外插入任何入口。 - String setFunc = methodBody(home, "private void setFunc()"); - assertFalse("未配置不得在导航行里插入额外按钮", setFunc.contains("home_no_config")); - assertFalse("未配置态的主动作不得是重试", showNoConfig.contains("error_retry")); - } - - /** - * 配置在**别的路径**上变得可用后,首页不能继续顶着状态卡。 - * - *

用户反馈:"卸载后重新安装再通过备份恢复也会出现类似情况,必现;杀进程后重进才行"。 - * 恢复备份走的是 {@code RefreshEvent.home()} 而不是重新走首页启动({@code showContent()}), - * 而状态浮层只被启动成功与点重试收掉——于是配置明明已经可用,首页仍停在状态卡上。 - */ - @Test - public void aConfiguredSourceDismissesAStaleStateOverlay() throws Exception { - String home = read(HOME); - String handler = methodBody(home, "public void onRefreshEvent(RefreshEvent event)"); - assertTrue("HOME 刷新时必须检查并收起过期的状态卡", handler.contains("dismissStateIfConfigured()")); - String dismiss = methodBody(home, "private void dismissStateIfConfigured()"); - assertTrue("只有配置真的可用(有站点)时才收起", dismiss.contains("getSites().isEmpty()")); - assertTrue("收起动作必须回到内容态", dismiss.contains("showContent()")); - // 只能收「空态 / 错误态」:加载中(PROGRESS)与内容态都不得被这条顺手收掉, - // 否则 HOME 刷新会把还在转圈的首页直接变成空白。 - assertTrue("必须能识别空态", dismiss.contains("isEmpty()")); - assertTrue("必须能识别错误态", dismiss.contains("isError()")); - } - - /** - * 未配置时宿主不得停在加载中:{@code Callback.noConfig()} 的默认实现必须保留改动前的收尾行为。 - * - *

{@code noConfig()} 是本次新增的**终态**分支,而既有宿主是拿 {@code start()} 与 - * {@code error(String)} 当「加载开始 / 加载结束」的一对在用。默认留空实现,所有没来得及 - * 覆写它的宿主在无配置时就会永远停在加载中。实测可达路径:TV 标题长按走 - * {@code reloadConfig()},它先 {@code progressLayout.showProgress()},加载失败后只有 - * {@code error("")} 能收掉转圈(空消息在 {@code Notify.show} 里直接返回,不会弹空吐司)。 - */ - @Test - public void defaultNoConfigStillTerminatesTheLoadingState() throws Exception { - String callback = read("app/src/main/java/com/fongmi/android/tv/impl/Callback.java"); - String body = methodBody(callback, "public void noConfig()"); - assertTrue("默认实现必须结束加载态(转调 error(\"\")),否则既有宿主会永久停在加载中", - body.contains("error(\"\")")); - // 可达性证据:reloadConfig() 先进加载态,且它的匿名回调只覆写了 error 作为收尾。 - String reload = methodBody(read(HOME), "public void reloadConfig()"); - assertTrue("reloadConfig 必须先进加载态", reload.contains("progressLayout.showProgress()")); - assertTrue("该回调以 error(String) 作为收尾", reload.contains("public void error(String msg)")); - } - - /** - * 设置页的内联错误必须与正常取值**可区分**,且失败过的行在后续刷新成功时能复位。 - */ - /** - * 底材改造(TV-6):错误取值换上一块**实心深底**后,正文必须稳过 4.5:1。 - * - *

这是 TV-5 记录的那个缺口的收口方式——缺口在壁纸/色带这种中亮度底材上无法靠调色 - * 解决(要达标就得接近纯白,错误色随之失去语义),只能改底材。 - */ - @Test - public void errorValueSubstrateGivesTheBodyTextARealMargin() throws Exception { - for (String table : new String[]{TOKENS, TOKENS_NIGHT}) { - Matcher matcher = Pattern.compile("#([0-9A-Fa-f]{6})") - .matcher(read(table)); - assertTrue(table + " 必须声明 " + ERROR_BG_ROLE, matcher.find()); - assertEquals("错误底材必须与明暗无关(它是实心色,不跟随调色板)", - ERROR_BG_VALUE, Integer.parseInt(matcher.group(1), 16)); - } - double onChip = contrast(luminance(ERROR_VALUE), luminance(ERROR_BG_VALUE)); - assertTrue("错误正文在实心底上必须稳过正文门槛 4.5:1,实测 " + round(onChip), onChip >= 4.5); - } - - @Test - public void settingsInlineErrorsUseTheErrorRoleAndRecoverOnSuccess() throws Exception { - for (String path : new String[]{ENHANCE, AD}) { - String source = read(path); - assertTrue(path + " 的 setError 必须使用错误角色", - source.contains("R.attr.webhtvColorOnWallpaperError")); - assertTrue(path + " 的 setError 必须换上错误底材", - source.contains("setBackgroundResource(R.drawable.shape_error_value)")); - assertTrue(path + " 必须把成功路径复位到常规壁纸前景色", - source.contains("R.attr.webhtvColorOnWallpaper)")); - // 复位必须发生在写入摘要的同一条路径上,否则某行失败一次后会一直是红的。 - assertTrue(path + " 的成功路径必须走 setSummary", source.contains("setSummary(view, supplier.get())")); - int reset = source.indexOf("private void setSummary("); - assertTrue(path + " 必须有 setSummary 实现", reset > 0); - String impl = source.substring(reset, source.indexOf("}", reset)); - assertTrue(path + " 的 setSummary 必须复位颜色", - impl.contains("setTextColor(themeColor(R.attr.webhtvColorOnWallpaper))")); - assertTrue(path + " 的 setSummary 必须撤掉错误底材(错误态是临时的)", - impl.contains("setBackground(null)")); - } - } - - /** - * 收藏页点击条目时的配置加载失败必须进入错误态并可重试。 - * - *

TV-5 的文档 §7.3 原本把"其余宿主"泛泛列成历史/收藏/文件三处;本批实际侦察后发现 - * **历史与文件的加载没有失败分支**(本地读取不会抛错),搜索/采集也只有提示性吐司—— - * 唯一真实的页面级远端失败是收藏页的 `loadConfig`。所以这里只钉这一处, - * 不为了凑数量去臆造错误态。 - */ - @Test - public void favoritesConfigLoadFailureEntersTheErrorStateWithARetry() throws Exception { - String source = read(KEEP); - int handler = source.indexOf("public void error(String msg)"); - assertTrue("收藏页必须有配置加载失败回调", handler > 0); - String body = withoutJavaComments(source.substring(handler, source.indexOf("});", handler))); - assertTrue("失败必须进入错误态", body.contains("showError(")); - assertTrue("错误态标题必须是 error_config_load", body.contains("R.string.error_config_load")); - assertTrue("必须有重试按钮文案", body.contains("R.string.error_retry")); - assertTrue("重试必须复用同一条加载路径", body.contains("loadConfig(config, item);")); - assertTrue("应保留原有的吐司提示", body.contains("Notify.show(msg);")); - } - - /** - * `setSummary` 会用 `setBackground(null)` 撤掉错误底材——前提是这些取值 TextView - * **原本就没有背景**。一旦有人给其中任何一个加上背景,复位就会把它一并抹掉。 - * 这里把前提钉住,让那种改动在这里先失败,而不是变成"设置页某个取值突然没底了"。 - */ - @Test - public void summaryTextViewsHaveNoBackgroundSoClearingTheErrorSubstrateIsSafe() throws Exception { - for (String layout : new String[]{ - "app/src/leanback/res/layout/activity_setting_enhance.xml", - "app/src/leanback/res/layout/activity_setting_ad.xml"}) { - String source = read(layout); - Matcher ids = Pattern.compile("@\\+id/(\\w*[Tt]ext\\w*)\"").matcher(source); - int checked = 0; - while (ids.find()) { - String id = ids.group(1); - String block = tagBlockAfter(source, ids.end()); - assertFalse(layout + " 的 " + id - + " 在布局里带了背景:setSummary 的 setBackground(null) 会把它抹掉", - block.contains("android:background=")); - checked++; - } - assertTrue(layout + " 应至少含一个取值 TextView", checked > 0); - } - } - - // ------------------------------------------------------------------ helpers - - /** 取某个 id 匹配位置之后、当前标签结束({@code />} 或 {@code >})之前的属性文本。 */ - private static String tagBlockAfter(String source, int from) { - int end = source.length(); - int selfClosing = source.indexOf("/>", from); - if (selfClosing >= 0) end = Math.min(end, selfClosing); - int closing = source.indexOf(">", from); - if (closing >= 0) end = Math.min(end, closing); - return source.substring(from, end); - } - - private static int tokenValue(String path) throws Exception { - Matcher matcher = Pattern.compile("#([0-9A-Fa-f]{6})") - .matcher(read(path)); - assertTrue(path + " 必须声明 " + ERROR_ROLE, matcher.find()); - return Integer.parseInt(matcher.group(1), 16); - } - - /** sRGB 相对亮度,与 WCAG 2.2 定义一致。 */ - private static double luminance(int rgb) { - double r = channel((rgb >> 16) & 0xFF); - double g = channel((rgb >> 8) & 0xFF); - double b = channel(rgb & 0xFF); - return 0.2126 * r + 0.7152 * g + 0.0722 * b; - } - - private static double channel(int value) { - double v = value / 255.0; - return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); - } - - private static double contrast(double first, double second) { - double hi = Math.max(first, second); - double lo = Math.min(first, second); - return (hi + 0.05) / (lo + 0.05); - } - - private static String round(double value) { - return String.format(java.util.Locale.ROOT, "%.2f:1", value); - } - - /** 剥掉 Java 的行注释与块注释,避免负向断言命中说明文字。 */ - private static String withoutJavaComments(String source) { - return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//.*$", " "); - } - - /** 取一个方法的完整方法体(含签名),用花括号配平切分。 */ - private static String methodBody(String source, String signature) { - String cleaned = withoutJavaComments(source); - int start = cleaned.indexOf(signature); - assertTrue("必须存在方法 " + signature, start > 0); - int open = cleaned.indexOf('{', start); - assertTrue("方法签名后必须有方法体", open > 0); - int depth = 0; - for (int i = open; i < cleaned.length(); i++) { - char c = cleaned.charAt(i); - if (c == '{') depth++; - else if (c == '}' && --depth == 0) return cleaned.substring(start, i + 1); - } - return cleaned.substring(start); - } - - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - return Files.readString(Path.of("..").resolve(path), StandardCharsets.UTF_8); - } -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvFocusRingContractTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvFocusRingContractTest.java deleted file mode 100644 index 5a5bccc703..0000000000 --- a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvFocusRingContractTest.java +++ /dev/null @@ -1,1499 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -import com.fongmi.android.tv.theme.ThemeTokens; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -/** - * 电视版「焦点 = 主题色边框环」契约。 - * - * 用户报告(两张实机截图): - * 1. 关于 WebHomeTV 弹窗的 `检查更新 / 加速源 / 我已知悉` 三个按钮和右下角齿轮图标, - * D-pad 焦点移上去基本看不出来; - * 2. 追更页顶栏 `检查更新/全部已读/显示全部/导入订阅` 与卡片内 - * `继续看/检查/标记已读/提醒开/换源/取消追更` 同样看不出焦点位置。 - * - * 根因是"焦点态只改填充色,而填充色本身就是主题色": - * `ThemeTokens` 冻结色板里 `colorFocus == colorPrimary`(light `#0B57D0` / dark `#A8C7FA`), - * 于是 `dialog_primary_button_bg.xml` 的 focused 与 default 解析结果字节相同, - * `following_button_primary_bg.xml` 的 focused(`webhtv_color_focus`) 与 - * default(`following_accent` = `webhtv_color_primary`) 在 TV 上也字节相同。 - * - * 2026-10-19 第二轮(用户反馈"高亮边框颜色和按钮本身的颜色太接近"):只补环仍然不够。 - * 环色对浅色填充确实有 7.50:1,但环的**外沿**贴在弹窗面板上只有 1.05:1(夜间)/ - * 1.22:1(浅色),看上去是按钮自带的深边而不是焦点高亮;而"既要对浅色填充 ≥3:1、 - * 又要对深色面板 ≥3:1"的环色取值空间是**空集**。所以修法是让**聚焦态填充与默认填充 - * 不同**:聚焦时填充翻成深色 `primaryContainer`,环取配对的浅色 `onPrimaryContainer`, - * 两个方向同时成立,焦点因此有「填充翻转」+「浅色环」两个独立线索。 - * 主按钮专用环色见 `color/focus_ring_primary_container.xml`。 - * - * 本测试把三条要求固化成契约: - * - R1 自带主题色填充的控件,聚焦时必须出现边框环(不能只靠填充变化); - * - R2 电视版只允许一套机制、一个宽度来源; - * - R3 焦点环色必须受主题色板控制(FOCUS 用户槽 / 填充的配对 on-色),不得写死。 - * - R4 主按钮聚焦态填充必须与默认填充可分辨(不能只靠环这一个线索)。 - */ -public class TvFocusRingContractTest { - - private static final String COLORS = "app/src/main/res/values/colors.xml"; - private static final String DIMENS = "app/src/main/res/values/webhtv_dimens.xml"; - private static final String ABOUT_LAYOUT = "app/src/main/res/layout/dialog_about.xml"; - private static final String ABOUT_ICON = "app/src/main/res/drawable/about_primary_icon_button.xml"; - private static final String FOLLOWING_ITEM = "app/src/main/res/layout/item_following.xml"; - private static final String FOLLOWING_LEANBACK = "app/src/leanback/res/layout/activity_following.xml"; - - private static final String RING_PRIMARY = "app/src/main/res/color/focus_ring_primary.xml"; - private static final String RING_PRIMARY_CONTAINER = "app/src/main/res/color/focus_ring_primary_container.xml"; - private static final String RING_SECONDARY = "app/src/main/res/color/focus_ring_secondary.xml"; - private static final String RING_ERROR = "app/src/main/res/color/focus_ring_error.xml"; - - private static final String PRIMARY_BG = "app/src/main/res/color/dialog_primary_button_bg.xml"; - private static final String PRIMARY_TEXT = "app/src/main/res/color/dialog_primary_button_text.xml"; - private static final String VERSION_ITEM = "app/src/main/res/drawable/selector_update_version_item.xml"; - - private static final String WIDTH = "@dimen/webhtv_focus_ring_width"; - - private static final String TOKENS_LIGHT = "app/src/main/res/values/webhtv_tokens.xml"; - private static final String TOKENS_NIGHT = "app/src/main/res/values-night/webhtv_tokens.xml"; - - private static final String LEANBACK_DRAWABLE = "app/src/leanback/res/drawable/"; - - private static final String UPDATE_LEANBACK = "app/src/leanback/res/layout/dialog_update.xml"; - private static final String UPDATE_MOBILE = "app/src/mobile/res/layout/dialog_update.xml"; - private static final String ABOUT_ICON_TINT = "app/src/main/res/color/about_primary_icon_tint.xml"; - - // ------------------------------------------------------------ R3 主题控制 - - @Test - public void focusRingColourIsThemeControlledNotHardCoded() throws Exception { - String colors = read(COLORS); - assertTrue("tvFocusRing 的取值必须接到主题 FOCUS 用户槽,不能再写死十六进制", - colors.contains("@color/webhtv_color_focus")); - assertFalse("tv_item_focus_ring 不允许再出现硬编码黄色", - colors.contains("#FFD166")); - - // 自带主题色填充的控件,环色必须是该填充的配对 on-色:这些角色由主题解析器 - // 从用户 seed 重新生成并做过对比度校验,所以换任何主题环都仍然可见。 - assertTrue("primary 填充的环色必须取配对 on-色", - colors.contains("@color/webhtv_color_on_primary")); - assertTrue("secondary container 填充的环色必须取配对 on-色", - colors.contains("@color/webhtv_color_on_secondary_container")); - assertTrue("error container 填充的环色必须取配对 on-色", - colors.contains("@color/webhtv_color_on_error_container")); - assertFalse("不得再用 on_error 去配 error container 填充(两者仅 1.40:1,环看不见)", - colors.contains("")); - } - - /** - * 环色只能配对**焦点态实际填充色**。这是实机验收暴露出的一类真缺陷: - * `following_button_*_bg` 聚焦时填充会变成 FOCUS 色(冻结色板里 == primary), - * 若环仍取 `on_secondary_container`,环与填充只有 1.33:1,焦点依旧看不见; - * 同理 `?attr/colorErrorContainer` 填充配 `on_error` 只有 1.40:1。 - * - *

逐对计算对比度,取 light / night / leanback 三套生效 token 各验一遍, - * 避免以后换色板或换环色时静默退化到“环贴在近似色上”。门槛取 WCAG 2.2 - * SC 1.4.11 非文本对比度的 3:1。 - */ - @Test - public void everyRingColourKeepsNonTextContrastAgainstItsFocusFill() throws Exception { - // {说明, 焦点态填充 token, 环色别名} - String[][] pairs = { - {"primary / 焦点填充", "webhtv_color_primary", "focus_ring_on_primary"}, - {"FOCUS 填充", "webhtv_color_focus", "focus_ring_on_primary"}, - // 主按钮聚焦态填充翻成 primary_container,环随之取 on_primary_container。 - // 这一对必须同时满足"对填充 ≥3:1"和"对外层面板 ≥3:1",否则焦点环会被 - // 看成按钮自带的深边(旧 on_primary 环对外层面板只有 1.05:1,即用户报告)。 - {"primary container 填充", "webhtv_color_primary_container", "focus_ring_on_primary_container"}, - {"secondary container 填充", "webhtv_color_secondary_container", "focus_ring_on_secondary_container"}, - {"error container 填充", "webhtv_color_error_container", "focus_ring_on_error_container"}, - }; - String colors = read(COLORS); - // TV 不再有独立的 leanback 色板,day/night 两张表就是全部生效表。 - for (String tokens : new String[]{TOKENS_LIGHT, TOKENS_NIGHT}) { - for (String[] pair : pairs) { - String fill = tokenHex(read(tokens), pair[1]); - String alias = aliasTarget(colors, pair[2]); - assertTrue(pair[2] + " 必须指向一个主题 token 而不是写死十六进制", alias.startsWith("@color/")); - String ring = tokenHex(read(tokens), alias.substring("@color/".length())); - double ratio = contrast(fill, ring); - assertTrue(tokens + " 中 " + pair[0] + " 的环色 " + ring + " 与填充 " + fill - + " 只有 " + String.format("%.2f", ratio) + ":1,低于 3:1,焦点会看不见", - ratio >= 3.0); - } - } - } - - /** - * 不变量:焦点环的**外沿**必须与它实际贴着的弹窗面板形成 ≥3:1。 - * - *

这是用户报告的直接回归门。旧方案让主按钮聚焦时填充保持浅色 primary、 - * 环取深色 on_primary:环对填充有 7.50:1,但环的外沿贴在面板 - * surface_container_high 上只有 1.05:1(夜间)/ 1.22:1(浅色),看上去 - * 就是按钮自带的深边而不是焦点高亮 —— 即「高亮边框颜色和按钮本身的颜色 - * 太接近」。 - * - *

只对「聚焦填充 = *container」的环生效,因为只有这些环的外沿落在弹窗 - * 面板上。默认填充 primary 的那一行被 - * {@link #noSingleRingColourCanSatisfyBothTheLightFillAndTheDarkPanel()} - * 证明为不可满足,所以主按钮的聚焦填充必须翻成 container - * (见 color/dialog_primary_button_bg.xml)。 - */ - @Test - public void everyRingThatSitsOnADialogPanelClearsItAgainstThatPanel() throws Exception { - // {说明, 焦点态填充 token, 环色别名} - String[][] pairs = { - {"primary container 填充", "webhtv_color_primary_container", "focus_ring_on_primary_container"}, - {"secondary container 填充", "webhtv_color_secondary_container", "focus_ring_on_secondary_container"}, - {"error container 填充", "webhtv_color_error_container", "focus_ring_on_error_container"}, - }; - // 弹窗面板的三个实际取值都必须满足,不能只验最有利的那一个。 - String[] panels = { - "webhtv_color_surface_container", - "webhtv_color_surface_container_high", - "webhtv_color_surface_container_highest", - }; - String colors = read(COLORS); - for (String tokens : new String[]{TOKENS_LIGHT, TOKENS_NIGHT}) { - String body = read(tokens); - for (String[] pair : pairs) { - String alias = aliasTarget(colors, pair[2]).substring("@color/".length()); - String ring = tokenHex(body, alias); - for (String panelToken : panels) { - String panel = tokenHex(body, panelToken); - double ratio = contrast(ring, panel); - assertTrue(tokens + " 中 " + pair[0] + " 的环色 " + ring + " 贴在面板 " - + panel + " 上只有 " + String.format("%.2f", ratio) - + ":1,低于 3:1,环会被看成按钮自带的深边", - ratio >= 3.0); - } - } - } - } - - /** - * 回归钉:证明「保持聚焦填充 = 默认填充、只靠环」在本色板下不可实现。 - * - *

环必须同时对浅色默认填充和深色弹窗面板 ≥3:1。把 WCAG 相对亮度记作 L, - * 两个约束分别给出环亮度的上界 {@code (L_fill+0.05)/3-0.05} 与下界 - * {@code 3*(L_panel+0.05)-0.05}。当前色板下上界 < 下界,取值空间是空集 —— - * 所以主按钮的聚焦填充**必须**与默认填充不同。这不是审美取舍,而是硬约束; - * 本测试把这条推理固化下来,避免以后有人"简化"回单线索方案。 - * - *

反向断言:若将来换色板后区间不再为空,本测试会失败并提示可以重新评估 - * 是否还需要翻转填充 —— 那时这是一个真实的设计选择,而不是被忽略的缺陷。 - */ - @Test - public void noSingleRingColourCanSatisfyBothTheLightFillAndTheDarkPanel() throws Exception { - for (String tokens : new String[]{TOKENS_LIGHT, TOKENS_NIGHT}) { - String body = read(tokens); - double fill = luminance(tokenHex(body, "webhtv_color_primary")); - double panel = luminance(tokenHex(body, "webhtv_color_surface_container_high")); - double maxDark = (fill + 0.05) / 3.0 - 0.05; // 对填充 ≥3:1 的环亮度上界 - double minLight = 3.0 * (panel + 0.05) - 0.05; // 对面板 ≥3:1 的环亮度下界 - assertTrue(tokens + ":默认填充 L=" + String.format("%.4f", fill) + " 与面板 L=" - + String.format("%.4f", panel) + " 之间居然存在可用的单一环色" - + "(上界 " + String.format("%.4f", maxDark) + " ≥ 下界 " - + String.format("%.4f", minLight) + ")。" - + "此时可以重新评估是否还需要翻转聚焦填充。", - minLight > maxDark); - } - } - - @Test - public void everyRingColourListResolvesThroughThemeTokens() throws Exception { - String[][] cases = { - {RING_PRIMARY, "focus_ring_on_primary"}, - {RING_PRIMARY_CONTAINER, "focus_ring_on_primary_container"}, - {RING_SECONDARY, "focus_ring_on_secondary_container"}, - {RING_ERROR, "focus_ring_on_error_container"}, - }; - for (String[] item : cases) { - String body = values(read(item[0])); - // 属性顺序不属于语义差异,按状态+取值分别断言。 - assertTrue(item[0] + " 聚焦态必须使用主题环色 " + item[1], - body.contains("android:state_focused=\"true\"") - && body.contains("android:color=\"@color/" + item[1] + "\"")); - assertTrue(item[0] + " 按下态必须使用主题环色", - body.contains("android:state_pressed=\"true\"") - && body.contains("android:color=\"@color/" + item[1] + "\"")); - // 全透明擦除态:strokeWidth 是常量,只切换颜色值可避免任何布局变化。 - assertTrue(item[0] + " 非聚焦态必须是全透明而不是移除描边", - body.contains("android:color=\"@android:color/transparent\"")); - // 环色只允许来自主题 token,不允许写死十六进制。 - assertFalse(item[0] + " 不允许出现硬编码环色", body.matches(".*android:color=\"#.*")); - } - } - - // ------------------------------------------------------------ R2 应用表面唯一宽度 - - /** - * 用户报告(两张实机截图):同一个电视首页上,「搜索/历史」两行功能按钮是**蓝色**边框, - * 「最近观看」内容卡片是**白色**边框,且两边粗细不同。 - * - *

根因是应用表面的焦点环分裂成两族: - *

    - *
  • {@code shape_item_focused} / {@code shape_item_round_focused} 写 - * {@code 1.5dp ?attr/colorPrimary}(蓝);
  • - *
  • {@code shape_vod_focused} / {@code shape_vod_oval_focused} / {@code shape_keyboard_focused} / - * {@code shape_search_hot_word_focused} / {@code shape_chip_*_focused} 写 - * {@code 1.5dp/2dp @color/white}(白,完全不随主题走);
  • - *
  • {@code shape_config_history_item_focused} / {@code shape_site_item_*} / - * {@code shape_group_button_focused} 写 {@code 2dp}。
  • - *
- * - *

本测试把这些应用表面(宿主为调色板表面或对话框面板)的焦点环钉死到统一取值: - * 宽度必须引用 {@code @dimen/webhtv_focus_ring_width},环色必须走 {@code ?attr/tvFocusRing} - * 或它配对的主题角色({@code ?attr/colorOnPrimary} / {@code ?attr/colorPrimary} / - * {@code ?attr/colorPrimaryContainer}),不得再写死 {@code @color/white} 或 {@code #FFFFFF}。 - * - *

视频层刻意不在本约束内:{@code shape_chip_focused}(播放页解析线路)、 - * {@code shape_chip_round_focused}(直播源弹窗)、{@code shape_video_focused}(画面框)、 - * {@code shape_subtitle_*_focused}(字幕工具栏)、{@code shape_live_focused}(直播抽屉)、 - * {@code selector_control_sheet_button} 与 {@code selector_exit_confirm_*} - * 叠在视频画面或固定明暗的玻璃面板上,改走调色板会让浅色表的深蓝环贴在深底上(实测 - * 1.91:1)或在夜间表的浅底上消失。它们只统一宽度,环色保持与调色板无关。 - */ - @Test - public void appSurfaceFocusRingsShareOneWidthTokenAndAThemeColour() throws Exception { - // 应用表面焦点环:宿主是调色板表面/对话框面板,环色必须跟随主题。 - String[][] themed = { - {"shape_item_focused.xml", "?attr/tvFocusRing"}, - {"shape_item_round_focused.xml", "?attr/tvFocusRing"}, - {"shape_item_selected.xml", "?attr/tvFocusRing"}, - {"shape_vod_focused.xml", "?attr/tvFocusRing"}, - {"shape_vod_oval_focused.xml", "?attr/tvFocusRing"}, - {"shape_keyboard_focused.xml", "?attr/tvFocusRing"}, - {"shape_search_hot_word_focused.xml", "?attr/tvFocusRing"}, - // 搜索框与空态主按钮:本批新纳入 ThemeBinder 应用表面白名单的两项,必须同样钉住 - // 「唯一宽度 token + ?attr/tvFocusRing」,否则白名单成员与色板契约会静默脱钩。 - {"shape_tv_search_field_focused.xml", "?attr/tvFocusRing"}, - {"shape_empty_action_focused.xml", "?attr/tvFocusRing"}, - // 自带主题色填充的控件:环色取焦点态填充的配对角色,同样由主题解析器保证对比度。 - {"shape_config_history_item_focused.xml", "?attr/colorOnPrimary"}, - {"shape_site_item_focused.xml", "?attr/colorOnPrimary"}, - {"shape_site_item_selected.xml", "?attr/colorPrimary"}, - {"shape_group_button_focused.xml", "?attr/colorOnPrimary"}, - }; - for (String[] entry : themed) { - String path = LEANBACK_DRAWABLE + entry[0]; - String body = values(read(path)); - assertTrue(path + " 的焦点环必须引用唯一宽度 token " + WIDTH, - body.contains("android:width=\"" + WIDTH + "\"")); - assertTrue(path + " 的环色必须取主题角色 " + entry[1] + ",不能写死", - body.contains("android:color=\"" + entry[1] + "\"")); - assertFalse(path + " 不允许再写死白色焦点环", - body.contains("android:color=\"@color/white\"") || body.contains("android:color=\"#FFFFFF\"")); - assertFalse(path + " 不允许再写死描边宽度(1.5dp/2dp/3dp)", - body.matches("(?s).*android:width=\"[0-9.]+dp\".*")); - } - - // 白名单成员的 selector 必须把焦点态指向上面已钉住的那个 shape:环宽与环色来自 shape, - // 若 selector 指错文件,ThemeBinder 的白名单判定仍会命中但改写的是别的图。 - String[][] selectorFocus = { - {"selector_tv_search_field.xml", "@drawable/shape_tv_search_field_focused"}, - {"selector_empty_action.xml", "@drawable/shape_empty_action_focused"}, - }; - for (String[] entry : selectorFocus) { - String path = LEANBACK_DRAWABLE + entry[0]; - String body = values(read(path)); - assertTrue(path + " 的焦点态必须指向 " + entry[1], - body.contains("android:drawable=\"" + entry[1] + "\" android:state_focused=\"true\"")); - } - - // 视频层/固定明暗宿主:只统一宽度,环色保持与调色板无关。 - String[] paletteIndependent = { - "shape_chip_focused.xml", - "shape_chip_round_focused.xml", - "shape_live_focused.xml", - "shape_video_focused.xml", - "shape_subtitle_focused.xml", - "shape_subtitle_pressed.xml", - "selector_control_sheet_button.xml", - "selector_episode_dialog_item.xml", - "selector_episode_dialog_page.xml", - "selector_danmaku_result_item.xml", - "selector_danmaku_search_action.xml", - "shape_video_item_focused.xml", - }; - for (String name : paletteIndependent) { - String path = LEANBACK_DRAWABLE + name; - String body = values(read(path)); - assertTrue(path + " 的焦点环必须引用唯一宽度 token " + WIDTH, - body.contains("android:width=\"" + WIDTH + "\"")); - // 只约束**焦点态**的描边宽度:这些文件里还带有 1dp 的常态轮廓, - // 那是「未聚焦轮廓」而不是焦点环,套用 3dp 反而会模糊常态与焦点的区分。 - java.util.List widths = focusStateStrokeWidths(body, name); - assertFalse(path + " 的焦点态描边不允许写死宽度,实际为 " + widths, - widths.stream().anyMatch(w -> !WIDTH.equals(w))); - } - } - - /** 抽出 {@code body} 里焦点态描边的宽度(含单 shape 的 focused 文件)。 */ - private static java.util.List focusStateStrokeWidths(String body, String name) { - java.util.List widths = new java.util.ArrayList<>(); - java.util.regex.Matcher item = java.util.regex.Pattern - .compile("]*state_focused=\"true\"[^>]*>(.*?)", java.util.regex.Pattern.DOTALL) - .matcher(body); - while (item.find()) { - java.util.regex.Matcher stroke = java.util.regex.Pattern - .compile("]*android:width=\"([^\"]+)\"") - .matcher(item.group(1)); - while (stroke.find()) widths.add(stroke.group(1)); - } - if (!body.contains("]*android:width=\"([^\"]+)\"") - .matcher(body); - while (stroke.find()) widths.add(stroke.group(1)); - } - return widths; - } - - /** - * 全库不变量:leanback 里**任何**焦点态描边都不得写死宽度,必须引用唯一 token。 - * - *

上一条测试是白名单式断言(只检查名单内的文件),新增文件不会被覆盖。本测试反过来 - * 扫描整个 {@code leanback/res/drawable},所以以后新增一个焦点环、或把旧的 1.5dp/2dp - * 写回来,都会直接转红——这正是用户报告「边框粗细也貌似不一样」需要被永久挡住的一类改动。 - * - *

仅两个文件豁免,且各有明确理由(豁免列表本身就是契约): - *

    - *
  • {@code selector_video_item.xml} —— 它是电视版统一焦点环规范的**样板文件** - * (其他文件都在注释里指向它),其字面量 {@code 3dp} 与 token 取值完全相同, - * 且 {@code NativeEnhancedPlaybackStyleFocusTest} 把它当作规范基准逐字断言; - * 改它只会引入无视觉收益的跨任务测试改动。
  • - *
  • {@code shape_audio_action_icon_focused.xml} —— 它不是容器焦点环,而是播放页音频 - * 按钮上带 {@code inset=3dp} 的**图标内描边环**(40dp 图标 / 17dp 圆角), - * 角色与尺寸均不同,套 3dp 会把图标糊成一团。
  • - *
- */ - @Test - public void noLeanbackFocusRingHardcodesItsWidth() throws Exception { - java.util.Set exempt = java.util.Set.of( - "shape_audio_action_icon_focused.xml"); - // 已知未收敛的 main 源集文件(2026-10-13 审计发现,见 - // docs/TV-FOCUS-RING-MAIN-SOURCE-20261013.md)。它们仍是 3dp 字面量 + 与注释公式 - // 不符的圆角,但**改宽度必须重做像素级月牙复核**(TvAppSurfaceFocusRingDeviceTest 的 - // Robolectric 栅格化扫描),属独立批次。这里显式列出而不是静默跳过: - // 一旦有人修好其中任一文件,下面的断言会失败并提醒把它从名单里删掉。 - java.util.Set knownUnconverged = java.util.Set.of( - "selector_episode_card.xml", - "selector_tmdb_cast_focus.xml", - "selector_tmdb_media_focus.xml", - "shape_episode_photo_focused.xml"); - java.util.List dirs = new java.util.ArrayList<>(); - for (String candidate : new String[]{ - "app/src/leanback/res/drawable", "app/src/main/res/drawable"}) { - java.nio.file.Path dir = java.nio.file.Path.of(candidate); - if (!java.nio.file.Files.isDirectory(dir)) dir = java.nio.file.Path.of("../" + candidate); - assertTrue(candidate + " 必须存在", java.nio.file.Files.isDirectory(dir)); - dirs.add(dir); - } - - java.util.List violations = new java.util.ArrayList<>(); - java.util.Set stillUnconverged = new java.util.TreeSet<>(); - int checked = 0; - for (java.nio.file.Path dir : dirs) { - boolean leanback = dir.toString().replace('\\', '/').contains("/leanback/"); - try (java.util.stream.Stream paths = java.nio.file.Files.list(dir)) { - for (java.nio.file.Path path : paths.sorted().toList()) { - String name = path.getFileName().toString(); - if (!name.endsWith(".xml") || exempt.contains(name)) continue; - String body = values(read(path.toString().replace('\\', '/'))); - // main 源集是两端共享的,里面绝大多数 drawable 与电视焦点环无关 - // (输入框/开关的 1.5dp 描边、手机端自己的 2dp 卡片描边等)。 - // 只收口**真正的电视焦点环**:引用 ?attr/tvFocusRing / tvCurrentRing 的那些。 - // 这正是漏掉 selector_episode_card / selector_tmdb_*_focus / - // shape_episode_photo_focused 的原因——它们在 main 里,用 3dp 字面量, - // 而同族的 leanback 文件早已改用 token。 - if (!leanback && !body.contains("?attr/tvFocusRing") && !body.contains("?attr/tvCurrentRing")) { - continue; - } - for (String width : focusStateStrokeWidths(body, name)) { - checked++; - if (WIDTH.equals(width)) continue; - if (knownUnconverged.contains(name)) { - stillUnconverged.add(name); - continue; - } - violations.add(dir.getFileName() + "/" + name + " -> " + width); - } - } - } - } - assertTrue("扫描必须真的读到焦点环,否则本测试是空断言", checked >= 15); - assertTrue("电视焦点环宽度只允许有一个来源 " + WIDTH + ",以下仍写死:" + violations, - violations.isEmpty()); - // 名单必须是「活的」:修好的文件要立刻从名单里移除,否则名单会腐烂成第二套豁免。 - assertEquals("以下文件已收敛到 token,请从 knownUnconverged 名单里删掉:" + stillUnconverged, - knownUnconverged, stillUnconverged); - } - - /** - * 回归门:同一个电视页面上的两族焦点环不得再分叉。 - * - *

首页同时渲染功能按钮行({@code adapter_func} → {@code selector_item})与内容卡片行 - * ({@code adapter_vod} → {@code selector_vod}),两者就是用户截图里「上面蓝色、下面白色」 - * 的那一对。它们的焦点态环色必须来自同一个主题属性,宽度来自同一个 token。 - */ - @Test - public void homeFunctionButtonsAndContentCardsShareTheSameRingSpec() throws Exception { - String button = values(read(LEANBACK_DRAWABLE + "shape_item_focused.xml")); - String card = values(read(LEANBACK_DRAWABLE + "shape_vod_focused.xml")); - for (String body : new String[]{button, card}) { - assertTrue("首页两族焦点环必须同宽", body.contains("android:width=\"" + WIDTH + "\"")); - assertTrue("首页两族焦点环必须同色(主题 FOCUS 槽)", body.contains("android:color=\"?attr/tvFocusRing\"")); - } - // 消费方必须仍然分别指向这两个 selector,否则本断言就不再覆盖用户报告的控件。 - assertTrue(read("app/src/leanback/res/drawable/selector_item.xml").contains("@drawable/shape_item_focused")); - assertTrue(read("app/src/leanback/res/drawable/selector_vod.xml").contains("@drawable/shape_vod_focused")); - } - - // ------------------------------------------------------------ R2 卡片环圆角必须对齐宿主表面 - - /** - * 用户报告(2026-10-10 dev1 实机):「边框在卡片上的圆角部分匹配不完美,感觉是圆角比卡片小, - * 导致卡片的圆角还露出一节了」。 - * - *

根因是几何而非颜色:{@code shape_vod_focused} 是卡片的 foreground,而卡片的可见 - * 表面另有自己的 8dp 圆角(图片走 {@code @style/Vod.Grid} 上圆角 8dp、标题块走 - * {@code shape_vod_name} 下圆角 8dp;列表型宿主走 {@code shape_vod_list} 四角 8dp)。 - * 描边型 {@code }({@code GradientDrawable})把描边中心线画在「内缩 w/2、圆角半径仍取 - * 声明值」的路径上,所以环的 外边界圆角半径 = 声明值 + w/2。声明 8dp 时外边界为 - * 8dp + w/2 > 8dp,比卡片自身的弧更大,沿角落对角线卡片弧反而更靠外,露出 1–2px 月牙。 - * - *

所以卡片环的声明圆角必须是 {@code 宿主圆角 - w/2}(理论值 8 - 0.75 = 7.25dp),再向下留 - * 一点像素取整余量:描边宽度按整数像素向上取整(1.5dp @ density 3 = 4.5px → 5px), - * 若写满 7.25dp,在部分密度下环外边界会超出宿主弧 0.25–0.5px,仍会露出亚像素月牙; - * 生产取值 7dp 在 density 1/1.75/2/2.353/3/4 下均为 0 溢出(见 - * {@code TvAppSurfaceFocusRingDeviceTest.cardRingOuterBoundaryContainsEveryCardSurfacePixel})。 - * 由于全部 6 个 {@code selector_vod} 宿主的可见表面都是 8dp,单一环仍可覆盖全部宿主—— - * 这里的断言就是在钉死这条几何关系,并在宿主圆角分叉时立刻报警。 - */ - @Test - public void cardRingCornerRadiusIsTheHostRadiusMinusHalfTheRingWidth() throws Exception { - double width = dpValue(read(DIMENS), "webhtv_focus_ring_width"); - double ringRadius = dpValue(values(read(LEANBACK_DRAWABLE + "shape_vod_focused.xml")), "android:radius"); - - // 宿主的可见表面圆角:图片型宿主的上圆角、标题块的下圆角、列表型宿主的四角。 - double imageTop = dpValue(values(read("app/src/main/res/values/styles.xml")), "cornerSizeTopLeft"); - double nameBottom = dpValue(values(read(LEANBACK_DRAWABLE + "shape_vod_name.xml")), "android:bottomLeftRadius"); - double listAll = dpValue(values(read(LEANBACK_DRAWABLE + "shape_vod_list.xml")), "android:radius"); - - assertTrue("selector_vod 的 6 个宿主表面必须同圆角,否则一个环无法同时对齐(图片上圆角=" - + imageTop + "dp,标题块下圆角=" + nameBottom + "dp,列表四角=" + listAll + "dp)", - imageTop == nameBottom && nameBottom == listAll); - double ideal = imageTop - width / 2; - assertTrue("卡片环的声明圆角必须取 宿主圆角 - 环宽/2(" + ideal + "dp)再向下留像素取整余量," - + "且不得超过理论值:实际 " + ringRadius + "dp(环宽 " + width + "dp)", - ringRadius <= ideal + 0.01 && ringRadius >= ideal - 0.5); - } - - /** 从 XML 文本里取出某个属性/dimen 的 dp 数值。 */ - private static double dpValue(String xml, String key) { - java.util.regex.Matcher matcher = java.util.regex.Pattern - .compile(java.util.regex.Pattern.quote(key) + "[^>]*?>?([0-9.]+)dp") - .matcher(xml); - assertTrue("必须能在 XML 里找到 " + key + " 的 dp 取值", matcher.find()); - return Double.parseDouble(matcher.group(1)); - } - - // ------------------------------------------------------------ R5 前景环圆角必须对齐宿主表面 - - /** - * 同一类缺陷在详情页/播放页的实例(2026-10-10 实机复核):环作为卡片的 foreground 时, - * 环外边界圆角半径 = 声明值 + w/2(见 §12.2 的几何推导)。若声明值直接写宿主圆角, - * 外边界就会比宿主大 w/2,宿主圆角在环外露出一段;宿主是直角时更明显。 - * - *

本测试把「声明值 = 宿主圆角 − w/2(下限 0)」钉死到四个宿主: - *

    - *
  • {@code selector_episode_card}(焦点 3dp)→ 宿主 {@code adapter_episode_card} 的 - * CardView {@code @dimen/webhtv_card_radius_default} = 8dp;
  • - *
  • {@code selector_tmdb_media_focus}(焦点 3dp)→ 宿主 {@code adapter_tmdb_video}/ - * {@code adapter_tmdb_photo} 的 MaterialCardView 8dp;
  • - *
  • {@code selector_tmdb_cast_focus}(焦点 3dp)→ 宿主 {@code adapter_tmdb_cast} 的 - * {@code @dimen/webhtv_card_radius_large} = 12dp;
  • - *
  • {@code shape_episode_photo_focused}(焦点 3dp)→ 宿主 {@code dialog_episode_detail} - * 的 {@code @id/stillCard} CardView 8dp;
  • - *
  • {@code shape_video_focused}(宽度走 token)→ 宿主是普通 FrameLayout(直角),故为 0dp。
  • - *
- * - *

栅格化端到端断言(真实宿主 inflate + 环栅格化后数溢出像素)在 - * {@code TvAppSurfaceFocusRingDeviceTest.cardRingOuterBoundaryContainsItsHostSurface}。 - */ - @Test - public void foregroundRingsDeclareTheirHostCornerMinusHalfTheRingWidth() throws Exception { - // {环文件, 该状态的环宽 dp, 宿主圆角 dp, 宿主来源说明, 理想声明值 dp} - // 理想值 = 宿主圆角 − 环宽/2;实测(Robolectric 栅格化,真实宿主 inflate)在各档密度下 - // 0 溢出的最小声明值就是该理想值(例如 8dp 宿主 + 3dp 环 = 6.5dp)。 - Object[][] rings = { - {"app/src/main/res/drawable/selector_episode_card.xml", 3.0, 8.0, 6.0, - "adapter_episode_card CardView cardCornerRadius=webhtv_card_radius_default"}, - {"app/src/main/res/drawable/selector_tmdb_media_focus.xml", 3.0, 8.0, 6.0, - "adapter_tmdb_video/photo MaterialCardView cardCornerRadius=webhtv_card_radius_default"}, - {"app/src/main/res/drawable/selector_tmdb_cast_focus.xml", 3.0, 12.0, 9.5, - "adapter_tmdb_cast MaterialCardView cardCornerRadius=webhtv_card_radius_large"}, - {"app/src/main/res/drawable/shape_episode_photo_focused.xml", 3.0, 8.0, 6.0, - "dialog_episode_detail stillCard CardView cardCornerRadius=8dp"}, - }; - for (Object[] entry : rings) { - String path = (String) entry[0]; - double width = (Double) entry[1]; - double host = (Double) entry[2]; - double expected = (Double) entry[3]; - double declared = firstCornerRadiusDp(values(read(path))); - double ideal = Math.max(host - width / 2, 0); - assertEquals(path + " 的焦点态声明圆角必须等于 宿主圆角 − 环宽/2 = " + ideal + "dp(宿主 " - + host + "dp 来自 " + entry[4] + "),实测 0 溢出的最小取值;实际 " + declared + "dp", - expected, declared, 0.01); - } - - // 当前态(2dp)同样要减自己的 w/2:8dp 宿主 → 7dp 理论、实测 6dp 起 0 溢出(取 6dp); - // 12dp 宿主 → 11dp 理论、实测 10dp 起 0 溢出(取 10dp)。 - assertEquals("选集卡当前态声明圆角", 6.0, - secondCurrentStateCornerRadiusDp(values(read("app/src/main/res/drawable/selector_episode_card.xml"))), 0.01); - assertEquals("剧照/相关视频当前态声明圆角", 6.0, - secondCurrentStateCornerRadiusDp(values(read("app/src/main/res/drawable/selector_tmdb_media_focus.xml"))), 0.01); - assertEquals("演员卡当前态声明圆角", 9.5, - secondCurrentStateCornerRadiusDp(values(read("app/src/main/res/drawable/selector_tmdb_cast_focus.xml"))), 0.01); - - // 直角宿主:播放页画面框是普通 FrameLayout(无圆角),环必须是 0dp, - // 否则环外边界圆角(w/2 + 声明值)比黑底直角还大,黑角会露在环外。 - double videoRing = firstCornerRadiusDp(values(read(LEANBACK_DRAWABLE + "shape_video_focused.xml"))); - assertEquals("播放页画面框是直角宿主,环声明圆角必须是 0dp", 0.0, videoRing, 0.01); - } - - /** 取 XML 里第一个 {@code android:radius} 的 dp 值(缺省视为 0,例如只写了 per-corner)。 */ - private static double firstCornerRadiusDp(String xml) { - java.util.regex.Matcher matcher = java.util.regex.Pattern - .compile("android:radius=\"([0-9.]+)dp\"").matcher(xml); - return matcher.find() ? Double.parseDouble(matcher.group(1)) : 0.0; - } - - /** 取 XML 里第二个 {@code android:radius} 的 dp 值(这些 selector 的第二段就是当前态)。 */ - private static double secondCurrentStateCornerRadiusDp(String xml) { - java.util.regex.Matcher matcher = java.util.regex.Pattern - .compile("android:radius=\"([0-9.]+)dp\"").matcher(xml); - assertTrue("必须至少有两段带圆角的 shape", matcher.find() && matcher.find()); - return Double.parseDouble(matcher.group(1)); - } - - /** - * 集数名可读性 + 卡片两行文字的层级。 - * - *

历史:这一行此前用 {@code ?attr/colorOnSurfaceVariant}(浅色表 #44474F)画在深色 - * {@code shape_vod_name} 条带上,实机对比度只有 1.35:1,用户报告「集数名称一栏字体 - * 颜色不对看不清」,于是被改成与刷名相同的纯白(条带上 6.89:1)。 - * - *

代价是卡片两行完全同色,标题与集数只剩字号差别。本次用「先把承载色带压暗、再给次级 - * 文字一个语义角色」解决:条带由 20% 黑改为 40% 黑后,刷名(纯白)升到 9.82:1,集数行 - * ({@code webhtvColorOnWallpaperVariant})为 6.99:1——仍高于被替换掉的那个纯白取值, - * 因此这是「既分层级、又不降低可读性」的改动,不是把 1.35:1 的缺陷放回来。 - * - *

本测试钉住三件事:刷名保持纯白、集数行走次级角色、条带保持 40% 黑。最后一条尤其重要: - * 一旦有人把条带调回 20%,集数行的对比度会掉到 5.12:1,必须在这里改断言并重新核算, - * 而不是悄悄放行。 - */ - @Test - public void leanbackCardEpisodeLineIsTheSecondaryRoleOnTheDarkenedBand() throws Exception { - String layout = read("app/src/leanback/res/layout/adapter_vod.xml"); - String nameColour = textColourOf(layout, "name"); - String remarkColour = textColourOf(layout, "remark"); - assertEquals("刷名必须保持纯白(40% 条带上 9.82:1)", - "?attr/webhtvColorOnWallpaper", nameColour); - assertEquals("集数行必须走壁纸次级角色(40% 条带上 6.99:1)", - "?attr/webhtvColorOnWallpaperVariant", remarkColour); - assertFalse("集数行不允许再用 colorOnSurfaceVariant(深色条带上仅 1.35:1)", - remarkColour.contains("colorOnSurfaceVariant")); - assertFalse("集数行不得与刷名同色,否则两行又会失去层级", nameColour.equals(remarkColour)); - assertTrue("卡片信息条带必须保持 40% 黑,否则集数行的次级色会跌破 4.5:1", - read("app/src/leanback/res/drawable/shape_vod_name.xml").contains("@color/black_40")); - } - - /** 取某个 {@code @+id/xxx} 控件上的 {@code android:textColor}。 */ - private static String textColourOf(String layout, String id) { - java.util.regex.Matcher matcher = java.util.regex.Pattern - .compile("@\\+id/" + java.util.regex.Pattern.quote(id) + "\"(.*?)(?:/>|播放页按钮的边框太粗了,按钮和播放器选中后的边框颜色也不统一, - * 请统一且都受到主题色彩控制 - * - *

实机实测(1920×1080,2.35px/dp):播放页同时存在两族环—— - * {@code selector_chip}(14+26 个 Control 按钮)走 1.5dp 写死白, - * {@code selector_video_item}(11 个文本按钮)走 3dp {@code ?attr/tvFocusRing}, - * 而「当前播放」走 2dp 写死绿 {@code #2CC56F};同一屏上三种宽度、三种颜色。 - * - *

本测试把播放页的两族环钉死(宿主分层:谁叠在视频/固定玻璃上,谁在页面背景上): - *

    - *
  1. 所有播放页焦点/当前态环的宽度都引用 {@code @dimen/webhtv_focus_ring_width};
  2. - *
  3. 视频层族(控制条芯片、控制面板按钮、画面框、字幕/直播环)走 - * {@code ?attr/tvPlayerRing} / {@code ?attr/tvPlayerCurrentRing};
  4. - *
  5. 应用表面族({@code selector_video_item}:简介/短显/搜索按钮行、集数表头、 - * 线路/清晰度/数组分段/分段/选集/快捷源芯片)走 {@code ?attr/tvFocusRing} / - * {@code ?attr/tvCurrentRing},因为它们画在页面背景上而不是视频/玻璃上;
  6. - *
  7. 复用这批芯片布局的两处固定玻璃对话框在子树内用 - * {@code ThemeOverlay.WebHTV.GlassFocusRings} 把两项重绑回玻璃可读色。
  8. - *
- * - *

为什么视频层族不能用 {@code ?attr/tvFocusRing}:控制条/控制面板是固定深靛 - * 玻璃({@code #E62F315E→#CC303463},两张表里都是深色),浅色表 FOCUS 深蓝 - * {@code #0B57D0} 画在它上面只有 1.83:1(dev1 实机实测)。所以 {@code tvPlayerRing} - * 在**浅色表**是 {@code ThemeResolver} 用 {@code readableAccent} 在三档玻璃上夹取到 - * ≥3:1(WCAG 2.2 SC 1.4.11 非文本门槛)后的派生色,保持用户 FOCUS / playerCurrent 槽 - * 的色相与彩度、只走明度;深色表两类宿主的底色都是深色,因此直接取与应用表面 - * 焦点环同值的近白——否则同一页会同时出现「应用表面近白 + 视频层灰蓝」两种高亮 - * (用户报告 2026-10-11「播放页存在多个不同颜色的高亮效果」)。 - */ - @Test - public void playerFocusRingsShareOneWidthAndAThemeControlledColour() throws Exception { - // {文件, 必须出现的环色属性} - // 视频层族:宿主是叠在视频画面或固定深色玻璃上的控件。 - String[][] rings = { - {"shape_chip_focused.xml", "?attr/tvPlayerRing"}, - {"shape_chip_round_focused.xml", "?attr/tvPlayerRing"}, - {"shape_video_focused.xml", "?attr/tvPlayerRing"}, - {"shape_subtitle_focused.xml", "?attr/tvPlayerRing"}, - {"shape_subtitle_pressed.xml", "?attr/tvPlayerRing"}, - {"shape_live_focused.xml", "?attr/tvPlayerRing"}, - {"selector_control_sheet_button.xml", "?attr/tvPlayerRing"}, - }; - for (String[] entry : rings) { - String path = LEANBACK_DRAWABLE + entry[0]; - String body = values(read(path)); - assertTrue(path + " 的焦点环宽度必须引用唯一 token " + WIDTH, - body.contains("android:width=\"" + WIDTH + "\"")); - assertTrue(path + " 的焦点环色必须走视频层主题属性 " + entry[1], body.contains(entry[1])); - assertFalse(path + " 不允许再写死白色焦点环", - body.contains("android:color=\"@color/white\"") || body.contains("android:color=\"#FFFFFF\"")); - } - - // 应用表面族:selector_video_item 的宿主是播放页内容区的按钮/芯片(简介/短显/搜索 - // 按钮行、集数表头、线路/清晰度/数组分段/分段/选集/快捷源行),它们画在页面背景上, - // 不是叠在视频或固定玻璃上——2026-10-11 用户报告「播放页存在多个不同颜色的高亮效果」, - // 这批芯片此前错用了视频层灰蓝环(#7695C5),现已归到调色板 FOCUS 槽。 - String chip = values(read(LEANBACK_DRAWABLE + "selector_video_item.xml")); - assertTrue(LEANBACK_DRAWABLE + "selector_video_item.xml 的焦点环必须走应用表面属性 ?attr/tvFocusRing", - chip.contains("?attr/tvFocusRing")); - assertTrue(LEANBACK_DRAWABLE + "selector_video_item.xml 的当前态环必须走应用表面属性 ?attr/tvCurrentRing", - chip.contains("?attr/tvCurrentRing")); - assertFalse("播放页内容区芯片不允许再用视频层环色(会造成同页两种高亮色)", - chip.contains("?attr/tvPlayerRing") || chip.contains("?attr/tvPlayerCurrentRing")); - - // 宿主是固定深色玻璃的两处对话框(复用同一批芯片布局)必须在子树内把两项重绑回玻璃可读色, - // 否则浅色表(FOCUS = 深蓝 #0B57D0)的芯片环画在固定玻璃上只有 1.83:1。 - String overlay = "ThemeOverlay.WebHTV.GlassFocusRings"; - String leanbackStyles = read("app/src/leanback/res/values/styles.xml"); - assertTrue("leanback styles.xml 必须定义玻璃宿主覆盖层 " + overlay, leanbackStyles.contains("